add care workflow and schedule management
This commit is contained in:
@@ -7,6 +7,9 @@
|
||||
.env
|
||||
.codex/
|
||||
data/
|
||||
plant-manager/App_Data/
|
||||
core.*
|
||||
**/core.*
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { ActionsView } from './components/ActionsView';
|
||||
import { ActivitiesView } from './components/ActivitiesView';
|
||||
import { CareView } from './components/CareView';
|
||||
import { FlagsView } from './components/FlagsView';
|
||||
import { GroupsView } from './components/GroupsView';
|
||||
import { HomeView } from './components/HomeView';
|
||||
@@ -35,6 +36,7 @@ export function App() {
|
||||
loadPlantsAndCareTasks,
|
||||
loadRecipesAndResources,
|
||||
loadTaxaAndPlants,
|
||||
plantCareSchedules,
|
||||
plantFlagDefinitions,
|
||||
plantGroups,
|
||||
plantLocations,
|
||||
@@ -64,7 +66,6 @@ export function App() {
|
||||
activePlantGroup,
|
||||
activeRecipe,
|
||||
activeResource,
|
||||
activeTaxon,
|
||||
activityForm,
|
||||
bulkScheduleForm,
|
||||
cancelEditing,
|
||||
@@ -75,7 +76,8 @@ export function App() {
|
||||
cancelEditingPlantGroup,
|
||||
cancelEditingRecipe,
|
||||
cancelEditingResource,
|
||||
cancelEditingTaxon,
|
||||
cancelEditingSchedule,
|
||||
editingScheduleId,
|
||||
flagDefinitionForm,
|
||||
form,
|
||||
isActionEditorOpen,
|
||||
@@ -86,7 +88,6 @@ export function App() {
|
||||
isPlantGroupEditorOpen,
|
||||
isRecipeEditorOpen,
|
||||
isResourceEditorOpen,
|
||||
isTaxonEditorOpen,
|
||||
locationForm,
|
||||
openPlantDetail,
|
||||
plantFlagForm,
|
||||
@@ -118,8 +119,6 @@ export function App() {
|
||||
startAddingPlantGroup,
|
||||
startAddingRecipe,
|
||||
startAddingResource,
|
||||
startAddingTaxon,
|
||||
startAddingTaxonFromPlantInfo,
|
||||
startEditingAction,
|
||||
startEditingActivity,
|
||||
startEditingFlagDefinition,
|
||||
@@ -127,8 +126,8 @@ export function App() {
|
||||
startEditingPlantGroup,
|
||||
startEditingRecipe,
|
||||
startEditingResource,
|
||||
startEditingTaxon,
|
||||
taxonForm,
|
||||
startEditingSchedule,
|
||||
startNewSchedule,
|
||||
updateActionForm,
|
||||
updateActivityForm,
|
||||
updateBulkScheduleForm,
|
||||
@@ -139,7 +138,6 @@ export function App() {
|
||||
updatePlantGroupForm,
|
||||
updateRecipeForm,
|
||||
updateResourceForm,
|
||||
updateTaxonForm,
|
||||
} = editors;
|
||||
const {
|
||||
assignFlagToSelectedPlant,
|
||||
@@ -147,11 +145,13 @@ export function App() {
|
||||
catalogImportResult,
|
||||
completeBulkTasks,
|
||||
completeTask,
|
||||
dismissCare,
|
||||
exportSpreadsheet,
|
||||
isExporting,
|
||||
isImportingCatalog,
|
||||
isSearchingPlantInfo,
|
||||
isSaving,
|
||||
logCare,
|
||||
importTaxonFromPlantInfo,
|
||||
hasSearchedPlantInfo,
|
||||
plantInfoQuery,
|
||||
@@ -159,7 +159,7 @@ export function App() {
|
||||
removeAction,
|
||||
removeActivity,
|
||||
removeAssignedPlantFlag,
|
||||
removeBulkSchedule,
|
||||
removeSchedule,
|
||||
removeFlagDefinition,
|
||||
removeLocation,
|
||||
removePlant,
|
||||
@@ -177,7 +177,6 @@ export function App() {
|
||||
savePlantGroup,
|
||||
saveRecipe,
|
||||
saveResource,
|
||||
saveTaxon,
|
||||
searchTaxonInfo,
|
||||
previewCatalogImportFile,
|
||||
setPlantInfoQuery,
|
||||
@@ -238,6 +237,13 @@ export function App() {
|
||||
|
||||
<div className="nav-group">
|
||||
<h3>Care</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'care' ? 'page' : undefined}
|
||||
onClick={() => setView('care')}
|
||||
>
|
||||
Care
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'schedules' ? 'page' : undefined}
|
||||
@@ -261,7 +267,7 @@ export function App() {
|
||||
aria-current={view === 'taxa' ? 'page' : undefined}
|
||||
onClick={() => setView('taxa')}
|
||||
>
|
||||
Plant Taxa
|
||||
Taxa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -303,7 +309,7 @@ export function App() {
|
||||
aria-current={view === 'flags' ? 'page' : undefined}
|
||||
onClick={() => setView('flags')}
|
||||
>
|
||||
Plant Flags
|
||||
Flags
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -365,34 +371,41 @@ export function App() {
|
||||
error={error}
|
||||
form={bulkScheduleForm}
|
||||
groups={plantGroups}
|
||||
editingScheduleId={editingScheduleId}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
plants={plants}
|
||||
schedules={plantCareSchedules}
|
||||
onCancel={cancelEditingSchedule}
|
||||
onDelete={removeSchedule}
|
||||
onEdit={startEditingSchedule}
|
||||
onFieldChange={updateBulkScheduleForm}
|
||||
onRemove={() => void removeBulkSchedule()}
|
||||
onNew={startNewSchedule}
|
||||
onSave={() => void saveBulkSchedule()}
|
||||
/>
|
||||
) : view === 'care' ? (
|
||||
<CareView
|
||||
activities={careActivities}
|
||||
careTasks={careTasks}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
plants={plants}
|
||||
onDismissCare={(payload) => void dismissCare(payload)}
|
||||
onLogCare={(payload, requireDueSchedule) => void logCare(payload, requireDueSchedule)}
|
||||
/>
|
||||
) : view === 'taxa' ? (
|
||||
<TaxaView
|
||||
activeTaxonName={activeTaxon?.name}
|
||||
error={error}
|
||||
form={taxonForm}
|
||||
hasSearched={hasSearchedPlantInfo}
|
||||
isEditorOpen={isTaxonEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
selectedTaxon={selectedTaxon}
|
||||
taxa={plantTaxa}
|
||||
onCancel={cancelEditingTaxon}
|
||||
onCloseDetail={() => setSelectedTaxonId(null)}
|
||||
onDelete={(taxon) => void removeTaxon(taxon)}
|
||||
onEdit={startEditingTaxon}
|
||||
onFieldChange={updateTaxonForm}
|
||||
onImportResult={(result) => void importTaxonFromPlantInfo(result)}
|
||||
onNew={startAddingTaxon}
|
||||
onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)}
|
||||
onPrefillResult={startAddingTaxonFromPlantInfo}
|
||||
onSave={() => void saveTaxon()}
|
||||
onSearch={(query) => void searchTaxonInfo(query)}
|
||||
onSearchQueryChange={setPlantInfoQuery}
|
||||
plantInfoResults={plantInfoResults}
|
||||
@@ -537,12 +550,10 @@ export function App() {
|
||||
<HomeView
|
||||
careTasks={careTasks}
|
||||
error={error}
|
||||
groups={plantGroups}
|
||||
isLoading={isLoading}
|
||||
plants={plants}
|
||||
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)}
|
||||
onCompleteTask={(task) => void completeTask(task)}
|
||||
onOpenPlant={openPlantDetail}
|
||||
onOpenCare={() => setView('care')}
|
||||
onOpenPlants={() => setView('plant-management')}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
BulkCompleteCareTasksPayload,
|
||||
CareTask,
|
||||
BulkPlantCareSchedulePayload,
|
||||
DismissCareTasksPayload,
|
||||
Recipe,
|
||||
RecipePayload,
|
||||
Plant,
|
||||
PlantCareScheduleRule,
|
||||
PlantInfoSearchResult,
|
||||
AssignPlantFlagPayload,
|
||||
PlantPayload,
|
||||
@@ -23,7 +25,6 @@ import type {
|
||||
PlantLocation,
|
||||
PlantLocationPayload,
|
||||
PlantTaxon,
|
||||
PlantTaxonPayload,
|
||||
} from './domain';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
@@ -157,13 +158,6 @@ export async function deletePlantGroup(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createPlantTaxon(payload: PlantTaxonPayload) {
|
||||
return request<PlantTaxon>('/api/plant-taxa', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function importPlantTaxon(payload: PlantInfoSearchResult) {
|
||||
return request<PlantTaxon>('/api/plant-taxa/import', {
|
||||
method: 'POST',
|
||||
@@ -171,13 +165,6 @@ export async function importPlantTaxon(payload: PlantInfoSearchResult) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) {
|
||||
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlantTaxon(id: number) {
|
||||
return request<void>(`/api/plant-taxa/${id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -347,23 +334,47 @@ export async function getCareTasks() {
|
||||
return request<CareTask[]>('/api/care-tasks/upcoming');
|
||||
}
|
||||
|
||||
export async function savePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
|
||||
return request<{ updated: number }>('/api/plant-care-schedules/bulk', {
|
||||
export async function getPlantCareSchedules() {
|
||||
return request<PlantCareScheduleRule[]>('/api/plant-care-schedules');
|
||||
}
|
||||
|
||||
export async function createPlantCareSchedule(payload: BulkPlantCareSchedulePayload) {
|
||||
return request<PlantCareScheduleRule>('/api/plant-care-schedules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
|
||||
return request<{ removed: number }>('/api/plant-care-schedules/bulk-remove', {
|
||||
method: 'POST',
|
||||
export async function updatePlantCareSchedule(id: number, payload: BulkPlantCareSchedulePayload) {
|
||||
return request<PlantCareScheduleRule>(`/api/plant-care-schedules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlantCareSchedule(id: number) {
|
||||
return request<void>(`/api/plant-care-schedules/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
|
||||
return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function dismissCareTasksBulk(payload: DismissCareTasksPayload) {
|
||||
return request<{ dismissed: number }>('/api/care-tasks/dismiss-bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createActionLogsBulk(payload: BulkCompleteCareTasksPayload) {
|
||||
return request<{ completed: number }>('/api/action-logs/bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionResource, CareAction, CareActivity } from '../domain';
|
||||
import type { ActionResource, CareAction, CareActivity, CareActivityRecipeComponent } from '../domain';
|
||||
import type { ActivityFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
@@ -147,14 +147,32 @@ export function ActivitiesView({
|
||||
<h4>{activityAction.name}</h4>
|
||||
<p>{activityAction.description ?? 'No description'}</p>
|
||||
{activityAction.resources.length > 0 ? (
|
||||
<p>
|
||||
{activityAction.resources.map((resource) => {
|
||||
const amount = resource.quantity === null
|
||||
? ''
|
||||
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
||||
return `${resource.name}${amount}`;
|
||||
}).join(', ')}
|
||||
</p>
|
||||
<div className="activity-resource-detail-list">
|
||||
{activityAction.resources.map((resource) => (
|
||||
<div className="activity-resource-detail" key={resource.actionResourceId}>
|
||||
<p>{formatActivityResource(resource)}</p>
|
||||
{resource.producedByRecipe ? (
|
||||
<div className="recipe-procedure">
|
||||
<h5>{resource.producedByRecipe.name}</h5>
|
||||
{resource.producedByRecipe.components.length > 0 ? (
|
||||
<ol>
|
||||
{resource.producedByRecipe.components.map((component) => (
|
||||
<li key={component.actionResourceId}>
|
||||
{formatRecipeComponent(component)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p>No recipe components configured.</p>
|
||||
)}
|
||||
{resource.producedByRecipe.notes ? (
|
||||
<p>{resource.producedByRecipe.notes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>No resources</p>
|
||||
)}
|
||||
@@ -406,3 +424,18 @@ function formatActivitySummary(activity: CareActivity) {
|
||||
})
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
function formatActivityResource(resource: CareActivity['actions'][number]['resources'][number]) {
|
||||
const amount = resource.quantity === null
|
||||
? ''
|
||||
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
||||
return `${resource.name}${amount}`;
|
||||
}
|
||||
|
||||
function formatRecipeComponent(component: CareActivityRecipeComponent) {
|
||||
const amount = component.quantity === null
|
||||
? ''
|
||||
: ` (${component.quantity}${component.unit ? ` ${component.unit}` : ''})`;
|
||||
const notes = component.notes ? ` - ${component.notes}` : '';
|
||||
return `${component.name}${amount}${notes}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { BulkCompleteCareTasksPayload, CareActivity, CareActivityActionResource, CareActivityRecipeComponent, CareTask, DismissCareTasksPayload, Plant } from '../domain';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type CareMode = 'due' | 'upcoming' | 'adHoc';
|
||||
|
||||
type CareViewProps = {
|
||||
activities: CareActivity[];
|
||||
careTasks: CareTask[];
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
plants: Plant[];
|
||||
onDismissCare: (payload: DismissCareTasksPayload) => void;
|
||||
onLogCare: (payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) => void;
|
||||
};
|
||||
|
||||
const modeOptions: { value: CareMode; label: string }[] = [
|
||||
{ value: 'due', label: 'Due' },
|
||||
{ value: 'upcoming', label: 'Upcoming' },
|
||||
{ value: 'adHoc', label: 'Ad Hoc' },
|
||||
];
|
||||
|
||||
export function CareView({
|
||||
activities,
|
||||
careTasks,
|
||||
error,
|
||||
isLoading,
|
||||
isSaving,
|
||||
plants,
|
||||
onDismissCare,
|
||||
onLogCare,
|
||||
}: CareViewProps) {
|
||||
const [mode, setMode] = useState<CareMode>('due');
|
||||
const [activityId, setActivityId] = useState('');
|
||||
const [selectedPlantIds, setSelectedPlantIds] = useState<string[]>([]);
|
||||
const [plantQuery, setPlantQuery] = useState('');
|
||||
const [performedOn, setPerformedOn] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [notes, setNotes] = useState('');
|
||||
const [resourceEdits, setResourceEdits] = useState<Record<number, { quantity: string; unit: string }>>({});
|
||||
|
||||
const modeTasks = careTasks.filter((task) => mode === 'due' ? task.status === 'due' : task.status === 'soon');
|
||||
const activityOptions = mode === 'adHoc'
|
||||
? activities
|
||||
: activities.filter((activity) => modeTasks.some((task) => task.careActivityId === activity.id));
|
||||
const selectedActivity = activities.find((activity) => String(activity.id) === activityId)
|
||||
?? activityOptions[0];
|
||||
const selectedActivityId = selectedActivity?.id ?? 0;
|
||||
const taskPlants = modeTasks.filter((task) => task.careActivityId === selectedActivityId);
|
||||
const visiblePlants = useMemo(
|
||||
() => filterPlants(mode === 'adHoc' ? plants : plantsForTasks(taskPlants, plants), plantQuery),
|
||||
[mode, plants, plantQuery, taskPlants],
|
||||
);
|
||||
const selectedPlantIdSet = new Set(selectedPlantIds);
|
||||
const activityResources = selectedActivity ? getActivityResources(selectedActivity) : [];
|
||||
|
||||
function togglePlant(plantId: number, checked: boolean) {
|
||||
const value = String(plantId);
|
||||
setSelectedPlantIds((current) => checked
|
||||
? [...new Set([...current, value])]
|
||||
: current.filter((id) => id !== value));
|
||||
}
|
||||
|
||||
function changeMode(nextMode: CareMode) {
|
||||
setMode(nextMode);
|
||||
setActivityId('');
|
||||
setSelectedPlantIds([]);
|
||||
setPlantQuery('');
|
||||
}
|
||||
|
||||
function changeActivity(nextActivityId: string) {
|
||||
setActivityId(nextActivityId);
|
||||
setSelectedPlantIds([]);
|
||||
setResourceEdits({});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!selectedActivity) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLogCare({
|
||||
careActivityId: selectedActivity.id,
|
||||
plantIds: selectedPlantIds.map((id) => Number(id)),
|
||||
performedOn,
|
||||
notes: notes.trim() || null,
|
||||
resources: activityResources.map((resource) => ({
|
||||
actionResourceId: resource.actionResourceId,
|
||||
quantity: normalizeQuantity(resourceEdits[resource.actionResourceId]?.quantity, resource.quantity),
|
||||
unit: resourceEdits[resource.actionResourceId]?.unit.trim() || resource.unit,
|
||||
})),
|
||||
}, mode === 'due');
|
||||
|
||||
setSelectedPlantIds([]);
|
||||
setNotes('');
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!selectedActivity) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDismissCare({
|
||||
careActivityId: selectedActivity.id,
|
||||
plantIds: selectedPlantIds.map((id) => Number(id)),
|
||||
dismissedOn: performedOn,
|
||||
notes: notes.trim() || null,
|
||||
});
|
||||
|
||||
setSelectedPlantIds([]);
|
||||
setNotes('');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummaryStrip ariaLabel="Care summary">
|
||||
<p>{error ?? 'Choose care to perform, review the procedure, then log the work.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="work-panel" aria-labelledby="care-workflow-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="care-workflow-heading">Care</h2>
|
||||
</div>
|
||||
<span className="schedule-count">{selectedPlantIds.length} selected</span>
|
||||
</div>
|
||||
|
||||
<div className="care-mode-switch" role="tablist" aria-label="Care mode">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === option.value}
|
||||
disabled={isSaving}
|
||||
onClick={() => changeMode(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
Activity
|
||||
<select
|
||||
disabled={isSaving || activityOptions.length === 0}
|
||||
value={selectedActivity ? String(selectedActivity.id) : ''}
|
||||
onChange={(event) => changeActivity(event.target.value)}
|
||||
>
|
||||
{activityOptions.length === 0 ? (
|
||||
<option value="">No activities available</option>
|
||||
) : null}
|
||||
{activityOptions.map((activity) => (
|
||||
<option key={activity.id} value={activity.id}>
|
||||
{activity.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Date
|
||||
<input
|
||||
disabled={isSaving}
|
||||
type="date"
|
||||
value={performedOn}
|
||||
onChange={(event) => setPerformedOn(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Search plants
|
||||
<input
|
||||
disabled={isSaving}
|
||||
type="search"
|
||||
value={plantQuery}
|
||||
onChange={(event) => setPlantQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="care-layout">
|
||||
<div className="work-panel care-subpanel" aria-labelledby="care-plants-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="care-plants-heading">Plants</h2>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={isSaving || visiblePlants.length === 0}
|
||||
onClick={() => {
|
||||
const allVisibleSelected = visiblePlants.every((plant) => selectedPlantIdSet.has(String(plant.id)));
|
||||
setSelectedPlantIds(allVisibleSelected
|
||||
? selectedPlantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
|
||||
: [...new Set([...selectedPlantIds, ...visiblePlants.map((plant) => String(plant.id))])]);
|
||||
}}
|
||||
>
|
||||
Select visible
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plant-list compact-plant-list">
|
||||
{!isLoading && visiblePlants.length === 0 ? (
|
||||
<p className="empty-state">{mode === 'adHoc' ? 'No plants match that search.' : 'No care tasks in this queue.'}</p>
|
||||
) : null}
|
||||
|
||||
{visiblePlants.map((plant) => {
|
||||
const task = taskPlants.find((item) => item.plantId === plant.id);
|
||||
return (
|
||||
<label className="plant-row check-row" key={plant.id}>
|
||||
<span>
|
||||
<input
|
||||
checked={selectedPlantIdSet.has(String(plant.id))}
|
||||
disabled={isSaving}
|
||||
type="checkbox"
|
||||
onChange={(event) => togglePlant(plant.id, event.target.checked)}
|
||||
/>
|
||||
<strong>{plant.nickname}</strong>
|
||||
<small>{mode === 'adHoc' ? `${plant.taxon} - ${plant.location}` : task?.due ?? plant.nextCare}</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="work-panel care-subpanel" aria-labelledby="care-procedure-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="care-procedure-heading">Procedure</h2>
|
||||
</div>
|
||||
|
||||
{!selectedActivity ? (
|
||||
<p className="empty-state">Select an activity.</p>
|
||||
) : (
|
||||
<div className="detail-list">
|
||||
{selectedActivity.actions.map((action) => (
|
||||
<div className="detail-row" key={action.careActionId}>
|
||||
<div>
|
||||
<h4>{action.name}</h4>
|
||||
<p>{action.description ?? 'No description'}</p>
|
||||
{action.resources.length === 0 ? (
|
||||
<p>No resources configured.</p>
|
||||
) : action.resources.map((resource) => (
|
||||
<div className="activity-resource-detail" key={resource.actionResourceId}>
|
||||
<p>{formatResource(resource)}</p>
|
||||
<div className="care-resource-edit">
|
||||
<input
|
||||
aria-label={`${resource.name} quantity`}
|
||||
disabled={isSaving}
|
||||
type="number"
|
||||
min="0"
|
||||
value={resourceEdits[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity)}
|
||||
onChange={(event) => setResourceEdits((current) => ({
|
||||
...current,
|
||||
[resource.actionResourceId]: {
|
||||
quantity: event.target.value,
|
||||
unit: current[resource.actionResourceId]?.unit ?? resource.unit ?? '',
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<input
|
||||
aria-label={`${resource.name} unit`}
|
||||
disabled={isSaving}
|
||||
value={resourceEdits[resource.actionResourceId]?.unit ?? resource.unit ?? ''}
|
||||
onChange={(event) => setResourceEdits((current) => ({
|
||||
...current,
|
||||
[resource.actionResourceId]: {
|
||||
quantity: current[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity),
|
||||
unit: event.target.value,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{resource.producedByRecipe ? (
|
||||
<div className="recipe-procedure">
|
||||
<h5>{resource.producedByRecipe.name}</h5>
|
||||
<ol>
|
||||
{resource.producedByRecipe.components.map((component) => (
|
||||
<li key={component.actionResourceId}>{formatRecipeComponent(component)}</li>
|
||||
))}
|
||||
</ol>
|
||||
{resource.producedByRecipe.notes ? <p>{resource.producedByRecipe.notes}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="care-notes">
|
||||
Notes
|
||||
<textarea
|
||||
disabled={isSaving}
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-action"
|
||||
type="button"
|
||||
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
{isSaving ? 'Saving' : 'Log'}
|
||||
</button>
|
||||
{mode === 'due' ? (
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
|
||||
onClick={dismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function plantsForTasks(tasks: CareTask[], plants: Plant[]) {
|
||||
const taskPlantIds = new Set(tasks.map((task) => task.plantId));
|
||||
return plants.filter((plant) => taskPlantIds.has(plant.id));
|
||||
}
|
||||
|
||||
function filterPlants(plants: Plant[], query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return plants;
|
||||
}
|
||||
|
||||
return plants.filter((plant) => `${plant.nickname} ${plant.taxon} ${plant.location}`.toLowerCase().includes(normalizedQuery));
|
||||
}
|
||||
|
||||
function getActivityResources(activity: CareActivity) {
|
||||
const resources = new Map<number, CareActivityActionResource>();
|
||||
for (const action of activity.actions) {
|
||||
for (const resource of action.resources) {
|
||||
if (!resources.has(resource.actionResourceId)) {
|
||||
resources.set(resource.actionResourceId, resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...resources.values()];
|
||||
}
|
||||
|
||||
function normalizeQuantity(value: string | undefined, fallback: number | null) {
|
||||
if (value === undefined || value.trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function formatResource(resource: CareActivityActionResource) {
|
||||
const quantity = resource.quantity === null ? '' : `${resource.quantity} `;
|
||||
const unit = resource.unit ? `${resource.unit} ` : '';
|
||||
return `${quantity}${unit}${resource.name}`.trim();
|
||||
}
|
||||
|
||||
function formatRecipeComponent(component: CareActivityRecipeComponent) {
|
||||
const quantity = component.quantity === null ? '' : `${component.quantity}`;
|
||||
const unit = component.unit ?? '';
|
||||
return `${component.name}: ${quantity}${unit ? ` ${unit}` : ''}`.trim();
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null) {
|
||||
return value === null ? '' : String(value);
|
||||
}
|
||||
@@ -1,332 +1,124 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CareTask, Plant, PlantGroup } from '../domain';
|
||||
import { PlantCard } from './PlantCard';
|
||||
import type { ActionLog, CareTask, Plant } from '../domain';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type HomeViewProps = {
|
||||
careTasks: CareTask[];
|
||||
error: string | null;
|
||||
groups: PlantGroup[];
|
||||
isLoading: boolean;
|
||||
plants: Plant[];
|
||||
onCompleteBulkTasks: (tasks: CareTask[]) => void;
|
||||
onCompleteTask: (task: CareTask) => void;
|
||||
onOpenPlant: (plant: Plant) => void;
|
||||
onOpenCare: () => void;
|
||||
onOpenPlants: () => void;
|
||||
};
|
||||
|
||||
export function HomeView({
|
||||
careTasks,
|
||||
error,
|
||||
groups,
|
||||
isLoading,
|
||||
plants,
|
||||
onCompleteBulkTasks,
|
||||
onCompleteTask,
|
||||
onOpenPlant,
|
||||
onOpenCare,
|
||||
onOpenPlants,
|
||||
}: HomeViewProps) {
|
||||
const dueTaskGroups = groupDueTasks(careTasks);
|
||||
const weekDays = useMemo(() => getWeekDays(), []);
|
||||
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date()));
|
||||
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0];
|
||||
const selectedTasks = getTasksForDate(careTasks, selectedDate);
|
||||
const groupDueTaskGroups = getGroupDueTaskGroups(careTasks, groups);
|
||||
const dueCount = careTasks.filter((task) => task.status === 'due').length;
|
||||
const upcomingCount = careTasks.filter((task) => task.status === 'soon').length;
|
||||
const activeFlagCount = plants.reduce(
|
||||
(count, plant) => count + plant.flags.filter((flag) => flag.resolvedOn === null).length,
|
||||
0,
|
||||
);
|
||||
const recentLogs = getRecentLogs(plants);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SummaryStrip ariaLabel="Dashboard summary">
|
||||
<p>{error ?? 'Start with the plants that need attention now.'}</p>
|
||||
<p>{error ?? 'Quick status for care and collection health.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="work-panel" aria-labelledby="calendar-heading">
|
||||
<div className="dashboard-panel-grid">
|
||||
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-care-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="calendar-heading">Care Calendar</h2>
|
||||
<span className="schedule-count">{selectedTasks.length} selected</span>
|
||||
<h2 id="dashboard-care-heading">Care</h2>
|
||||
</div>
|
||||
<div className="dashboard-stat-grid">
|
||||
<DashboardStat label="Due" value={dueCount} />
|
||||
<DashboardStat label="Upcoming" value={upcomingCount} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" onClick={onOpenCare}>
|
||||
Open Care
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-plants-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="dashboard-plants-heading">Plants</h2>
|
||||
</div>
|
||||
<div className="dashboard-stat-grid">
|
||||
<DashboardStat label="Total" value={plants.length} />
|
||||
<DashboardStat label="Flagged" value={activeFlagCount} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" onClick={onOpenPlants}>
|
||||
Open Plant Management
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="week-strip" role="tablist" aria-label="Care tasks by day">
|
||||
{weekDays.map((day) => {
|
||||
const dayTasks = getTasksForDate(careTasks, day.dateKey);
|
||||
const hasDueTasks = dayTasks.some((task) => task.status === 'due');
|
||||
<section className="work-panel" aria-labelledby="recent-care-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="recent-care-heading">Recent Care</h2>
|
||||
</div>
|
||||
|
||||
<div className="detail-list">
|
||||
{!isLoading && recentLogs.length === 0 ? (
|
||||
<p className="empty-state">No care has been logged yet.</p>
|
||||
) : null}
|
||||
|
||||
{recentLogs.map((log) => (
|
||||
<article className="detail-row" key={log.id}>
|
||||
<div>
|
||||
<h4>{log.action}</h4>
|
||||
<p>{log.plantName} - {formatDate(log.performedOn)}</p>
|
||||
{log.notes ? <p>{log.notes}</p> : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardStat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<button
|
||||
className="week-day"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={day.dateKey === selectedDate}
|
||||
key={day.dateKey}
|
||||
onClick={() => setSelectedDate(day.dateKey)}
|
||||
>
|
||||
<span>{day.label}</span>
|
||||
<strong>{day.dayNumber}</strong>
|
||||
<small>{dayTasks.length}</small>
|
||||
{hasDueTasks ? <span className="week-day-alert" aria-hidden="true" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="dashboard-stat">
|
||||
<strong>{value}</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="task-list">
|
||||
<p className="task-list-label">{selectedDay?.heading ?? 'Selected day'}</p>
|
||||
{!isLoading && selectedTasks.length === 0 ? (
|
||||
<p className="empty-state">No care scheduled for this day.</p>
|
||||
) : null}
|
||||
|
||||
{selectedTasks.map((task) => (
|
||||
<article className="task-row" key={`calendar-${task.id}`}>
|
||||
<span className={`status-dot ${task.status}`} />
|
||||
<div>
|
||||
<h3>{task.action}</h3>
|
||||
<p>{task.plantName} - {task.due}</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
disabled={task.status !== 'due'}
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="work-panel" aria-labelledby="care-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="care-heading">Due Care</h2>
|
||||
<button className="text-button" type="button">View all</button>
|
||||
</div>
|
||||
|
||||
<div className="task-list">
|
||||
{!isLoading && careTasks.length === 0 ? (
|
||||
<p className="empty-state">No care tasks yet.</p>
|
||||
) : null}
|
||||
|
||||
{dueTaskGroups.length > 0 ? (
|
||||
<>
|
||||
<p className="task-list-label">Bulk actions</p>
|
||||
{dueTaskGroups.map((group) => (
|
||||
<article className="task-row task-row-group" key={group.careActivityId}>
|
||||
<span className="status-dot due" />
|
||||
<div>
|
||||
<h3>{group.action}</h3>
|
||||
<p>
|
||||
{group.tasks.length} due - {formatPlantNames(group.tasks)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
Log all due
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{groupDueTaskGroups.length > 0 ? (
|
||||
<>
|
||||
<p className="task-list-label">Group actions</p>
|
||||
{groupDueTaskGroups.map((group) => (
|
||||
<article className="task-row task-row-group" key={`${group.groupId}-${group.careActivityId}`}>
|
||||
<span className="status-dot due" />
|
||||
<div>
|
||||
<h3>{group.groupName} / {group.action}</h3>
|
||||
<p>
|
||||
{group.tasks.length} due - {formatPlantNames(group.tasks)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
Log group
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{careTasks.length > 0 ? (
|
||||
<>
|
||||
<p className="task-list-label">Individual tasks</p>
|
||||
{careTasks.map((task) => (
|
||||
<article className="task-row" key={task.id}>
|
||||
<span className={`status-dot ${task.status}`} />
|
||||
<div>
|
||||
<h3>{task.action}</h3>
|
||||
<p>{task.plantName} - {task.due}</p>
|
||||
</div>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="work-panel" aria-labelledby="plants-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="plants-heading">My Plants</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-grid">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupDueTasks(tasks: CareTask[]) {
|
||||
const groups = new Map<number, { careActivityId: number; action: string; tasks: CareTask[] }>();
|
||||
for (const task of tasks) {
|
||||
if (task.status !== 'due') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = groups.get(task.careActivityId);
|
||||
if (group) {
|
||||
group.tasks.push(task);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(task.careActivityId, {
|
||||
careActivityId: task.careActivityId,
|
||||
action: task.action,
|
||||
tasks: [task],
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action));
|
||||
function getRecentLogs(plants: Plant[]) {
|
||||
return plants
|
||||
.flatMap((plant) => plant.actionLogs.map((log) => ({ ...log, plantName: log.plantName || plant.nickname })))
|
||||
.sort(compareLogs)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function getGroupDueTaskGroups(tasks: CareTask[], groups: PlantGroup[]) {
|
||||
const dueTasks = tasks.filter((task) => task.status === 'due');
|
||||
function compareLogs(left: ActionLog, right: ActionLog) {
|
||||
const dateComparison = right.performedOn.localeCompare(left.performedOn);
|
||||
if (dateComparison !== 0) {
|
||||
return dateComparison;
|
||||
}
|
||||
|
||||
return groups.flatMap((group) => {
|
||||
const groupPlantIds = new Set(group.plants.map((plant) => plant.id));
|
||||
return groupDueTasks(dueTasks.filter((task) => groupPlantIds.has(task.plantId)))
|
||||
.map((taskGroup) => ({
|
||||
...taskGroup,
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
}));
|
||||
});
|
||||
return right.id - left.id;
|
||||
}
|
||||
|
||||
function formatPlantNames(tasks: CareTask[]) {
|
||||
const names = tasks.map((task) => task.plantName);
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
function formatDate(value: string) {
|
||||
const [year, month, day] = value.split('-');
|
||||
if (!year || !month || !day) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`;
|
||||
}
|
||||
|
||||
function getWeekDays() {
|
||||
const today = new Date();
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + index);
|
||||
const dateKey = getDateKey(date);
|
||||
|
||||
return {
|
||||
dateKey,
|
||||
dayNumber: date.getDate().toString(),
|
||||
heading: index === 0
|
||||
? 'Today'
|
||||
: date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' }),
|
||||
label: index === 0
|
||||
? 'Today'
|
||||
: date.toLocaleDateString(undefined, { weekday: 'short' }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getTasksForDate(tasks: CareTask[], dateKey: string) {
|
||||
const todayKey = getDateKey(new Date());
|
||||
|
||||
return tasks
|
||||
.filter((task) => {
|
||||
const taskDateKey = getTaskDateKey(task);
|
||||
if (!taskDateKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateKey === todayKey) {
|
||||
return taskDateKey <= todayKey;
|
||||
}
|
||||
|
||||
return taskDateKey === dateKey;
|
||||
})
|
||||
.sort(compareCareTasks);
|
||||
}
|
||||
|
||||
function compareCareTasks(left: CareTask, right: CareTask) {
|
||||
const dueComparison = (getTaskDateKey(left) ?? '').localeCompare(getTaskDateKey(right) ?? '');
|
||||
if (dueComparison !== 0) {
|
||||
return dueComparison;
|
||||
}
|
||||
|
||||
const plantComparison = left.plantName.localeCompare(right.plantName);
|
||||
if (plantComparison !== 0) {
|
||||
return plantComparison;
|
||||
}
|
||||
|
||||
return left.action.localeCompare(right.action);
|
||||
}
|
||||
|
||||
function getTaskDateKey(task: CareTask) {
|
||||
if (task.dueDate) {
|
||||
return task.dueDate;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
if (task.due === 'Today' || task.due === 'Yesterday' || task.due.endsWith(' days ago')) {
|
||||
return getDateKey(today);
|
||||
}
|
||||
|
||||
if (task.due === 'Tomorrow') {
|
||||
return getOffsetDateKey(today, 1);
|
||||
}
|
||||
|
||||
const relativeMatch = /^In (\d+) days$/.exec(task.due);
|
||||
if (relativeMatch) {
|
||||
return getOffsetDateKey(today, Number(relativeMatch[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOffsetDateKey(date: Date, offsetDays: number) {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(date.getDate() + offsetDays);
|
||||
|
||||
return getDateKey(nextDate);
|
||||
}
|
||||
|
||||
function getDateKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ type PlantManagementViewProps = {
|
||||
onDeletePlant: (plant: Plant) => void;
|
||||
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
|
||||
onNewPlant: () => void;
|
||||
onPlantFieldChange: (field: keyof PlantFormState, value: string) => void;
|
||||
onPlantFieldChange: (field: keyof PlantFormState, value: PlantFormState[keyof PlantFormState]) => void;
|
||||
onRemoveFlag: (flag: PlantFlag) => void;
|
||||
onResolveFlag: (flag: PlantFlag) => void;
|
||||
onSavePlant: () => void;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CareActivity, Plant, PlantGroup } from '../domain';
|
||||
import type { CareActivity, Plant, PlantCareScheduleRule, PlantGroup } from '../domain';
|
||||
import type { BulkScheduleFormState } from '../form-state';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type SchedulesViewProps = {
|
||||
activities: CareActivity[];
|
||||
editingScheduleId: number | null;
|
||||
error: string | null;
|
||||
form: BulkScheduleFormState;
|
||||
groups: PlantGroup[];
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
plants: Plant[];
|
||||
schedules: PlantCareScheduleRule[];
|
||||
onCancel: () => void;
|
||||
onDelete: (schedule: PlantCareScheduleRule) => void;
|
||||
onEdit: (schedule: PlantCareScheduleRule) => void;
|
||||
onFieldChange: (field: keyof BulkScheduleFormState, value: string | string[]) => void;
|
||||
onRemove: () => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
@@ -37,14 +42,19 @@ const weekdayOptions = [
|
||||
|
||||
export function SchedulesView({
|
||||
activities,
|
||||
editingScheduleId,
|
||||
error,
|
||||
form,
|
||||
groups,
|
||||
isLoading,
|
||||
isSaving,
|
||||
plants,
|
||||
schedules,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFieldChange,
|
||||
onRemove,
|
||||
onNew,
|
||||
onSave,
|
||||
}: SchedulesViewProps) {
|
||||
const [plantQuery, setPlantQuery] = useState('');
|
||||
@@ -67,12 +77,15 @@ export function SchedulesView({
|
||||
<>
|
||||
<SummaryStrip ariaLabel="Schedules summary">
|
||||
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onNew}>
|
||||
New
|
||||
</button>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="work-panel" aria-labelledby="bulk-schedule-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="bulk-schedule-heading">Apply activity interval</h2>
|
||||
<h2 id="bulk-schedule-heading">{editingScheduleId === null ? 'New Schedule' : 'Edit Schedule'}</h2>
|
||||
</div>
|
||||
<div className="schedule-count">
|
||||
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
|
||||
@@ -104,27 +117,6 @@ export function SchedulesView({
|
||||
onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Target group
|
||||
<select
|
||||
disabled={isSaving}
|
||||
value={selectedGroupId}
|
||||
onChange={(event) => {
|
||||
const groupId = event.target.value;
|
||||
const group = groups.find((item) => String(item.id) === groupId);
|
||||
setSelectedGroupId(groupId);
|
||||
setPlantQuery('');
|
||||
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
|
||||
}}
|
||||
>
|
||||
<option value="">Individual plants</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} ({group.plants.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="schedule-options">
|
||||
@@ -195,6 +187,17 @@ export function SchedulesView({
|
||||
{form.recurrenceMode !== 'none' ? (
|
||||
<fieldset className="schedule-end-options">
|
||||
<legend>Ends</legend>
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={form.endsMode === 'never'}
|
||||
disabled={isSaving}
|
||||
type="radio"
|
||||
name="endsMode"
|
||||
value="never"
|
||||
onChange={(event) => onFieldChange('endsMode', event.target.value)}
|
||||
/>
|
||||
Never
|
||||
</label>
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={form.endsMode === 'on'}
|
||||
@@ -278,8 +281,30 @@ export function SchedulesView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="compact-search">
|
||||
<span className="sr-only">Search target plants</span>
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
Target group
|
||||
<select
|
||||
disabled={isSaving}
|
||||
value={selectedGroupId}
|
||||
onChange={(event) => {
|
||||
const groupId = event.target.value;
|
||||
const group = groups.find((item) => String(item.id) === groupId);
|
||||
setSelectedGroupId(groupId);
|
||||
setPlantQuery('');
|
||||
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
|
||||
}}
|
||||
>
|
||||
<option value="">Individual plants</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} ({group.plants.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Search plants
|
||||
<input
|
||||
disabled={isSaving}
|
||||
type="search"
|
||||
@@ -288,6 +313,7 @@ export function SchedulesView({
|
||||
onChange={(event) => setPlantQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="plant-list compact-plant-list">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
@@ -332,20 +358,62 @@ export function SchedulesView({
|
||||
setSelectedGroupId('');
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
onCancel();
|
||||
setSelectedGroupId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="work-panel" aria-labelledby="schedules-list-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="schedules-list-heading">Schedules</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-list">
|
||||
{!isLoading && schedules.length === 0 ? (
|
||||
<p className="empty-state">No schedules yet.</p>
|
||||
) : null}
|
||||
|
||||
{schedules.map((schedule) => (
|
||||
<article className="detail-row schedule-detail-row" key={schedule.id}>
|
||||
<div>
|
||||
<h4>{schedule.action}</h4>
|
||||
<p>{formatRecurrence(schedule)}</p>
|
||||
<p>{formatSchedulePlants(schedule)}</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="text-button"
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => onEdit(schedule)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="text-button danger"
|
||||
type="button"
|
||||
disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId}
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
setSelectedGroupId('');
|
||||
}}
|
||||
disabled={isSaving}
|
||||
onClick={() => onDelete(schedule)}
|
||||
>
|
||||
Remove from {form.plantIds.length} plants
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="work-panel" aria-labelledby="current-schedules-heading">
|
||||
@@ -398,6 +466,19 @@ function formatPlantScheduleSummary(plant: Plant, selectedActivity?: CareActivit
|
||||
return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.nextCare}`;
|
||||
}
|
||||
|
||||
function formatSchedulePlants(schedule: PlantCareScheduleRule) {
|
||||
if (schedule.plants.length === 0) {
|
||||
return 'No plants assigned';
|
||||
}
|
||||
|
||||
const names = schedule.plants.map((plant) => plant.nickname);
|
||||
if (names.length <= 4) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 4).join(', ')} + ${names.length - 4} more`;
|
||||
}
|
||||
|
||||
function filterPlants(plants: Plant[], query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
|
||||
import type { TaxonFormState } from '../form-state';
|
||||
import { formatTaxon } from '../form-state';
|
||||
import { CatalogFilterSection, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
import { CatalogFilterSection, EntityList, SummaryStrip } from './Ui';
|
||||
|
||||
type TaxaViewProps = {
|
||||
activeTaxonName?: string;
|
||||
error: string | null;
|
||||
form: TaxonFormState;
|
||||
hasSearched: boolean;
|
||||
isEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
isSearching: boolean;
|
||||
@@ -17,16 +13,10 @@ type TaxaViewProps = {
|
||||
searchQuery: string;
|
||||
selectedTaxon?: PlantTaxon;
|
||||
taxa: PlantTaxon[];
|
||||
onCancel: () => void;
|
||||
onCloseDetail: () => void;
|
||||
onDelete: (taxon: PlantTaxon) => void;
|
||||
onEdit: (taxon: PlantTaxon) => void;
|
||||
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
|
||||
onImportResult: (result: PlantInfoSearchResult) => void;
|
||||
onNew: () => void;
|
||||
onOpenDetail: (taxon: PlantTaxon) => void;
|
||||
onPrefillResult: (result: PlantInfoSearchResult) => void;
|
||||
onSave: () => void;
|
||||
onSearch: (query?: string) => void;
|
||||
onSearchQueryChange: (value: string) => void;
|
||||
};
|
||||
@@ -40,11 +30,8 @@ function getPlantInfoSubtitle(result: PlantInfoSearchResult) {
|
||||
}
|
||||
|
||||
export function TaxaView({
|
||||
activeTaxonName,
|
||||
error,
|
||||
form,
|
||||
hasSearched,
|
||||
isEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
isSearching,
|
||||
@@ -52,16 +39,10 @@ export function TaxaView({
|
||||
searchQuery,
|
||||
selectedTaxon,
|
||||
taxa,
|
||||
onCancel,
|
||||
onCloseDetail,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFieldChange,
|
||||
onImportResult,
|
||||
onNew,
|
||||
onOpenDetail,
|
||||
onPrefillResult,
|
||||
onSave,
|
||||
onSearch,
|
||||
onSearchQueryChange,
|
||||
}: TaxaViewProps) {
|
||||
@@ -83,9 +64,8 @@ export function TaxaView({
|
||||
<>
|
||||
<SummaryStrip
|
||||
ariaLabel="Taxa summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Create and maintain the plant identities used by your collection.'}</p>
|
||||
<p>{error ?? 'Import GBIF-backed plant identities for use in your collection.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
@@ -101,14 +81,14 @@ export function TaxaView({
|
||||
isLoading={isLoading}
|
||||
items={filteredTaxa}
|
||||
renderActions={(taxon) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${taxon.name}`}
|
||||
editLabel={`Edit ${taxon.name}`}
|
||||
viewLabel={`View ${taxon.name}`}
|
||||
onDelete={() => onDelete(taxon)}
|
||||
onEdit={() => onEdit(taxon)}
|
||||
onView={() => onOpenDetail(taxon)}
|
||||
/>
|
||||
<>
|
||||
<button className="icon-button compact" type="button" aria-label={`View ${taxon.name}`} onClick={() => onOpenDetail(taxon)}>
|
||||
View
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${taxon.name}`} onClick={() => onDelete(taxon)}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>}
|
||||
/>
|
||||
@@ -116,7 +96,7 @@ export function TaxaView({
|
||||
<section className="work-panel" aria-labelledby="taxa-search-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="taxa-search-heading">Search plant info</h2>
|
||||
<h2 id="taxa-search-heading">Search GBIF</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,9 +167,6 @@ export function TaxaView({
|
||||
</dl>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" onClick={() => onPrefillResult(result)}>
|
||||
Prefill
|
||||
</button>
|
||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
|
||||
Import
|
||||
</button>
|
||||
@@ -202,16 +179,13 @@ export function TaxaView({
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{selectedTaxon && !isEditorOpen ? (
|
||||
{selectedTaxon ? (
|
||||
<section className="work-panel" aria-labelledby="taxon-detail-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedTaxon.name}`} onClick={() => onEdit(selectedTaxon)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
|
||||
Close
|
||||
</button>
|
||||
@@ -254,87 +228,6 @@ export function TaxaView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="work-panel" aria-labelledby="taxon-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="taxon-editor-heading">{activeTaxonName ?? 'New taxon'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancel}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
Common name
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => onFieldChange('name', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Genus
|
||||
<input
|
||||
value={form.genus}
|
||||
onChange={(event) => onFieldChange('genus', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Species
|
||||
<input
|
||||
value={form.species}
|
||||
onChange={(event) => onFieldChange('species', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Cultivar
|
||||
<input
|
||||
value={form.cultivar}
|
||||
onChange={(event) => onFieldChange('cultivar', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Variety
|
||||
<input
|
||||
value={form.variety}
|
||||
onChange={(event) => onFieldChange('variety', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Authority
|
||||
<input
|
||||
value={form.authority}
|
||||
onChange={(event) => onFieldChange('authority', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Family
|
||||
<input
|
||||
value={form.family}
|
||||
onChange={(event) => onFieldChange('family', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
GBIF ID
|
||||
<input
|
||||
value={form.externalId}
|
||||
onChange={(event) => onFieldChange('externalId', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,9 +36,31 @@ export type PlantCareSchedule = {
|
||||
status: CareStatus;
|
||||
};
|
||||
|
||||
export type PlantCareScheduleAssignment = {
|
||||
id: number;
|
||||
nickname: string;
|
||||
};
|
||||
|
||||
export type PlantCareScheduleRule = {
|
||||
id: number;
|
||||
careActivityId: number;
|
||||
careActionId: number;
|
||||
action: string;
|
||||
everyDays: number;
|
||||
scheduledFor: string | null;
|
||||
recurrenceMode: ScheduleRecurrenceMode;
|
||||
repeatEvery: number;
|
||||
repeatUnit: ScheduleRepeatUnit;
|
||||
repeatOnDays: string | null;
|
||||
endsMode: ScheduleEndsMode;
|
||||
endsOn: string | null;
|
||||
endsAfterOccurrences: number | null;
|
||||
plants: PlantCareScheduleAssignment[];
|
||||
};
|
||||
|
||||
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
|
||||
export type ScheduleEndsMode = 'on' | 'after';
|
||||
export type ScheduleEndsMode = 'never' | 'on' | 'after';
|
||||
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
|
||||
|
||||
export type PlantTaxon = {
|
||||
@@ -183,6 +205,24 @@ export type CareActivityActionResource = {
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
producedByRecipe: CareActivityRecipe | null;
|
||||
};
|
||||
|
||||
export type CareActivityRecipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
measurementMode: RecipeMeasurementMode;
|
||||
components: CareActivityRecipeComponent[];
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export type CareActivityRecipeComponent = {
|
||||
actionResourceId: number;
|
||||
name: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type PlantFlagDefinition = {
|
||||
@@ -255,19 +295,6 @@ export type BulkPlantCareSchedulePayload = {
|
||||
endsAfterOccurrences: number | null;
|
||||
};
|
||||
|
||||
export type PlantTaxonPayload = {
|
||||
name: string;
|
||||
genus: string;
|
||||
species: string;
|
||||
cultivar: string | null;
|
||||
variety: string | null;
|
||||
authority: string | null;
|
||||
family: string | null;
|
||||
commonName: string | null;
|
||||
externalSource: string | null;
|
||||
externalId: string | null;
|
||||
};
|
||||
|
||||
export type PlantLocationPayload = {
|
||||
name: string;
|
||||
notes: string | null;
|
||||
@@ -341,6 +368,13 @@ export type BulkCompleteCareTasksPayload = {
|
||||
resources: CareLogResourcePayload[];
|
||||
};
|
||||
|
||||
export type DismissCareTasksPayload = {
|
||||
careActivityId: number;
|
||||
plantIds: number[];
|
||||
notes: string | null;
|
||||
dismissedOn: string | null;
|
||||
};
|
||||
|
||||
export type CareLogResourcePayload = {
|
||||
actionResourceId: number;
|
||||
quantity: number | null;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
Recipe,
|
||||
RecipePayload,
|
||||
Plant,
|
||||
PlantInfoSearchResult,
|
||||
PlantCareScheduleRule,
|
||||
AssignPlantFlagPayload,
|
||||
PlantPayload,
|
||||
PlantFlagDefinition,
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
ScheduleRecurrenceMode,
|
||||
ScheduleRepeatUnit,
|
||||
PlantTaxon,
|
||||
PlantTaxonPayload,
|
||||
} from './domain';
|
||||
|
||||
export const emptyPlantForm = {
|
||||
@@ -33,19 +32,6 @@ export const emptyPlantForm = {
|
||||
locationId: '',
|
||||
};
|
||||
|
||||
export const emptyTaxonForm = {
|
||||
name: '',
|
||||
genus: '',
|
||||
species: '',
|
||||
cultivar: '',
|
||||
variety: '',
|
||||
authority: '',
|
||||
family: '',
|
||||
commonName: '',
|
||||
externalSource: '',
|
||||
externalId: '',
|
||||
};
|
||||
|
||||
export const emptyLocationForm = {
|
||||
name: '',
|
||||
notes: '',
|
||||
@@ -119,14 +105,13 @@ export const emptyBulkScheduleForm = {
|
||||
repeatEvery: '1',
|
||||
repeatUnit: 'week',
|
||||
repeatOnDays: [] as string[],
|
||||
endsMode: 'after',
|
||||
endsMode: 'never',
|
||||
endsOn: '',
|
||||
endsAfterOccurrences: '12',
|
||||
plantIds: [] as string[],
|
||||
};
|
||||
|
||||
export type PlantFormState = typeof emptyPlantForm;
|
||||
export type TaxonFormState = typeof emptyTaxonForm;
|
||||
export type LocationFormState = typeof emptyLocationForm;
|
||||
export type PlantGroupFormState = typeof emptyPlantGroupForm;
|
||||
export type ActionFormState = typeof emptyActionForm;
|
||||
@@ -136,7 +121,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
|
||||
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
||||
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
||||
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
||||
export type View = 'home' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||
export type View = 'home' | 'plant-management' | 'care' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||
|
||||
export function toPlantForm(plant: Plant): PlantFormState {
|
||||
return {
|
||||
@@ -187,51 +172,6 @@ export function toPlantGroupPayload(form: PlantGroupFormState): PlantGroupPayloa
|
||||
};
|
||||
}
|
||||
|
||||
export function toTaxonForm(taxon: PlantTaxon): TaxonFormState {
|
||||
return {
|
||||
name: taxon.name,
|
||||
genus: taxon.genus,
|
||||
species: taxon.species,
|
||||
cultivar: taxon.cultivar ?? '',
|
||||
variety: taxon.variety ?? '',
|
||||
authority: taxon.authority ?? '',
|
||||
family: taxon.family ?? '',
|
||||
commonName: taxon.commonName ?? '',
|
||||
externalSource: taxon.externalSource ?? '',
|
||||
externalId: taxon.externalId ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toTaxonFormFromPlantInfo(result: PlantInfoSearchResult): TaxonFormState {
|
||||
const canonicalName = result.canonicalName ?? result.scientificName;
|
||||
|
||||
return {
|
||||
...emptyTaxonForm,
|
||||
name: result.commonName ?? canonicalName,
|
||||
genus: result.genus ?? '',
|
||||
species: result.species ?? canonicalName.split(' ')[1] ?? '',
|
||||
family: result.family ?? '',
|
||||
commonName: result.commonName ?? '',
|
||||
externalSource: result.source,
|
||||
externalId: result.externalId,
|
||||
};
|
||||
}
|
||||
|
||||
export function toTaxonPayload(form: TaxonFormState): PlantTaxonPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
genus: form.genus.trim(),
|
||||
species: form.species.trim(),
|
||||
cultivar: form.cultivar.trim() || null,
|
||||
variety: form.variety.trim() || null,
|
||||
authority: form.authority.trim() || null,
|
||||
family: form.family.trim() || null,
|
||||
commonName: form.commonName.trim() || null,
|
||||
externalSource: form.externalSource.trim() || null,
|
||||
externalId: form.externalId.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toActionForm(action: CareAction): ActionFormState {
|
||||
return {
|
||||
name: action.name,
|
||||
@@ -364,6 +304,22 @@ export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCar
|
||||
};
|
||||
}
|
||||
|
||||
export function toBulkScheduleForm(schedule: PlantCareScheduleRule): BulkScheduleFormState {
|
||||
return {
|
||||
careActivityId: String(schedule.careActivityId),
|
||||
everyDays: String(schedule.everyDays),
|
||||
scheduledFor: schedule.scheduledFor ?? '',
|
||||
recurrenceMode: schedule.recurrenceMode,
|
||||
repeatEvery: String(schedule.repeatEvery),
|
||||
repeatUnit: schedule.repeatUnit,
|
||||
repeatOnDays: schedule.repeatOnDays ? schedule.repeatOnDays.split(',').filter(Boolean) : [],
|
||||
endsMode: schedule.endsMode,
|
||||
endsOn: schedule.endsOn ?? '',
|
||||
endsAfterOccurrences: schedule.endsAfterOccurrences === null ? '12' : String(schedule.endsAfterOccurrences),
|
||||
plantIds: schedule.plants.map((plant) => String(plant.id)),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTaxon(taxon: PlantTaxon) {
|
||||
const botanical = `${taxon.genus} ${taxon.species}`.trim();
|
||||
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
|
||||
|
||||
@@ -1013,6 +1013,47 @@ dd {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.activity-resource-detail-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.activity-resource-detail > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipe-procedure {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #f8f6ee;
|
||||
}
|
||||
|
||||
.recipe-procedure h5 {
|
||||
margin: 0;
|
||||
color: #24342c;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.recipe-procedure ol {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.recipe-procedure p,
|
||||
.recipe-procedure li {
|
||||
margin: 0;
|
||||
color: #59675d;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.activity-action-cell {
|
||||
padding: 8px 0 8px 12px;
|
||||
}
|
||||
@@ -1168,6 +1209,95 @@ dd {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.care-mode-switch {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.care-mode-switch button {
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.care-mode-switch button:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.care-mode-switch button[aria-selected="true"] {
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.care-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.care-subpanel {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.care-resource-edit {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 120px) minmax(90px, 140px);
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.care-notes {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.care-notes textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dashboard-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-panel {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-stat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.dashboard-stat strong {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard-stat span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.plant-detail-meta,
|
||||
.plant-detail-grid {
|
||||
display: grid;
|
||||
@@ -1499,6 +1629,14 @@ dd {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.care-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-nav {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
@@ -6,42 +6,42 @@ import {
|
||||
createActionResource,
|
||||
createCareAction,
|
||||
createCareActivity,
|
||||
createActionLogsBulk,
|
||||
createPlantCareSchedule,
|
||||
createPlant,
|
||||
createPlantFlag,
|
||||
createPlantGroup,
|
||||
createPlantLocation,
|
||||
createPlantTaxon,
|
||||
createRecipe,
|
||||
deleteActionResource,
|
||||
deleteCareAction,
|
||||
deleteCareActivity,
|
||||
deletePlant,
|
||||
deletePlantCareSchedule,
|
||||
deletePlantFlag,
|
||||
deletePlantGroup,
|
||||
deletePlantLocation,
|
||||
deletePlantTaxon,
|
||||
deleteRecipe,
|
||||
dismissCareTasksBulk,
|
||||
downloadSpreadsheetExport,
|
||||
importPlantTaxon,
|
||||
previewCatalogImport,
|
||||
removePlantCareSchedulesBulk,
|
||||
removePlantFlagAssignment,
|
||||
resolvePlantFlag,
|
||||
savePlantCareSchedulesBulk,
|
||||
searchPlantInfo,
|
||||
updateActionResource,
|
||||
updateCareAction,
|
||||
updateCareActivity,
|
||||
updatePlant,
|
||||
updatePlantCareSchedule,
|
||||
updatePlantFlag,
|
||||
updatePlantGroup,
|
||||
updatePlantLocation,
|
||||
updatePlantTaxon,
|
||||
updateRecipe,
|
||||
} from './api';
|
||||
import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
|
||||
import type { BulkCompleteCareTasksPayload, CareTask, CatalogImportResult, DismissCareTasksPayload, Plant, PlantCareScheduleRule, PlantFlag, PlantInfoSearchResult, PlantTaxon } from './domain';
|
||||
import {
|
||||
emptyBulkScheduleForm,
|
||||
emptyPlantFlagForm,
|
||||
toActionPayload,
|
||||
toActivityPayload,
|
||||
@@ -53,7 +53,6 @@ import {
|
||||
toPlantPayload,
|
||||
toRecipePayload,
|
||||
toResourcePayload,
|
||||
toTaxonPayload,
|
||||
} from './form-state';
|
||||
import type { useAppEditors } from './use-app-editors';
|
||||
import type { useDashboardData } from './use-dashboard-data';
|
||||
@@ -222,29 +221,6 @@ export function useAppActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaxon() {
|
||||
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
|
||||
setError('Name, genus, and species are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = toTaxonPayload(editors.taxonForm);
|
||||
if (editors.editingTaxonId === null) {
|
||||
await createPlantTaxon(payload);
|
||||
} else {
|
||||
await updatePlantTaxon(editors.editingTaxonId, payload);
|
||||
}
|
||||
editors.cancelEditingTaxon();
|
||||
await loadTaxaAndPlants();
|
||||
} catch {
|
||||
setError('Could not save the taxon.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTaxonInfo(
|
||||
queryOverride?: string,
|
||||
options: { updateQuery?: boolean } = {},
|
||||
@@ -301,7 +277,7 @@ export function useAppActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTaxon(taxon: Parameters<typeof editors.startEditingTaxon>[0]) {
|
||||
async function removeTaxon(taxon: PlantTaxon) {
|
||||
const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
@@ -310,9 +286,6 @@ export function useAppActions({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await deletePlantTaxon(taxon.id);
|
||||
if (editors.editingTaxonId === taxon.id) {
|
||||
editors.cancelEditingTaxon();
|
||||
}
|
||||
await loadTaxaAndPlants();
|
||||
} catch {
|
||||
setError('Could not delete the taxon. It may still be used by a plant.');
|
||||
@@ -618,29 +591,36 @@ export function useAppActions({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await savePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
|
||||
editors.setBulkScheduleForm(emptyBulkScheduleForm);
|
||||
const payload = toBulkSchedulePayload(editors.bulkScheduleForm);
|
||||
if (editors.editingScheduleId === null) {
|
||||
await createPlantCareSchedule(payload);
|
||||
} else {
|
||||
await updatePlantCareSchedule(editors.editingScheduleId, payload);
|
||||
}
|
||||
editors.cancelEditingSchedule();
|
||||
await loadPlantsAndCareTasks();
|
||||
} catch {
|
||||
setError('Could not apply the care schedule.');
|
||||
setError('Could not save the care schedule.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBulkSchedule() {
|
||||
if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) {
|
||||
setError('Select an activity and at least one plant.');
|
||||
async function removeSchedule(schedule: PlantCareScheduleRule) {
|
||||
const confirmed = window.confirm(`Delete ${schedule.action} schedule?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await removePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
|
||||
editors.setBulkScheduleForm(emptyBulkScheduleForm);
|
||||
await deletePlantCareSchedule(schedule.id);
|
||||
if (editors.editingScheduleId === schedule.id) {
|
||||
editors.cancelEditingSchedule();
|
||||
}
|
||||
await loadPlantsAndCareTasks();
|
||||
} catch {
|
||||
setError('Could not remove the care schedule.');
|
||||
setError('Could not delete the care schedule.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -769,6 +749,44 @@ export function useAppActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function logCare(payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) {
|
||||
if (payload.plantIds.length === 0 || !payload.careActivityId) {
|
||||
setError('Select an activity and at least one plant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (requireDueSchedule) {
|
||||
await completeCareTasksBulk(payload);
|
||||
} else {
|
||||
await createActionLogsBulk(payload);
|
||||
}
|
||||
await loadPlantsAndCareTasks();
|
||||
} catch {
|
||||
setError('Could not log care.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissCare(payload: DismissCareTasksPayload) {
|
||||
if (payload.plantIds.length === 0 || !payload.careActivityId) {
|
||||
setError('Select an activity and at least one plant.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await dismissCareTasksBulk(payload);
|
||||
await loadPlantsAndCareTasks();
|
||||
} catch {
|
||||
setError('Could not dismiss care.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assignFlagToSelectedPlant,
|
||||
applyCatalogImportFile,
|
||||
@@ -776,18 +794,20 @@ export function useAppActions({
|
||||
completeBulkTasks,
|
||||
completeTask,
|
||||
exportSpreadsheet,
|
||||
dismissCare,
|
||||
isImportingCatalog,
|
||||
hasSearchedPlantInfo,
|
||||
isExporting,
|
||||
isSearchingPlantInfo,
|
||||
isSaving,
|
||||
logCare,
|
||||
importTaxonFromPlantInfo,
|
||||
plantInfoQuery,
|
||||
plantInfoResults,
|
||||
removeAction,
|
||||
removeActivity,
|
||||
removeAssignedPlantFlag,
|
||||
removeBulkSchedule,
|
||||
removeSchedule,
|
||||
removeFlagDefinition,
|
||||
removeLocation,
|
||||
removePlant,
|
||||
@@ -805,7 +825,6 @@ export function useAppActions({
|
||||
savePlantGroup,
|
||||
saveRecipe,
|
||||
saveResource,
|
||||
saveTaxon,
|
||||
searchTaxonInfo,
|
||||
previewCatalogImportFile,
|
||||
setPlantInfoQuery,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
CareActivity,
|
||||
CareAction,
|
||||
Plant,
|
||||
PlantInfoSearchResult,
|
||||
PlantCareScheduleRule,
|
||||
PlantFlagDefinition,
|
||||
PlantGroup,
|
||||
PlantLocation,
|
||||
@@ -22,17 +22,15 @@ import {
|
||||
emptyPlantFlagForm,
|
||||
emptyRecipeForm,
|
||||
emptyResourceForm,
|
||||
emptyTaxonForm,
|
||||
toActionForm,
|
||||
toActivityForm,
|
||||
toBulkScheduleForm,
|
||||
toFlagDefinitionForm,
|
||||
toLocationForm,
|
||||
toPlantForm,
|
||||
toPlantGroupForm,
|
||||
toRecipeForm,
|
||||
toResourceForm,
|
||||
toTaxonForm,
|
||||
toTaxonFormFromPlantInfo,
|
||||
type ActionFormState,
|
||||
type ActivityFormState,
|
||||
type BulkScheduleFormState,
|
||||
@@ -43,7 +41,6 @@ import {
|
||||
type PlantFlagFormState,
|
||||
type RecipeFormState,
|
||||
type ResourceFormState,
|
||||
type TaxonFormState,
|
||||
type View,
|
||||
} from './form-state';
|
||||
|
||||
@@ -61,7 +58,6 @@ type AppEditorData = {
|
||||
|
||||
export function useAppEditors(data: AppEditorData, setView: (view: View) => void) {
|
||||
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
|
||||
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
|
||||
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
|
||||
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
|
||||
const [editingActionId, setEditingActionId] = useState<number | null>(null);
|
||||
@@ -69,8 +65,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
|
||||
const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
|
||||
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
|
||||
const [editingScheduleId, setEditingScheduleId] = useState<number | null>(null);
|
||||
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
|
||||
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
|
||||
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
|
||||
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
|
||||
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
|
||||
@@ -87,7 +83,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
|
||||
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
|
||||
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
|
||||
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
|
||||
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
|
||||
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
|
||||
@@ -100,7 +95,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
|
||||
const activePlant = data.plants.find((plant) => plant.id === editingPlantId);
|
||||
const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId);
|
||||
const activeTaxon = data.plantTaxa.find((taxon) => taxon.id === editingTaxonId);
|
||||
const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
|
||||
const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId);
|
||||
const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId);
|
||||
@@ -120,10 +114,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateTaxonForm(field: keyof TaxonFormState, value: string) {
|
||||
setTaxonForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateLocationForm(field: keyof LocationFormState, value: string) {
|
||||
setLocationForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
@@ -163,6 +153,23 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
setBulkScheduleForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function startNewSchedule() {
|
||||
setEditingScheduleId(null);
|
||||
setBulkScheduleForm(emptyBulkScheduleForm);
|
||||
setView('schedules');
|
||||
}
|
||||
|
||||
function startEditingSchedule(schedule: PlantCareScheduleRule) {
|
||||
setEditingScheduleId(schedule.id);
|
||||
setBulkScheduleForm(toBulkScheduleForm(schedule));
|
||||
setView('schedules');
|
||||
}
|
||||
|
||||
function cancelEditingSchedule() {
|
||||
setEditingScheduleId(null);
|
||||
setBulkScheduleForm(emptyBulkScheduleForm);
|
||||
}
|
||||
|
||||
function startAddingPlant() {
|
||||
setEditingPlantId(null);
|
||||
setSelectedPlantId(null);
|
||||
@@ -195,36 +202,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
setPlantFlagForm(emptyPlantFlagForm);
|
||||
}
|
||||
|
||||
function startAddingTaxon() {
|
||||
setEditingTaxonId(null);
|
||||
setSelectedTaxonId(null);
|
||||
setTaxonForm(emptyTaxonForm);
|
||||
setIsTaxonEditorOpen(true);
|
||||
setView('taxa');
|
||||
}
|
||||
|
||||
function startEditingTaxon(taxon: PlantTaxon) {
|
||||
setSelectedTaxonId(null);
|
||||
setEditingTaxonId(taxon.id);
|
||||
setTaxonForm(toTaxonForm(taxon));
|
||||
setIsTaxonEditorOpen(true);
|
||||
setView('taxa');
|
||||
}
|
||||
|
||||
function startAddingTaxonFromPlantInfo(result: PlantInfoSearchResult) {
|
||||
setEditingTaxonId(null);
|
||||
setSelectedTaxonId(null);
|
||||
setTaxonForm(toTaxonFormFromPlantInfo(result));
|
||||
setIsTaxonEditorOpen(true);
|
||||
setView('taxa');
|
||||
}
|
||||
|
||||
function cancelEditingTaxon() {
|
||||
setEditingTaxonId(null);
|
||||
setTaxonForm(emptyTaxonForm);
|
||||
setIsTaxonEditorOpen(false);
|
||||
}
|
||||
|
||||
function startAddingLocation() {
|
||||
setEditingLocationId(null);
|
||||
setSelectedLocationId(null);
|
||||
@@ -406,9 +383,9 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
activePlantGroup,
|
||||
activeRecipe,
|
||||
activeResource,
|
||||
activeTaxon,
|
||||
activityForm,
|
||||
bulkScheduleForm,
|
||||
cancelEditingSchedule,
|
||||
cancelEditing,
|
||||
cancelEditingAction,
|
||||
cancelEditingActivity,
|
||||
@@ -417,7 +394,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
cancelEditingPlantGroup,
|
||||
cancelEditingRecipe,
|
||||
cancelEditingResource,
|
||||
cancelEditingTaxon,
|
||||
editingActionId,
|
||||
editingActivityId,
|
||||
editingFlagDefinitionId,
|
||||
@@ -426,7 +402,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
editingPlantId,
|
||||
editingRecipeId,
|
||||
editingResourceId,
|
||||
editingTaxonId,
|
||||
editingScheduleId,
|
||||
flagDefinitionForm,
|
||||
form,
|
||||
isActionEditorOpen,
|
||||
@@ -437,7 +413,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
isPlantGroupEditorOpen,
|
||||
isRecipeEditorOpen,
|
||||
isResourceEditorOpen,
|
||||
isTaxonEditorOpen,
|
||||
locationForm,
|
||||
openPlantDetail,
|
||||
plantFlagForm,
|
||||
@@ -464,7 +439,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
setEditingPlantId,
|
||||
setEditingRecipeId,
|
||||
setEditingResourceId,
|
||||
setEditingTaxonId,
|
||||
setEditingScheduleId,
|
||||
setPlantFlagForm,
|
||||
setSelectedActionId,
|
||||
setSelectedActivityId,
|
||||
@@ -482,8 +457,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
startAddingPlantGroup,
|
||||
startAddingRecipe,
|
||||
startAddingResource,
|
||||
startAddingTaxon,
|
||||
startAddingTaxonFromPlantInfo,
|
||||
startEditingAction,
|
||||
startEditingActivity,
|
||||
startEditingFlagDefinition,
|
||||
@@ -492,8 +465,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
startEditingPlantGroup,
|
||||
startEditingRecipe,
|
||||
startEditingResource,
|
||||
startEditingTaxon,
|
||||
taxonForm,
|
||||
startEditingSchedule,
|
||||
startNewSchedule,
|
||||
updateActionForm,
|
||||
updateActivityForm,
|
||||
updateBulkScheduleForm,
|
||||
@@ -504,6 +477,5 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
updatePlantGroupForm,
|
||||
updateRecipeForm,
|
||||
updateResourceForm,
|
||||
updateTaxonForm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCareActions,
|
||||
getCareTasks,
|
||||
getPlants,
|
||||
getPlantCareSchedules,
|
||||
getPlantFlags,
|
||||
getPlantGroups,
|
||||
getPlantLocations,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
CareAction,
|
||||
CareTask,
|
||||
Plant,
|
||||
PlantCareScheduleRule,
|
||||
PlantFlagDefinition,
|
||||
PlantGroup,
|
||||
PlantLocation,
|
||||
@@ -33,6 +35,7 @@ export function useDashboardData() {
|
||||
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
|
||||
const [plantCareSchedules, setPlantCareSchedules] = useState<PlantCareScheduleRule[]>([]);
|
||||
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
|
||||
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -43,6 +46,7 @@ export function useDashboardData() {
|
||||
const [
|
||||
plantsResponse,
|
||||
tasksResponse,
|
||||
schedulesResponse,
|
||||
taxaResponse,
|
||||
locationsResponse,
|
||||
groupsResponse,
|
||||
@@ -54,6 +58,7 @@ export function useDashboardData() {
|
||||
] = await Promise.all([
|
||||
getPlants(),
|
||||
getCareTasks(),
|
||||
getPlantCareSchedules(),
|
||||
getPlantTaxa(),
|
||||
getPlantLocations(),
|
||||
getPlantGroups(),
|
||||
@@ -67,6 +72,7 @@ export function useDashboardData() {
|
||||
setError(null);
|
||||
setPlants(plantsResponse);
|
||||
setCareTasks(tasksResponse);
|
||||
setPlantCareSchedules(schedulesResponse);
|
||||
setPlantTaxa(taxaResponse);
|
||||
setPlantLocations(locationsResponse);
|
||||
setPlantGroups(groupsResponse);
|
||||
@@ -108,14 +114,16 @@ export function useDashboardData() {
|
||||
}
|
||||
|
||||
async function loadPlantsAndCareTasks() {
|
||||
const [plantsResponse, tasksResponse] = await Promise.all([
|
||||
const [plantsResponse, tasksResponse, schedulesResponse] = await Promise.all([
|
||||
getPlants(),
|
||||
getCareTasks(),
|
||||
getPlantCareSchedules(),
|
||||
]);
|
||||
|
||||
setError(null);
|
||||
setPlants(plantsResponse);
|
||||
setCareTasks(tasksResponse);
|
||||
setPlantCareSchedules(schedulesResponse);
|
||||
}
|
||||
|
||||
async function loadTaxaAndPlants() {
|
||||
@@ -144,6 +152,7 @@ export function useDashboardData() {
|
||||
const [
|
||||
plantsResponse,
|
||||
tasksResponse,
|
||||
schedulesResponse,
|
||||
actionsResponse,
|
||||
resourcesResponse,
|
||||
recipesResponse,
|
||||
@@ -151,6 +160,7 @@ export function useDashboardData() {
|
||||
] = await Promise.all([
|
||||
getPlants(),
|
||||
getCareTasks(),
|
||||
getPlantCareSchedules(),
|
||||
getCareActions(),
|
||||
getActionResources(),
|
||||
getRecipes(),
|
||||
@@ -160,6 +170,7 @@ export function useDashboardData() {
|
||||
setError(null);
|
||||
setPlants(plantsResponse);
|
||||
setCareTasks(tasksResponse);
|
||||
setPlantCareSchedules(schedulesResponse);
|
||||
setCareActions(actionsResponse);
|
||||
setActionResources(resourcesResponse);
|
||||
setRecipes(recipesResponse);
|
||||
@@ -206,6 +217,7 @@ export function useDashboardData() {
|
||||
loadRecipesAndResources,
|
||||
loadTaxaAndPlants,
|
||||
plantFlagDefinitions,
|
||||
plantCareSchedules,
|
||||
plantGroups,
|
||||
plantLocations,
|
||||
plantTaxa,
|
||||
|
||||
+124
-31
@@ -18,6 +18,7 @@ namespace plant_manager
|
||||
|
||||
public record SavePlantCareScheduleRequest(
|
||||
int CareActivityId,
|
||||
IReadOnlyList<int>? PlantIds,
|
||||
int? EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string? RecurrenceMode,
|
||||
@@ -41,18 +42,6 @@ namespace plant_manager
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences);
|
||||
|
||||
public record SavePlantTaxonRequest(
|
||||
string Name,
|
||||
string Genus,
|
||||
string Species,
|
||||
string? Cultivar,
|
||||
string? Variety,
|
||||
string? Authority,
|
||||
string? Family,
|
||||
string? CommonName,
|
||||
string? ExternalSource,
|
||||
string? ExternalId);
|
||||
|
||||
public record ImportPlantTaxonRequest(
|
||||
string Source,
|
||||
string ExternalId,
|
||||
@@ -110,12 +99,50 @@ namespace plant_manager
|
||||
string? Unit,
|
||||
string? Notes);
|
||||
|
||||
public record CareActivityRecipeComponentDto(
|
||||
int ActionResourceId,
|
||||
string Name,
|
||||
decimal? Quantity,
|
||||
string? Unit,
|
||||
string? Notes,
|
||||
int SortOrder)
|
||||
{
|
||||
public static CareActivityRecipeComponentDto FromRecipeComponent(RecipeComponent component) =>
|
||||
new(
|
||||
component.ActionResourceId,
|
||||
component.ActionResource.Name,
|
||||
component.Quantity,
|
||||
component.Unit,
|
||||
component.Notes,
|
||||
component.SortOrder);
|
||||
}
|
||||
|
||||
public record CareActivityRecipeDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string MeasurementMode,
|
||||
IReadOnlyList<CareActivityRecipeComponentDto> Components,
|
||||
string? Notes)
|
||||
{
|
||||
public static CareActivityRecipeDto FromRecipe(Recipe recipe) =>
|
||||
new(
|
||||
recipe.Id,
|
||||
recipe.Name,
|
||||
recipe.MeasurementMode,
|
||||
recipe.Components
|
||||
.OrderBy(component => component.SortOrder)
|
||||
.Select(CareActivityRecipeComponentDto.FromRecipeComponent)
|
||||
.ToList(),
|
||||
recipe.Notes);
|
||||
}
|
||||
|
||||
public record CareActivityActionResourceDto(
|
||||
int ActionResourceId,
|
||||
string Name,
|
||||
decimal? Quantity,
|
||||
string? Unit,
|
||||
string? Notes)
|
||||
string? Notes,
|
||||
CareActivityRecipeDto? ProducedByRecipe)
|
||||
{
|
||||
public static CareActivityActionResourceDto FromCareActivityActionResource(
|
||||
CareActivityActionResource resource) =>
|
||||
@@ -124,7 +151,10 @@ namespace plant_manager
|
||||
resource.ActionResource.Name,
|
||||
resource.Quantity,
|
||||
resource.Unit,
|
||||
resource.Notes);
|
||||
resource.Notes,
|
||||
resource.ActionResource.ProducedByRecipe is null
|
||||
? null
|
||||
: CareActivityRecipeDto.FromRecipe(resource.ActionResource.ProducedByRecipe));
|
||||
}
|
||||
|
||||
public record CareActivityActionDto(
|
||||
@@ -186,6 +216,12 @@ namespace plant_manager
|
||||
string? Notes,
|
||||
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
||||
|
||||
public record DismissCareTasksRequest(
|
||||
int CareActivityId,
|
||||
IReadOnlyList<int> PlantIds,
|
||||
DateOnly? DismissedOn,
|
||||
string? Notes);
|
||||
|
||||
public record CatalogImportIssue(
|
||||
string Sheet,
|
||||
int Row,
|
||||
@@ -401,21 +437,23 @@ namespace plant_manager
|
||||
public static PlantDto FromPlant(Plant plant)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var schedules = plant.CareSchedules
|
||||
.OrderBy(schedule => schedule.CareActivity.Name)
|
||||
.Select(schedule => PlantCareScheduleDto.FromSchedule(
|
||||
schedule,
|
||||
GetLatestPerformedOn(plant, schedule.CareActivityId),
|
||||
var schedules = plant.CareScheduleAssignments
|
||||
.OrderBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
|
||||
.Select(assignment => PlantCareScheduleDto.FromAssignment(
|
||||
assignment,
|
||||
GetLatestCareEventOn(plant, assignment.PlantCareSchedule.CareActivityId),
|
||||
today))
|
||||
.ToList();
|
||||
var nextCare = schedules
|
||||
.Select(schedule =>
|
||||
{
|
||||
var source = plant.CareSchedules.First(item => item.Id == schedule.Id);
|
||||
var source = plant.CareScheduleAssignments
|
||||
.Select(assignment => assignment.PlantCareSchedule)
|
||||
.First(item => item.Id == schedule.Id);
|
||||
return PlantCareFormatter.GetNextCareDate(
|
||||
source,
|
||||
schedule.LastPerformedOn,
|
||||
plant.ActionLogs.Count(log => log.CareActivityId == source.CareActivityId));
|
||||
GetCompletedOccurrences(plant, source.CareActivityId));
|
||||
})
|
||||
.Where(date => date is not null)
|
||||
.OrderBy(date => date)
|
||||
@@ -449,11 +487,18 @@ namespace plant_manager
|
||||
schedules);
|
||||
}
|
||||
|
||||
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) =>
|
||||
private static DateOnly? GetLatestCareEventOn(Plant plant, int careActivityId) =>
|
||||
plant.ActionLogs
|
||||
.Where(log => log.CareActivityId == careActivityId)
|
||||
.Select(log => (DateOnly?)log.PerformedOn)
|
||||
.Concat(plant.CareDismissals
|
||||
.Where(dismissal => dismissal.CareActivityId == careActivityId)
|
||||
.Select(dismissal => (DateOnly?)dismissal.DismissedOn))
|
||||
.Max();
|
||||
|
||||
private static int GetCompletedOccurrences(Plant plant, int careActivityId) =>
|
||||
plant.ActionLogs.Count(log => log.CareActivityId == careActivityId)
|
||||
+ plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == careActivityId);
|
||||
}
|
||||
|
||||
public record PlantCareScheduleDto(
|
||||
@@ -475,12 +520,13 @@ namespace plant_manager
|
||||
string NextCare,
|
||||
string Status)
|
||||
{
|
||||
public static PlantCareScheduleDto FromSchedule(
|
||||
PlantCareSchedule schedule,
|
||||
public static PlantCareScheduleDto FromAssignment(
|
||||
PlantCareScheduleAssignment assignment,
|
||||
DateOnly? lastPerformedOn,
|
||||
DateOnly today)
|
||||
{
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(schedule));
|
||||
var schedule = assignment.PlantCareSchedule;
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(assignment));
|
||||
|
||||
return new PlantCareScheduleDto(
|
||||
schedule.Id,
|
||||
@@ -502,8 +548,54 @@ namespace plant_manager
|
||||
PlantCareFormatter.GetStatus(nextCare, today));
|
||||
}
|
||||
|
||||
private static int GetCompletedOccurrences(PlantCareSchedule schedule) =>
|
||||
schedule.Plant.ActionLogs.Count(log => log.CareActivityId == schedule.CareActivityId);
|
||||
private static int GetCompletedOccurrences(PlantCareScheduleAssignment assignment) =>
|
||||
assignment.Plant.ActionLogs.Count(log => log.CareActivityId == assignment.PlantCareSchedule.CareActivityId)
|
||||
+ assignment.Plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == assignment.PlantCareSchedule.CareActivityId);
|
||||
}
|
||||
|
||||
public record PlantCareScheduleAssignmentDto(
|
||||
int Id,
|
||||
string Nickname)
|
||||
{
|
||||
public static PlantCareScheduleAssignmentDto FromAssignment(PlantCareScheduleAssignment assignment) =>
|
||||
new(assignment.PlantId, assignment.Plant.Nickname);
|
||||
}
|
||||
|
||||
public record PlantCareScheduleRuleDto(
|
||||
int Id,
|
||||
int CareActivityId,
|
||||
int CareActionId,
|
||||
string Action,
|
||||
int EveryDays,
|
||||
DateOnly? ScheduledFor,
|
||||
string RecurrenceMode,
|
||||
int RepeatEvery,
|
||||
string RepeatUnit,
|
||||
string? RepeatOnDays,
|
||||
string EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences,
|
||||
IReadOnlyList<PlantCareScheduleAssignmentDto> Plants)
|
||||
{
|
||||
public static PlantCareScheduleRuleDto FromSchedule(PlantCareSchedule schedule) =>
|
||||
new(
|
||||
schedule.Id,
|
||||
schedule.CareActivityId,
|
||||
schedule.CareActionId,
|
||||
schedule.CareActivity.Name,
|
||||
schedule.EveryDays,
|
||||
schedule.ScheduledFor,
|
||||
schedule.RecurrenceMode,
|
||||
schedule.RepeatEvery,
|
||||
schedule.RepeatUnit,
|
||||
schedule.RepeatOnDays,
|
||||
schedule.EndsMode,
|
||||
schedule.EndsOn,
|
||||
schedule.EndsAfterOccurrences,
|
||||
schedule.Assignments
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.Select(PlantCareScheduleAssignmentDto.FromAssignment)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
internal static class CareActivityExtensions
|
||||
@@ -532,18 +624,19 @@ namespace plant_manager
|
||||
string Due,
|
||||
string Status)
|
||||
{
|
||||
public static CareTaskDto FromSchedule(
|
||||
PlantCareSchedule schedule,
|
||||
public static CareTaskDto FromAssignment(
|
||||
PlantCareScheduleAssignment assignment,
|
||||
DateOnly? lastPerformedOn,
|
||||
int completedOccurrences,
|
||||
DateOnly today)
|
||||
{
|
||||
var schedule = assignment.PlantCareSchedule;
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
|
||||
|
||||
return new CareTaskDto(
|
||||
schedule.Id,
|
||||
schedule.PlantId,
|
||||
schedule.Plant.Nickname,
|
||||
assignment.PlantId,
|
||||
assignment.Plant.Nickname,
|
||||
schedule.CareActivityId,
|
||||
schedule.CareActionId,
|
||||
schedule.CareActivity.Name,
|
||||
|
||||
@@ -15,7 +15,9 @@ namespace plant_manager.Data
|
||||
public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; }
|
||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||
public DbSet<ActionLogResource> ActionLogResources { get; set; }
|
||||
public DbSet<CareDismissal> CareDismissals { get; set; }
|
||||
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
|
||||
public DbSet<PlantCareScheduleAssignment> PlantCareScheduleAssignments { get; set; }
|
||||
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
|
||||
public DbSet<PlantFlag> PlantFlags { get; set; }
|
||||
public DbSet<Recipe> Recipes { get; set; }
|
||||
@@ -40,9 +42,9 @@ namespace plant_manager.Data
|
||||
entity.Property(e => e.Authority).HasMaxLength(120);
|
||||
entity.Property(e => e.Family).HasMaxLength(120);
|
||||
entity.Property(e => e.CommonName).HasMaxLength(120);
|
||||
entity.Property(e => e.ExternalSource).HasMaxLength(40);
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(80);
|
||||
entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique(false);
|
||||
entity.Property(e => e.ExternalSource).HasMaxLength(40).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(80).IsRequired();
|
||||
entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Plant>(entity =>
|
||||
@@ -165,6 +167,23 @@ namespace plant_manager.Data
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CareDismissal>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.Notes).HasMaxLength(1000);
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActivityId, e.DismissedOn });
|
||||
entity.HasOne(e => e.Plant)
|
||||
.WithMany(e => e.CareDismissals)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.CareActivity)
|
||||
.WithMany(e => e.CareDismissals)
|
||||
.HasForeignKey(e => e.CareActivityId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantCareSchedule>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
@@ -179,12 +198,6 @@ namespace plant_manager.Data
|
||||
entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.EndsOn);
|
||||
entity.Property(e => e.EndsAfterOccurrences);
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique(false);
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActivityId }).IsUnique();
|
||||
entity.HasOne(e => e.Plant)
|
||||
.WithMany(e => e.CareSchedules)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.CareAction)
|
||||
.WithMany(e => e.PlantCareSchedules)
|
||||
.HasForeignKey(e => e.CareActionId)
|
||||
@@ -195,6 +208,20 @@ namespace plant_manager.Data
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantCareScheduleAssignment>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.PlantCareScheduleId, e.PlantId });
|
||||
entity.HasIndex(e => new { e.PlantId, e.PlantCareScheduleId }).IsUnique();
|
||||
entity.HasOne(e => e.PlantCareSchedule)
|
||||
.WithMany(e => e.Assignments)
|
||||
.HasForeignKey(e => e.PlantCareScheduleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.Plant)
|
||||
.WithMany(e => e.CareScheduleAssignments)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantFlagDefinition>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
+92
-14
@@ -11,7 +11,7 @@ using plant_manager.Data;
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260612235128_InitialCreate")]
|
||||
[Migration("20260614222157_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -201,6 +201,34 @@ namespace plant_manager.Data.Migrations
|
||||
b.ToTable("CareActivityActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("DismissedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
|
||||
|
||||
b.ToTable("CareDismissals");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -256,9 +284,6 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -285,12 +310,23 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId");
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId")
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||
{
|
||||
b.Property<int>("PlantCareScheduleId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PlantCareScheduleId", "PlantId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantCareScheduleId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
b.ToTable("PlantCareScheduleAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
@@ -428,10 +464,12 @@ namespace plant_manager.Data.Migrations
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExternalSource")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -460,7 +498,8 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalSource", "ExternalId");
|
||||
b.HasIndex("ExternalSource", "ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
@@ -616,6 +655,25 @@ namespace plant_manager.Data.Migrations
|
||||
b.Navigation("CareActivityAction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("CareDismissals")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareDismissals")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||
@@ -647,17 +705,28 @@ namespace plant_manager.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
|
||||
.WithMany("Assignments")
|
||||
.HasForeignKey("PlantCareScheduleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.WithMany("CareScheduleAssignments")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
|
||||
b.Navigation("PlantCareSchedule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
@@ -758,6 +827,8 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.Navigation("Actions");
|
||||
|
||||
b.Navigation("CareDismissals");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
@@ -770,13 +841,20 @@ namespace plant_manager.Data.Migrations
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
b.Navigation("CareDismissals");
|
||||
|
||||
b.Navigation("CareScheduleAssignments");
|
||||
|
||||
b.Navigation("Flags");
|
||||
|
||||
b.Navigation("GroupMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Navigation("Assignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
+92
-35
@@ -109,8 +109,8 @@ namespace plant_manager.Data.Migrations
|
||||
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true)
|
||||
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -164,6 +164,41 @@ namespace plant_manager.Data.Migrations
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantCareSchedules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_CareActions_CareActionId",
|
||||
column: x => x.CareActionId,
|
||||
principalTable: "CareActions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
|
||||
column: x => x.CareActivityId,
|
||||
principalTable: "CareActivities",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Plants",
|
||||
columns: table => new
|
||||
@@ -285,41 +320,51 @@ namespace plant_manager.Data.Migrations
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantCareSchedules",
|
||||
name: "CareDismissals",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
|
||||
DismissedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
|
||||
table.PrimaryKey("PK_CareDismissals", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_CareActions_CareActionId",
|
||||
column: x => x.CareActionId,
|
||||
principalTable: "CareActions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
|
||||
name: "FK_CareDismissals_CareActivities_CareActivityId",
|
||||
column: x => x.CareActivityId,
|
||||
principalTable: "CareActivities",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_Plants_PlantId",
|
||||
name: "FK_CareDismissals_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantCareScheduleAssignments",
|
||||
columns: table => new
|
||||
{
|
||||
PlantCareScheduleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantCareScheduleAssignments", x => new { x.PlantCareScheduleId, x.PlantId });
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareScheduleAssignments_PlantCareSchedules_PlantCareScheduleId",
|
||||
column: x => x.PlantCareScheduleId,
|
||||
principalTable: "PlantCareSchedules",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareScheduleAssignments_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
@@ -459,6 +504,22 @@ namespace plant_manager.Data.Migrations
|
||||
columns: new[] { "CareActivityId", "SortOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CareDismissals_CareActivityId",
|
||||
table: "CareDismissals",
|
||||
column: "CareActivityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CareDismissals_PlantId_CareActivityId_DismissedOn",
|
||||
table: "CareDismissals",
|
||||
columns: new[] { "PlantId", "CareActivityId", "DismissedOn" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareScheduleAssignments_PlantId_PlantCareScheduleId",
|
||||
table: "PlantCareScheduleAssignments",
|
||||
columns: new[] { "PlantId", "PlantCareScheduleId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareSchedules_CareActionId",
|
||||
table: "PlantCareSchedules",
|
||||
@@ -469,17 +530,6 @@ namespace plant_manager.Data.Migrations
|
||||
table: "PlantCareSchedules",
|
||||
column: "CareActivityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareSchedules_PlantId_CareActionId",
|
||||
table: "PlantCareSchedules",
|
||||
columns: new[] { "PlantId", "CareActionId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareSchedules_PlantId_CareActivityId",
|
||||
table: "PlantCareSchedules",
|
||||
columns: new[] { "PlantId", "CareActivityId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantFlagDefinitions_Name",
|
||||
table: "PlantFlagDefinitions",
|
||||
@@ -526,7 +576,8 @@ namespace plant_manager.Data.Migrations
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
||||
table: "PlantTaxa",
|
||||
columns: new[] { "ExternalSource", "ExternalId" });
|
||||
columns: new[] { "ExternalSource", "ExternalId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeComponents_ActionResourceId",
|
||||
@@ -562,7 +613,10 @@ namespace plant_manager.Data.Migrations
|
||||
name: "CareActivityActionResources");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantCareSchedules");
|
||||
name: "CareDismissals");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantCareScheduleAssignments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantFlags");
|
||||
@@ -579,6 +633,9 @@ namespace plant_manager.Data.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "CareActivityActions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantCareSchedules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantFlagDefinitions");
|
||||
|
||||
@@ -198,6 +198,34 @@ namespace plant_manager.Data.Migrations
|
||||
b.ToTable("CareActivityActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("DismissedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
|
||||
|
||||
b.ToTable("CareDismissals");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -253,9 +281,6 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
@@ -282,12 +307,23 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId");
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId")
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||
{
|
||||
b.Property<int>("PlantCareScheduleId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PlantCareScheduleId", "PlantId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantCareScheduleId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
b.ToTable("PlantCareScheduleAssignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
@@ -425,10 +461,12 @@ namespace plant_manager.Data.Migrations
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExternalSource")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -457,7 +495,8 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalSource", "ExternalId");
|
||||
b.HasIndex("ExternalSource", "ExternalId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
@@ -613,6 +652,25 @@ namespace plant_manager.Data.Migrations
|
||||
b.Navigation("CareActivityAction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("CareDismissals")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareDismissals")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||
@@ -644,17 +702,28 @@ namespace plant_manager.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
|
||||
.WithMany("Assignments")
|
||||
.HasForeignKey("PlantCareScheduleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.WithMany("CareScheduleAssignments")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
|
||||
b.Navigation("PlantCareSchedule");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
@@ -755,6 +824,8 @@ namespace plant_manager.Data.Migrations
|
||||
|
||||
b.Navigation("Actions");
|
||||
|
||||
b.Navigation("CareDismissals");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
@@ -767,13 +838,20 @@ namespace plant_manager.Data.Migrations
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
b.Navigation("CareDismissals");
|
||||
|
||||
b.Navigation("CareScheduleAssignments");
|
||||
|
||||
b.Navigation("Flags");
|
||||
|
||||
b.Navigation("GroupMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Navigation("Assignments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace plant_manager.Data.Models
|
||||
|
||||
public List<CareActivityAction> Actions { get; set; } = [];
|
||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||
public List<CareDismissal> CareDismissals { get; set; } = [];
|
||||
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class CareDismissal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PlantId { get; set; }
|
||||
public int CareActivityId { get; set; }
|
||||
public DateOnly DismissedOn { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public Plant Plant { get; set; } = null!;
|
||||
public CareActivity CareActivity { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ namespace plant_manager.Data.Models
|
||||
public PlantTaxon? Taxon { get; set; }
|
||||
public PlantLocation? Location { get; set; }
|
||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||
public List<PlantCareSchedule> CareSchedules { get; set; } = [];
|
||||
public List<CareDismissal> CareDismissals { get; set; } = [];
|
||||
public List<PlantCareScheduleAssignment> CareScheduleAssignments { get; set; } = [];
|
||||
public List<PlantFlag> Flags { get; set; } = [];
|
||||
public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace plant_manager.Data.Models
|
||||
public class PlantCareSchedule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PlantId { get; set; }
|
||||
public int CareActionId { get; set; }
|
||||
public int CareActivityId { get; set; }
|
||||
public int EveryDays { get; set; } = 7;
|
||||
@@ -16,8 +15,8 @@ namespace plant_manager.Data.Models
|
||||
public DateOnly? EndsOn { get; set; }
|
||||
public int? EndsAfterOccurrences { get; set; }
|
||||
|
||||
public Plant Plant { get; set; } = null!;
|
||||
public CareAction CareAction { get; set; } = null!;
|
||||
public CareActivity CareActivity { get; set; } = null!;
|
||||
public List<PlantCareScheduleAssignment> Assignments { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class PlantCareScheduleAssignment
|
||||
{
|
||||
public int PlantCareScheduleId { get; set; }
|
||||
public int PlantId { get; set; }
|
||||
|
||||
public PlantCareSchedule PlantCareSchedule { get; set; } = null!;
|
||||
public Plant Plant { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace plant_manager.Data.Models
|
||||
public string? Authority { get; set; }
|
||||
public string? Family { get; set; }
|
||||
public string? CommonName { get; set; }
|
||||
public string? ExternalSource { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public string ExternalSource { get; set; } = string.Empty;
|
||||
public string ExternalId { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using plant_manager.Data.Models;
|
||||
|
||||
namespace plant_manager.Data
|
||||
{
|
||||
public static class SeedData
|
||||
{
|
||||
public static async Task SeedDevelopmentDataAsync(this ApplicationDbContext db)
|
||||
{
|
||||
if (await db.Plants.AnyAsync())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
var taxa = new
|
||||
{
|
||||
Monstera = new PlantTaxon
|
||||
{
|
||||
Name = "Swiss Cheese Plant",
|
||||
Genus = "Monstera",
|
||||
Species = "deliciosa",
|
||||
Family = "Araceae",
|
||||
CommonName = "Swiss Cheese Plant",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "2871984"
|
||||
},
|
||||
Ficus = new PlantTaxon
|
||||
{
|
||||
Name = "Fiddle-leaf Fig",
|
||||
Genus = "Ficus",
|
||||
Species = "lyrata",
|
||||
Family = "Moraceae",
|
||||
CommonName = "Fiddle-leaf Fig",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "5361909"
|
||||
},
|
||||
Alocasia = new PlantTaxon
|
||||
{
|
||||
Name = "Kris Plant",
|
||||
Genus = "Alocasia",
|
||||
Species = "sanderiana",
|
||||
Family = "Araceae",
|
||||
CommonName = "Kris Plant",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "2879681"
|
||||
},
|
||||
Peperomia = new PlantTaxon
|
||||
{
|
||||
Name = "Marble Peperomia",
|
||||
Genus = "Peperomia",
|
||||
Species = "obtusifolia",
|
||||
Cultivar = "Marble",
|
||||
Family = "Piperaceae",
|
||||
CommonName = "Marble Peperomia",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "4189935"
|
||||
},
|
||||
Pothos = new PlantTaxon
|
||||
{
|
||||
Name = "Pothos",
|
||||
Genus = "Epipremnum",
|
||||
Species = "aureum",
|
||||
Family = "Araceae",
|
||||
CommonName = "Pothos",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "2868275"
|
||||
},
|
||||
Orchid = new PlantTaxon
|
||||
{
|
||||
Name = "Moth Orchid",
|
||||
Genus = "Phalaenopsis",
|
||||
Species = "hybrid",
|
||||
Family = "Orchidaceae",
|
||||
CommonName = "Moth Orchid",
|
||||
ExternalSource = "gbif",
|
||||
ExternalId = "2879487"
|
||||
}
|
||||
};
|
||||
|
||||
var locations = new
|
||||
{
|
||||
LivingRoom = new PlantLocation { Name = "Living Room", Notes = "Bright indirect light near the east window." },
|
||||
Office = new PlantLocation { Name = "Office", Notes = "Grow light shelf." },
|
||||
Kitchen = new PlantLocation { Name = "Kitchen", Notes = "Higher humidity and morning light." },
|
||||
Quarantine = new PlantLocation { Name = "Quarantine Shelf", Notes = "Temporary isolation and observation." }
|
||||
};
|
||||
|
||||
var actions = new
|
||||
{
|
||||
Water = new CareAction { Name = "Water", Description = "Apply water or prepared solution." },
|
||||
Fertilize = new CareAction { Name = "Fertilize", Description = "Apply nutrients at the selected dilution." },
|
||||
Inspect = new CareAction { Name = "Inspect", Description = "Check foliage, roots, and pest pressure." },
|
||||
Repot = new CareAction { Name = "Repot", Description = "Refresh substrate or move to a larger pot." },
|
||||
Prune = new CareAction { Name = "Prune", Description = "Trim damaged or overgrown foliage." }
|
||||
};
|
||||
|
||||
var resources = new
|
||||
{
|
||||
Water = new ActionResource { Name = "Water", Notes = "Room-temperature filtered water." },
|
||||
Fertilizer = new ActionResource { Name = "Liquid Fertilizer", Notes = "Balanced houseplant concentrate." },
|
||||
PottingMix = new ActionResource { Name = "Potting Mix", Notes = "General indoor plant substrate." },
|
||||
OrchidBark = new ActionResource { Name = "Orchid Bark", Notes = "Chunky aeration component." },
|
||||
Perlite = new ActionResource { Name = "Perlite", Notes = "Lightweight aeration component." },
|
||||
NeemOil = new ActionResource { Name = "Neem Oil", Notes = "Pest treatment concentrate." },
|
||||
Shears = new ActionResource { Name = "Clean Shears", Notes = "Sterilized cutting tool." }
|
||||
};
|
||||
|
||||
var nutrientMix = new Recipe
|
||||
{
|
||||
Name = "Gentle Nutrient Mix",
|
||||
MeasurementMode = "bakers_percent",
|
||||
OutputResource = new ActionResource
|
||||
{
|
||||
Name = "Gentle Nutrient Mix",
|
||||
Notes = "Seed recipe output used by fertilizing activities."
|
||||
},
|
||||
Notes = "Water is the 100% base; fertilizer is measured against that base.",
|
||||
Components =
|
||||
[
|
||||
new RecipeComponent
|
||||
{
|
||||
ActionResource = resources.Water,
|
||||
Quantity = 100,
|
||||
Unit = "%",
|
||||
Notes = "Base",
|
||||
SortOrder = 0
|
||||
},
|
||||
new RecipeComponent
|
||||
{
|
||||
ActionResource = resources.Fertilizer,
|
||||
Quantity = 5,
|
||||
Unit = "%",
|
||||
Notes = "Light feeding strength",
|
||||
SortOrder = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var waterActivity = new CareActivity
|
||||
{
|
||||
Name = "Water",
|
||||
Notes = "Routine watering based on substrate dryness.",
|
||||
Actions =
|
||||
[
|
||||
new CareActivityAction
|
||||
{
|
||||
CareAction = actions.Water,
|
||||
SortOrder = 0,
|
||||
Resources =
|
||||
[
|
||||
new CareActivityActionResource
|
||||
{
|
||||
ActionResource = resources.Water,
|
||||
Quantity = 500,
|
||||
Unit = "ml",
|
||||
Notes = "Adjust by pot size."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fertilizeActivity = new CareActivity
|
||||
{
|
||||
Name = "Fertilize",
|
||||
Notes = "Light feeding during active growth.",
|
||||
Actions =
|
||||
[
|
||||
new CareActivityAction
|
||||
{
|
||||
CareAction = actions.Fertilize,
|
||||
SortOrder = 0,
|
||||
Resources =
|
||||
[
|
||||
new CareActivityActionResource
|
||||
{
|
||||
ActionResource = nutrientMix.OutputResource,
|
||||
Quantity = 250,
|
||||
Unit = "ml",
|
||||
Notes = "Use after watering if soil is very dry."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var inspectActivity = new CareActivity
|
||||
{
|
||||
Name = "Pest Check",
|
||||
Notes = "Inspect leaves, stems, and soil surface.",
|
||||
Actions =
|
||||
[
|
||||
new CareActivityAction
|
||||
{
|
||||
CareAction = actions.Inspect,
|
||||
SortOrder = 0,
|
||||
Resources =
|
||||
[
|
||||
new CareActivityActionResource
|
||||
{
|
||||
ActionResource = resources.NeemOil,
|
||||
Quantity = 0,
|
||||
Unit = "ml",
|
||||
Notes = "Only use if pests are found."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var repotActivity = new CareActivity
|
||||
{
|
||||
Name = "Repot",
|
||||
Notes = "Refresh substrate and inspect roots.",
|
||||
Actions =
|
||||
[
|
||||
new CareActivityAction
|
||||
{
|
||||
CareAction = actions.Repot,
|
||||
SortOrder = 0,
|
||||
Resources =
|
||||
[
|
||||
new CareActivityActionResource { ActionResource = resources.PottingMix, Quantity = 1, Unit = "L" },
|
||||
new CareActivityActionResource { ActionResource = resources.OrchidBark, Quantity = 0.5m, Unit = "L" },
|
||||
new CareActivityActionResource { ActionResource = resources.Perlite, Quantity = 0.5m, Unit = "L" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var pruneActivity = new CareActivity
|
||||
{
|
||||
Name = "Prune",
|
||||
Notes = "Remove damaged foliage and shape growth.",
|
||||
Actions =
|
||||
[
|
||||
new CareActivityAction
|
||||
{
|
||||
CareAction = actions.Prune,
|
||||
SortOrder = 0,
|
||||
Resources =
|
||||
[
|
||||
new CareActivityActionResource { ActionResource = resources.Shears, Quantity = 1, Unit = "tool" }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var flags = new
|
||||
{
|
||||
Attention = new PlantFlagDefinition { Name = "Attention", Color = "#FFAB00" },
|
||||
Recovering = new PlantFlagDefinition { Name = "Recovering", Color = "#36B37E" },
|
||||
Quarantine = new PlantFlagDefinition { Name = "Quarantine", Color = "#FF5630" },
|
||||
Wishlist = new PlantFlagDefinition { Name = "Wishlist", Color = "#6554C0" }
|
||||
};
|
||||
|
||||
var plants = new
|
||||
{
|
||||
Monstera = new Plant
|
||||
{
|
||||
Nickname = "Elara",
|
||||
Birthday = today.AddDays(-420),
|
||||
Taxon = taxa.Monstera,
|
||||
Location = locations.LivingRoom
|
||||
},
|
||||
Ficus = new Plant
|
||||
{
|
||||
Nickname = "Darrow",
|
||||
Birthday = today.AddDays(-260),
|
||||
Taxon = taxa.Ficus,
|
||||
Location = locations.LivingRoom
|
||||
},
|
||||
Kris = new Plant
|
||||
{
|
||||
Nickname = "Tamsin",
|
||||
Birthday = today.AddDays(-120),
|
||||
Taxon = taxa.Alocasia,
|
||||
Location = locations.Quarantine
|
||||
},
|
||||
Peperomia = new Plant
|
||||
{
|
||||
Nickname = "Rowan",
|
||||
Birthday = today.AddDays(-95),
|
||||
Taxon = taxa.Peperomia,
|
||||
Location = locations.Office
|
||||
},
|
||||
Pothos = new Plant
|
||||
{
|
||||
Nickname = "Juno",
|
||||
Birthday = today.AddDays(-700),
|
||||
Taxon = taxa.Pothos,
|
||||
Location = locations.Kitchen
|
||||
},
|
||||
Orchid = new Plant
|
||||
{
|
||||
Nickname = "Lyric",
|
||||
Birthday = today.AddDays(-35),
|
||||
Taxon = taxa.Orchid,
|
||||
Location = locations.Office
|
||||
}
|
||||
};
|
||||
|
||||
var groups = new
|
||||
{
|
||||
LivingRoom = new PlantGroup
|
||||
{
|
||||
Name = "Living Room Group",
|
||||
Notes = "Larger statement plants.",
|
||||
Memberships =
|
||||
[
|
||||
new PlantGroupMembership { Plant = plants.Monstera },
|
||||
new PlantGroupMembership { Plant = plants.Ficus }
|
||||
]
|
||||
},
|
||||
Humidity = new PlantGroup
|
||||
{
|
||||
Name = "Humidity Lovers",
|
||||
Notes = "Plants that prefer steadier humidity.",
|
||||
Memberships =
|
||||
[
|
||||
new PlantGroupMembership { Plant = plants.Kris },
|
||||
new PlantGroupMembership { Plant = plants.Orchid },
|
||||
new PlantGroupMembership { Plant = plants.Peperomia }
|
||||
]
|
||||
},
|
||||
EasyCare = new PlantGroup
|
||||
{
|
||||
Name = "Easy Care",
|
||||
Notes = "Reliable low-maintenance plants.",
|
||||
Memberships =
|
||||
[
|
||||
new PlantGroupMembership { Plant = plants.Pothos },
|
||||
new PlantGroupMembership { Plant = plants.Peperomia }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
plants.Ficus.Flags.Add(new PlantFlag
|
||||
{
|
||||
Definition = flags.Attention,
|
||||
StartedOn = today.AddDays(-3),
|
||||
Notes = "Watch for leaf drop after relocation."
|
||||
});
|
||||
plants.Kris.Flags.Add(new PlantFlag
|
||||
{
|
||||
Definition = flags.Quarantine,
|
||||
StartedOn = today.AddDays(-8),
|
||||
Notes = "New arrival. Inspect before moving near other plants."
|
||||
});
|
||||
plants.Orchid.Flags.Add(new PlantFlag
|
||||
{
|
||||
Definition = flags.Recovering,
|
||||
StartedOn = today.AddDays(-15),
|
||||
ResolvedOn = today.AddDays(-2),
|
||||
Notes = "Recovered after bloom spike trim."
|
||||
});
|
||||
|
||||
waterActivity.PlantCareSchedules =
|
||||
[
|
||||
new PlantCareSchedule
|
||||
{
|
||||
CareAction = actions.Water,
|
||||
EveryDays = 7,
|
||||
ScheduledFor = today.AddDays(-7),
|
||||
RecurrenceMode = "weekly",
|
||||
RepeatEvery = 1,
|
||||
RepeatUnit = "week",
|
||||
EndsMode = "never",
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment { Plant = plants.Monstera },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Ficus },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Pothos }
|
||||
]
|
||||
},
|
||||
new PlantCareSchedule
|
||||
{
|
||||
CareAction = actions.Water,
|
||||
EveryDays = 4,
|
||||
ScheduledFor = today.AddDays(-4),
|
||||
RecurrenceMode = "custom",
|
||||
RepeatEvery = 4,
|
||||
RepeatUnit = "day",
|
||||
EndsMode = "never",
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment { Plant = plants.Kris },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Peperomia },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Orchid }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
fertilizeActivity.PlantCareSchedules =
|
||||
[
|
||||
new PlantCareSchedule
|
||||
{
|
||||
CareAction = actions.Fertilize,
|
||||
EveryDays = 30,
|
||||
ScheduledFor = today.AddDays(5),
|
||||
RecurrenceMode = "monthly",
|
||||
RepeatEvery = 1,
|
||||
RepeatUnit = "month",
|
||||
EndsMode = "never",
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment { Plant = plants.Monstera },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Pothos },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Peperomia }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
inspectActivity.PlantCareSchedules =
|
||||
[
|
||||
new PlantCareSchedule
|
||||
{
|
||||
CareAction = actions.Inspect,
|
||||
EveryDays = 14,
|
||||
ScheduledFor = today.AddDays(-2),
|
||||
RecurrenceMode = "weekly",
|
||||
RepeatEvery = 1,
|
||||
RepeatUnit = "week",
|
||||
EndsMode = "after",
|
||||
EndsAfterOccurrences = 8,
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment { Plant = plants.Kris },
|
||||
new PlantCareScheduleAssignment { Plant = plants.Ficus }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
repotActivity.PlantCareSchedules =
|
||||
[
|
||||
new PlantCareSchedule
|
||||
{
|
||||
CareAction = actions.Repot,
|
||||
EveryDays = 365,
|
||||
ScheduledFor = today.AddDays(45),
|
||||
RecurrenceMode = "none",
|
||||
RepeatEvery = 1,
|
||||
RepeatUnit = "week",
|
||||
EndsMode = "after",
|
||||
EndsAfterOccurrences = 1,
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment { Plant = plants.Ficus }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
plants.Monstera.ActionLogs.Add(new ActionLog
|
||||
{
|
||||
CareAction = actions.Water,
|
||||
CareActivity = waterActivity,
|
||||
ActionNameSnapshot = waterActivity.Name,
|
||||
PerformedOn = today.AddDays(-7),
|
||||
Notes = "Thorough soak; pot drained well.",
|
||||
Resources =
|
||||
[
|
||||
new ActionLogResource { ActionResource = resources.Water, Quantity = 650, Unit = "ml" }
|
||||
]
|
||||
});
|
||||
plants.Pothos.ActionLogs.Add(new ActionLog
|
||||
{
|
||||
CareAction = actions.Water,
|
||||
CareActivity = waterActivity,
|
||||
ActionNameSnapshot = waterActivity.Name,
|
||||
PerformedOn = today.AddDays(-10),
|
||||
Notes = "Let dry longer next round.",
|
||||
Resources =
|
||||
[
|
||||
new ActionLogResource { ActionResource = resources.Water, Quantity = 400, Unit = "ml" }
|
||||
]
|
||||
});
|
||||
plants.Peperomia.ActionLogs.Add(new ActionLog
|
||||
{
|
||||
CareAction = actions.Inspect,
|
||||
CareActivity = inspectActivity,
|
||||
ActionNameSnapshot = inspectActivity.Name,
|
||||
PerformedOn = today.AddDays(-1),
|
||||
Notes = "No pests observed.",
|
||||
Resources = []
|
||||
});
|
||||
plants.Monstera.ActionLogs.Add(new ActionLog
|
||||
{
|
||||
CareAction = actions.Fertilize,
|
||||
CareActivity = fertilizeActivity,
|
||||
ActionNameSnapshot = fertilizeActivity.Name,
|
||||
PerformedOn = today.AddDays(-28),
|
||||
Notes = "Light feed during new leaf growth.",
|
||||
Resources =
|
||||
[
|
||||
new ActionLogResource { ActionResource = nutrientMix.OutputResource, Quantity = 250, Unit = "ml" }
|
||||
]
|
||||
});
|
||||
|
||||
db.PlantTaxa.AddRange(taxa.Monstera, taxa.Ficus, taxa.Alocasia, taxa.Peperomia, taxa.Pothos, taxa.Orchid);
|
||||
db.PlantLocations.AddRange(locations.LivingRoom, locations.Office, locations.Kitchen, locations.Quarantine);
|
||||
db.CareActions.AddRange(actions.Water, actions.Fertilize, actions.Inspect, actions.Repot, actions.Prune);
|
||||
db.ActionResources.AddRange(resources.Water, resources.Fertilizer, resources.PottingMix, resources.OrchidBark, resources.Perlite, resources.NeemOil, resources.Shears);
|
||||
db.Recipes.Add(nutrientMix);
|
||||
db.CareActivities.AddRange(waterActivity, fertilizeActivity, inspectActivity, repotActivity, pruneActivity);
|
||||
db.PlantFlagDefinitions.AddRange(flags.Attention, flags.Recovering, flags.Quarantine, flags.Wishlist);
|
||||
db.Plants.AddRange(plants.Monstera, plants.Ficus, plants.Kris, plants.Peperomia, plants.Pothos, plants.Orchid);
|
||||
db.PlantGroups.AddRange(groups.LivingRoom, groups.Humidity, groups.EasyCare);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,80 @@ namespace plant_manager.Endpoints
|
||||
return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log));
|
||||
});
|
||||
|
||||
app.MapPost("/api/action-logs/bulk", async (BulkCompleteCareTasksRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
var plantIds = request.PlantIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (plantIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||
}
|
||||
|
||||
var plants = await db.Plants
|
||||
.Where(plant => plantIds.Contains(plant.Id))
|
||||
.OrderBy(plant => plant.Nickname)
|
||||
.ToListAsync();
|
||||
if (plants.Count != plantIds.Count)
|
||||
{
|
||||
return Results.BadRequest(new { error = "One or more plants were not found." });
|
||||
}
|
||||
|
||||
var activity = await db.CareActivities
|
||||
.Include(item => item.Actions)
|
||||
.ThenInclude(action => action.CareAction)
|
||||
.Include(item => item.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
|
||||
if (activity is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity was not found." });
|
||||
}
|
||||
|
||||
var primaryAction = activity.PrimaryAction();
|
||||
if (primaryAction is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity has no configured actions." });
|
||||
}
|
||||
|
||||
var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db);
|
||||
if (resourceError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = resourceError });
|
||||
}
|
||||
|
||||
var logs = plants
|
||||
.Select(plant => new ActionLog
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
CareActionId = primaryAction.Id,
|
||||
CareActivityId = activity.Id,
|
||||
CareAction = primaryAction,
|
||||
CareActivity = activity,
|
||||
ActionNameSnapshot = activity.Name,
|
||||
Notes = notes,
|
||||
PerformedOn = performedOn,
|
||||
Resources = resources
|
||||
.Select(resource => new ActionLogResource
|
||||
{
|
||||
ActionResourceId = resource.ActionResourceId,
|
||||
Quantity = resource.Quantity,
|
||||
Unit = resource.Unit
|
||||
})
|
||||
.ToList()
|
||||
})
|
||||
.ToList();
|
||||
|
||||
db.ActionLogs.AddRange(logs);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { completed = logs.Count });
|
||||
});
|
||||
|
||||
app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
var log = await db.ActionLogs
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace plant_manager.Endpoints
|
||||
.Include(activity => activity.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.ThenInclude(resource => resource.ProducedByRecipe)
|
||||
.ThenInclude(recipe => recipe!.Components)
|
||||
.ThenInclude(component => component.ActionResource)
|
||||
.OrderBy(activity => activity.Name)
|
||||
.Select(activity => CareActivityDto.FromCareActivity(activity))
|
||||
.ToListAsync();
|
||||
@@ -59,6 +62,9 @@ namespace plant_manager.Endpoints
|
||||
.Include(item => item.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.ThenInclude(resource => resource.ProducedByRecipe)
|
||||
.ThenInclude(recipe => recipe!.Components)
|
||||
.ThenInclude(component => component.ActionResource)
|
||||
.FirstOrDefaultAsync(item => item.Id == id);
|
||||
if (activity is null)
|
||||
{
|
||||
|
||||
@@ -11,41 +11,32 @@ namespace plant_manager.Endpoints
|
||||
async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.Plant)
|
||||
.Include(schedule => schedule.CareAction)
|
||||
.Include(schedule => schedule.CareActivity)
|
||||
var assignments = await db.PlantCareScheduleAssignments
|
||||
.Include(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.CareDismissals)
|
||||
.Include(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.ActionLogs)
|
||||
.Include(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.CareAction)
|
||||
.Include(schedule => schedule.CareActivity)
|
||||
.Include(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
||||
.ThenBy(schedule => schedule.CareActivity.Name)
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.ThenBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
|
||||
.ToListAsync();
|
||||
var latestLogs = await db.ActionLogs
|
||||
.GroupBy(log => new { log.PlantId, log.CareActivityId })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.PlantId,
|
||||
group.Key.CareActivityId,
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn),
|
||||
CompletedOccurrences = group.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
var latestLogLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => (DateOnly?)log.LastPerformedOn);
|
||||
var completedLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => log.CompletedOccurrences);
|
||||
var (latestLogLookup, completedLookup) = await GetCareProgress(db);
|
||||
|
||||
var tasks = schedules
|
||||
.Select(schedule => CareTaskDto.FromSchedule(
|
||||
schedule,
|
||||
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
||||
completedLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
||||
var tasks = assignments
|
||||
.Select(assignment => CareTaskDto.FromAssignment(
|
||||
assignment,
|
||||
latestLogLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
|
||||
completedLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
|
||||
today))
|
||||
.Where(task => task.Status is "due" or "soon")
|
||||
.ToList();
|
||||
@@ -88,14 +79,15 @@ namespace plant_manager.Endpoints
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.Plant)
|
||||
.Where(schedule =>
|
||||
schedule.CareActivityId == activity.Id
|
||||
&& plantIds.Contains(schedule.PlantId))
|
||||
var assignments = await db.PlantCareScheduleAssignments
|
||||
.Include(assignment => assignment.Plant)
|
||||
.Include(assignment => assignment.PlantCareSchedule)
|
||||
.Where(assignment =>
|
||||
assignment.PlantCareSchedule.CareActivityId == activity.Id
|
||||
&& plantIds.Contains(assignment.PlantId))
|
||||
.ToListAsync();
|
||||
var schedulePlantIds = schedules
|
||||
.Select(schedule => schedule.PlantId)
|
||||
var schedulePlantIds = assignments
|
||||
.Select(assignment => assignment.PlantId)
|
||||
.ToHashSet();
|
||||
if (schedulePlantIds.Count != plantIds.Count)
|
||||
{
|
||||
@@ -118,15 +110,33 @@ namespace plant_manager.Endpoints
|
||||
var completedLookup = latestLogs.ToDictionary(
|
||||
log => log.PlantId,
|
||||
log => log.CompletedOccurrences);
|
||||
var duePlantIds = schedules
|
||||
.Where(schedule =>
|
||||
var dismissals = await db.CareDismissals
|
||||
.Where(dismissal => dismissal.CareActivityId == activity.Id && plantIds.Contains(dismissal.PlantId))
|
||||
.GroupBy(dismissal => dismissal.PlantId)
|
||||
.Select(group => new
|
||||
{
|
||||
PlantId = group.Key,
|
||||
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
|
||||
DismissedOccurrences = group.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
foreach (var dismissal in dismissals)
|
||||
{
|
||||
latestLogLookup[dismissal.PlantId] = MaxDate(
|
||||
latestLogLookup.GetValueOrDefault(dismissal.PlantId),
|
||||
dismissal.LastDismissedOn);
|
||||
completedLookup[dismissal.PlantId] = completedLookup.GetValueOrDefault(dismissal.PlantId)
|
||||
+ dismissal.DismissedOccurrences;
|
||||
}
|
||||
var duePlantIds = assignments
|
||||
.Where(assignment =>
|
||||
PlantCareFormatter.GetStatus(
|
||||
PlantCareFormatter.GetNextCareDate(
|
||||
schedule,
|
||||
latestLogLookup.GetValueOrDefault(schedule.PlantId),
|
||||
completedLookup.GetValueOrDefault(schedule.PlantId)),
|
||||
assignment.PlantCareSchedule,
|
||||
latestLogLookup.GetValueOrDefault(assignment.PlantId),
|
||||
completedLookup.GetValueOrDefault(assignment.PlantId)),
|
||||
today) == "due")
|
||||
.Select(schedule => schedule.PlantId)
|
||||
.Select(assignment => assignment.PlantId)
|
||||
.ToHashSet();
|
||||
|
||||
if (duePlantIds.Count != plantIds.Count)
|
||||
@@ -166,11 +176,11 @@ namespace plant_manager.Endpoints
|
||||
|
||||
var performedOn = request.PerformedOn ?? today;
|
||||
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
var logs = schedules
|
||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
||||
.Select(schedule => new ActionLog
|
||||
var logs = assignments
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.Select(assignment => new ActionLog
|
||||
{
|
||||
PlantId = schedule.PlantId,
|
||||
PlantId = assignment.PlantId,
|
||||
CareActionId = primaryAction.Id,
|
||||
CareActivityId = activity.Id,
|
||||
ActionNameSnapshot = activity.Name,
|
||||
@@ -192,8 +202,139 @@ namespace plant_manager.Endpoints
|
||||
|
||||
return Results.Ok(new { completed = logs.Count });
|
||||
});
|
||||
|
||||
app.MapPost("/api/care-tasks/dismiss-bulk", async (
|
||||
DismissCareTasksRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var plantIds = request.PlantIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (plantIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||
}
|
||||
|
||||
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
|
||||
if (activity is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity was not found." });
|
||||
}
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var assignments = await db.PlantCareScheduleAssignments
|
||||
.Include(assignment => assignment.Plant)
|
||||
.Include(assignment => assignment.PlantCareSchedule)
|
||||
.Where(assignment =>
|
||||
assignment.PlantCareSchedule.CareActivityId == activity.Id
|
||||
&& plantIds.Contains(assignment.PlantId))
|
||||
.ToListAsync();
|
||||
var schedulePlantIds = assignments
|
||||
.Select(assignment => assignment.PlantId)
|
||||
.ToHashSet();
|
||||
if (schedulePlantIds.Count != plantIds.Count)
|
||||
{
|
||||
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
|
||||
}
|
||||
|
||||
var (latestLookup, completedLookup) = await GetCareProgress(db, activity.Id, plantIds);
|
||||
var duePlantIds = assignments
|
||||
.Where(assignment =>
|
||||
PlantCareFormatter.GetStatus(
|
||||
PlantCareFormatter.GetNextCareDate(
|
||||
assignment.PlantCareSchedule,
|
||||
latestLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
|
||||
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
|
||||
today) == "due")
|
||||
.Select(assignment => assignment.PlantId)
|
||||
.ToHashSet();
|
||||
|
||||
if (duePlantIds.Count != plantIds.Count)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Only due care tasks can be dismissed." });
|
||||
}
|
||||
|
||||
var dismissedOn = request.DismissedOn ?? today;
|
||||
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||
var dismissals = assignments
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.Select(assignment => new CareDismissal
|
||||
{
|
||||
PlantId = assignment.PlantId,
|
||||
CareActivityId = activity.Id,
|
||||
DismissedOn = dismissedOn,
|
||||
Notes = notes
|
||||
})
|
||||
.ToList();
|
||||
|
||||
db.CareDismissals.AddRange(dismissals);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { dismissed = dismissals.Count });
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<(
|
||||
Dictionary<(int PlantId, int CareActivityId), DateOnly?> LatestLookup,
|
||||
Dictionary<(int PlantId, int CareActivityId), int> CompletedLookup)> GetCareProgress(
|
||||
ApplicationDbContext db,
|
||||
int? careActivityId = null,
|
||||
IReadOnlyList<int>? plantIds = null)
|
||||
{
|
||||
var logsQuery = db.ActionLogs.AsQueryable();
|
||||
var dismissalsQuery = db.CareDismissals.AsQueryable();
|
||||
if (careActivityId is not null)
|
||||
{
|
||||
logsQuery = logsQuery.Where(log => log.CareActivityId == careActivityId);
|
||||
dismissalsQuery = dismissalsQuery.Where(dismissal => dismissal.CareActivityId == careActivityId);
|
||||
}
|
||||
if (plantIds is not null)
|
||||
{
|
||||
logsQuery = logsQuery.Where(log => plantIds.Contains(log.PlantId));
|
||||
dismissalsQuery = dismissalsQuery.Where(dismissal => plantIds.Contains(dismissal.PlantId));
|
||||
}
|
||||
|
||||
var latestLogs = await logsQuery
|
||||
.GroupBy(log => new { log.PlantId, log.CareActivityId })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.PlantId,
|
||||
group.Key.CareActivityId,
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn),
|
||||
CompletedOccurrences = group.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
var latestLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => (DateOnly?)log.LastPerformedOn);
|
||||
var completedLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActivityId),
|
||||
log => log.CompletedOccurrences);
|
||||
|
||||
var latestDismissals = await dismissalsQuery
|
||||
.GroupBy(dismissal => new { dismissal.PlantId, dismissal.CareActivityId })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.PlantId,
|
||||
group.Key.CareActivityId,
|
||||
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
|
||||
DismissedOccurrences = group.Count()
|
||||
})
|
||||
.ToListAsync();
|
||||
foreach (var dismissal in latestDismissals)
|
||||
{
|
||||
var key = (dismissal.PlantId, dismissal.CareActivityId);
|
||||
latestLookup[key] = MaxDate(latestLookup.GetValueOrDefault(key), dismissal.LastDismissedOn);
|
||||
completedLookup[key] = completedLookup.GetValueOrDefault(key) + dismissal.DismissedOccurrences;
|
||||
}
|
||||
|
||||
return (latestLookup, completedLookup);
|
||||
}
|
||||
|
||||
private static DateOnly MaxDate(DateOnly? left, DateOnly right) =>
|
||||
left is null || right > left ? right : left.Value;
|
||||
|
||||
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
|
||||
activity.Actions
|
||||
.SelectMany(action => action.Resources)
|
||||
|
||||
@@ -43,7 +43,8 @@ namespace plant_manager.Endpoints
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.Include(plant => plant.GroupMemberships)
|
||||
.ThenInclude(membership => membership.PlantGroup)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.Include(plant => plant.ActionLogs)
|
||||
.AsSplitQuery()
|
||||
@@ -65,7 +66,7 @@ namespace plant_manager.Endpoints
|
||||
.Where(flag => flag.ResolvedOn == null)
|
||||
.OrderBy(flag => flag.Definition.Name)
|
||||
.Select(flag => flag.Definition.Name)),
|
||||
plant.CareSchedules.Count,
|
||||
plant.CareScheduleAssignments.Count,
|
||||
plant.ActionLogs.Count
|
||||
})
|
||||
.ToList();
|
||||
@@ -87,18 +88,23 @@ namespace plant_manager.Endpoints
|
||||
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||
{
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.Plant)
|
||||
.Include(schedule => schedule.CareActivity)
|
||||
.Include(schedule => schedule.CareAction)
|
||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
||||
.ThenBy(schedule => schedule.CareActivity.Name)
|
||||
.Include(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.OrderBy(schedule => schedule.CareActivity.Name)
|
||||
.ThenBy(schedule => schedule.Id)
|
||||
.ToListAsync();
|
||||
var rows = schedules
|
||||
.Select(schedule => new object?[]
|
||||
{
|
||||
schedule.Id,
|
||||
schedule.PlantId,
|
||||
schedule.Plant.Nickname,
|
||||
string.Join(", ", schedule.Assignments
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.Select(assignment => assignment.PlantId)),
|
||||
string.Join(", ", schedule.Assignments
|
||||
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||
.Select(assignment => assignment.Plant.Nickname)),
|
||||
schedule.CareActivity.Name,
|
||||
schedule.CareAction.Name,
|
||||
schedule.EveryDays,
|
||||
@@ -115,8 +121,8 @@ namespace plant_manager.Endpoints
|
||||
|
||||
AddSheet(workbook, "Care Schedules", [
|
||||
"ID",
|
||||
"Plant ID",
|
||||
"Plant",
|
||||
"Plant IDs",
|
||||
"Plants",
|
||||
"Activity",
|
||||
"Primary Action",
|
||||
"Every Days",
|
||||
|
||||
@@ -69,7 +69,7 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
|
||||
}
|
||||
await ImportTaxa(workbook, db, context, apply);
|
||||
ImportTaxa(workbook, context);
|
||||
|
||||
if (context.Sheets.Count == 0)
|
||||
{
|
||||
@@ -343,87 +343,27 @@ namespace plant_manager.Endpoints
|
||||
AddSummary(context, summary);
|
||||
}
|
||||
|
||||
private static async Task ImportTaxa(
|
||||
private static void ImportTaxa(
|
||||
XLWorkbook workbook,
|
||||
ApplicationDbContext db,
|
||||
ImportContext context,
|
||||
bool apply)
|
||||
ImportContext context)
|
||||
{
|
||||
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]);
|
||||
var existing = await db.PlantTaxa.ToListAsync();
|
||||
var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: []);
|
||||
var summary = new MutableSummary(TaxaSheet);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var name = Required(row, "Name", context);
|
||||
var genus = Required(row, "Genus", context);
|
||||
var species = Required(row, "Species", context);
|
||||
if (name is null || genus is null || species is null)
|
||||
{
|
||||
context.Issues.Add(new CatalogImportIssue(
|
||||
TaxaSheet,
|
||||
row.Row.RowNumber(),
|
||||
"Taxa",
|
||||
"Taxa must be imported from GBIF search results, not spreadsheet rows.",
|
||||
"error"));
|
||||
summary.Skips++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Key(name);
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
Duplicate(row, "Name", context);
|
||||
summary.Skips++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var cultivar = Optional(row, "Cultivar");
|
||||
var variety = Optional(row, "Variety");
|
||||
var authority = Optional(row, "Authority");
|
||||
var family = Optional(row, "Family");
|
||||
var commonName = Optional(row, "Common Name", "CommonName");
|
||||
var externalSource = Optional(row, "External Source", "Source");
|
||||
var externalId = Optional(row, "External ID", "External Id", "GBIF ID");
|
||||
|
||||
if (byName.TryGetValue(key, out var taxon))
|
||||
{
|
||||
summary.Updates++;
|
||||
if (apply)
|
||||
{
|
||||
taxon.Name = name;
|
||||
taxon.Genus = genus;
|
||||
taxon.Species = species;
|
||||
taxon.Cultivar = cultivar;
|
||||
taxon.Variety = variety;
|
||||
taxon.Authority = authority;
|
||||
taxon.Family = family;
|
||||
taxon.CommonName = commonName;
|
||||
taxon.ExternalSource = externalSource;
|
||||
taxon.ExternalId = externalId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
summary.Creates++;
|
||||
if (apply)
|
||||
{
|
||||
db.PlantTaxa.Add(new PlantTaxon
|
||||
{
|
||||
Name = name,
|
||||
Genus = genus,
|
||||
Species = species,
|
||||
Cultivar = cultivar,
|
||||
Variety = variety,
|
||||
Authority = authority,
|
||||
Family = family,
|
||||
CommonName = commonName,
|
||||
ExternalSource = externalSource,
|
||||
ExternalId = externalId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddSummary(context, summary);
|
||||
|
||||
@@ -8,9 +8,124 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
public static void MapPlantCareScheduleEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapPost("/api/plant-care-schedules/bulk", async (
|
||||
app.MapGet("/api/plant-care-schedules", async (ApplicationDbContext db) =>
|
||||
{
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.CareActivity)
|
||||
.Include(schedule => schedule.CareAction)
|
||||
.Include(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.OrderBy(schedule => schedule.CareActivity.Name)
|
||||
.ThenBy(schedule => schedule.Id)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(schedules.Select(PlantCareScheduleRuleDto.FromSchedule));
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-care-schedules", async (
|
||||
BulkSavePlantCareScheduleRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var context = await ValidateScheduleRequest(request, db);
|
||||
if (context.Error is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = context.Error });
|
||||
}
|
||||
|
||||
var schedule = new PlantCareSchedule
|
||||
{
|
||||
CareActivityId = context.Activity!.Id,
|
||||
CareActionId = context.PrimaryAction!.Id,
|
||||
CareActivity = context.Activity,
|
||||
CareAction = context.PrimaryAction
|
||||
};
|
||||
ApplyRecurrence(schedule, context.Recurrence!);
|
||||
schedule.Assignments = context.PlantIds
|
||||
.Select(plantId => new PlantCareScheduleAssignment { PlantId = plantId })
|
||||
.ToList();
|
||||
|
||||
db.PlantCareSchedules.Add(schedule);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await LoadSchedule(schedule.Id, db);
|
||||
return Results.Created($"/api/plant-care-schedules/{schedule.Id}", PlantCareScheduleRuleDto.FromSchedule(saved!));
|
||||
});
|
||||
|
||||
app.MapPut("/api/plant-care-schedules/{id:int}", async (
|
||||
int id,
|
||||
BulkSavePlantCareScheduleRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var schedule = await db.PlantCareSchedules
|
||||
.Include(item => item.Assignments)
|
||||
.FirstOrDefaultAsync(item => item.Id == id);
|
||||
if (schedule is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var context = await ValidateScheduleRequest(request, db);
|
||||
if (context.Error is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = context.Error });
|
||||
}
|
||||
|
||||
schedule.CareActivityId = context.Activity!.Id;
|
||||
schedule.CareActionId = context.PrimaryAction!.Id;
|
||||
ApplyRecurrence(schedule, context.Recurrence!);
|
||||
|
||||
var nextPlantIds = context.PlantIds.ToHashSet();
|
||||
var assignmentsToRemove = schedule.Assignments
|
||||
.Where(assignment => !nextPlantIds.Contains(assignment.PlantId))
|
||||
.ToList();
|
||||
db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
|
||||
|
||||
var existingPlantIds = schedule.Assignments
|
||||
.Select(assignment => assignment.PlantId)
|
||||
.ToHashSet();
|
||||
foreach (var plantId in context.PlantIds.Where(plantId => !existingPlantIds.Contains(plantId)))
|
||||
{
|
||||
schedule.Assignments.Add(new PlantCareScheduleAssignment
|
||||
{
|
||||
PlantCareScheduleId = schedule.Id,
|
||||
PlantId = plantId
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var saved = await LoadSchedule(schedule.Id, db);
|
||||
return Results.Ok(PlantCareScheduleRuleDto.FromSchedule(saved!));
|
||||
});
|
||||
|
||||
app.MapDelete("/api/plant-care-schedules/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||
{
|
||||
var schedule = await db.PlantCareSchedules.FindAsync(id);
|
||||
if (schedule is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.PlantCareSchedules.Remove(schedule);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<PlantCareSchedule?> LoadSchedule(int id, ApplicationDbContext db) =>
|
||||
await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.CareActivity)
|
||||
.Include(schedule => schedule.CareAction)
|
||||
.Include(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(schedule => schedule.Id == id);
|
||||
|
||||
private static async Task<ValidatedScheduleRequest> ValidateScheduleRequest(
|
||||
BulkSavePlantCareScheduleRequest request,
|
||||
ApplicationDbContext db)
|
||||
{
|
||||
var plantIds = request.PlantIds
|
||||
.Where(id => id > 0)
|
||||
@@ -18,7 +133,7 @@ namespace plant_manager.Endpoints
|
||||
.ToList();
|
||||
if (plantIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||
return ValidatedScheduleRequest.Invalid("At least one plant is required.");
|
||||
}
|
||||
|
||||
var activity = await db.CareActivities
|
||||
@@ -27,22 +142,22 @@ namespace plant_manager.Endpoints
|
||||
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
|
||||
if (activity is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity was not found." });
|
||||
return ValidatedScheduleRequest.Invalid("Care activity was not found.");
|
||||
}
|
||||
|
||||
var primaryAction = activity.PrimaryAction();
|
||||
if (primaryAction is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity has no configured actions." });
|
||||
return ValidatedScheduleRequest.Invalid("Care activity has no configured actions.");
|
||||
}
|
||||
|
||||
var plants = await db.Plants
|
||||
.Include(plant => plant.CareSchedules)
|
||||
var existingPlantIds = await db.Plants
|
||||
.Where(plant => plantIds.Contains(plant.Id))
|
||||
.Select(plant => plant.Id)
|
||||
.ToListAsync();
|
||||
if (plants.Count != plantIds.Count)
|
||||
if (existingPlantIds.Count != plantIds.Count)
|
||||
{
|
||||
return Results.BadRequest(new { error = "One or more plants were not found." });
|
||||
return ValidatedScheduleRequest.Invalid("One or more plants were not found.");
|
||||
}
|
||||
|
||||
var recurrence = NormalizeRecurrence(
|
||||
@@ -55,60 +170,8 @@ namespace plant_manager.Endpoints
|
||||
request.EndsAfterOccurrences,
|
||||
request.ScheduledFor,
|
||||
request.EveryDays);
|
||||
foreach (var plant in plants)
|
||||
{
|
||||
var schedule = plant.CareSchedules
|
||||
.FirstOrDefault(item => item.CareActivityId == activity.Id);
|
||||
if (schedule is null)
|
||||
{
|
||||
schedule = new PlantCareSchedule
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
CareActionId = primaryAction.Id,
|
||||
CareActivityId = activity.Id
|
||||
};
|
||||
plant.CareSchedules.Add(schedule);
|
||||
}
|
||||
|
||||
schedule.CareActionId = primaryAction.Id;
|
||||
schedule.CareActivityId = activity.Id;
|
||||
ApplyRecurrence(schedule, recurrence);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { updated = plants.Count });
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-care-schedules/bulk-remove", async (
|
||||
BulkSavePlantCareScheduleRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var plantIds = request.PlantIds
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (plantIds.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||
}
|
||||
|
||||
if (request.CareActivityId <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care activity is required." });
|
||||
}
|
||||
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Where(schedule =>
|
||||
schedule.CareActivityId == request.CareActivityId
|
||||
&& plantIds.Contains(schedule.PlantId))
|
||||
.ToListAsync();
|
||||
|
||||
db.PlantCareSchedules.RemoveRange(schedules);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { removed = schedules.Count });
|
||||
});
|
||||
return ValidatedScheduleRequest.Valid(plantIds, activity, primaryAction, recurrence);
|
||||
}
|
||||
|
||||
internal static ScheduleRecurrence NormalizeRecurrence(
|
||||
@@ -139,7 +202,7 @@ namespace plant_manager.Endpoints
|
||||
};
|
||||
var normalizedEndsMode = mode == "none"
|
||||
? "after"
|
||||
: NormalizeOption(endsMode, new HashSet<string> { "on", "after" }, "after");
|
||||
: NormalizeOption(endsMode, new HashSet<string> { "never", "on", "after" }, "never");
|
||||
var normalizedEveryDays = mode switch
|
||||
{
|
||||
"none" => Math.Clamp(everyDays ?? 7, 1, 365),
|
||||
@@ -199,5 +262,23 @@ namespace plant_manager.Endpoints
|
||||
string EndsMode,
|
||||
DateOnly? EndsOn,
|
||||
int? EndsAfterOccurrences);
|
||||
|
||||
private sealed record ValidatedScheduleRequest(
|
||||
IReadOnlyList<int> PlantIds,
|
||||
CareActivity? Activity,
|
||||
CareAction? PrimaryAction,
|
||||
ScheduleRecurrence? Recurrence,
|
||||
string? Error)
|
||||
{
|
||||
public static ValidatedScheduleRequest Invalid(string error) =>
|
||||
new([], null, null, null, error);
|
||||
|
||||
public static ValidatedScheduleRequest Valid(
|
||||
IReadOnlyList<int> plantIds,
|
||||
CareActivity activity,
|
||||
CareAction primaryAction,
|
||||
ScheduleRecurrence recurrence) =>
|
||||
new(plantIds, activity, primaryAction, recurrence, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,34 @@ namespace plant_manager.Endpoints
|
||||
var plants = await db.Plants
|
||||
.Include(plant => plant.Taxon)
|
||||
.Include(plant => plant.Location)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.CareAction)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.ActionLogs)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.CareDismissals)
|
||||
.Include(plant => plant.ActionLogs)
|
||||
.ThenInclude(log => log.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(plant => plant.CareDismissals)
|
||||
.Include(plant => plant.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.Include(plant => plant.GroupMemberships)
|
||||
@@ -42,20 +56,34 @@ namespace plant_manager.Endpoints
|
||||
var plant = await db.Plants
|
||||
.Include(item => item.Taxon)
|
||||
.Include(item => item.Location)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.CareAction)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.ActionLogs)
|
||||
.Include(plant => plant.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.CareDismissals)
|
||||
.Include(plant => plant.ActionLogs)
|
||||
.ThenInclude(log => log.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(plant => plant.CareDismissals)
|
||||
.Include(plant => plant.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.Include(plant => plant.GroupMemberships)
|
||||
@@ -79,6 +107,10 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Taxon was not found." });
|
||||
}
|
||||
if (taxon is not null && !IsGbifTaxon(taxon))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
|
||||
}
|
||||
|
||||
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
||||
if (request.LocationId is not null && location is null)
|
||||
@@ -122,20 +154,34 @@ namespace plant_manager.Endpoints
|
||||
var plant = await db.Plants
|
||||
.Include(item => item.Taxon)
|
||||
.Include(item => item.Location)
|
||||
.Include(item => item.CareSchedules)
|
||||
.Include(item => item.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(item => item.CareSchedules)
|
||||
.Include(item => item.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.CareAction)
|
||||
.Include(item => item.CareSchedules)
|
||||
.Include(item => item.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.CareActivity)
|
||||
.ThenInclude(activity => activity.Actions)
|
||||
.ThenInclude(action => action.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(item => item.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.ActionLogs)
|
||||
.Include(item => item.CareScheduleAssignments)
|
||||
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||
.ThenInclude(schedule => schedule.Assignments)
|
||||
.ThenInclude(assignment => assignment.Plant)
|
||||
.ThenInclude(plant => plant.CareDismissals)
|
||||
.Include(item => item.ActionLogs)
|
||||
.ThenInclude(log => log.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.Include(item => item.CareDismissals)
|
||||
.Include(item => item.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.Include(item => item.GroupMemberships)
|
||||
@@ -152,6 +198,10 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Taxon was not found." });
|
||||
}
|
||||
if (taxon is not null && !IsGbifTaxon(taxon))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
|
||||
}
|
||||
|
||||
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
||||
if (request.LocationId is not null && location is null)
|
||||
@@ -212,6 +262,7 @@ namespace plant_manager.Endpoints
|
||||
[
|
||||
new SavePlantCareScheduleRequest(
|
||||
waterActivity.Id,
|
||||
null,
|
||||
7,
|
||||
null,
|
||||
"weekly",
|
||||
@@ -253,28 +304,40 @@ namespace plant_manager.Endpoints
|
||||
}
|
||||
|
||||
var requestedActivityIds = activityIds.ToHashSet();
|
||||
var schedulesToRemove = plant.CareSchedules
|
||||
.Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId))
|
||||
var assignmentsToRemove = plant.CareScheduleAssignments
|
||||
.Where(assignment => !requestedActivityIds.Contains(assignment.PlantCareSchedule.CareActivityId))
|
||||
.ToList();
|
||||
db.PlantCareSchedules.RemoveRange(schedulesToRemove);
|
||||
db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
|
||||
|
||||
foreach (var requestedSchedule in normalizedSchedules)
|
||||
{
|
||||
var activity = activitiesById[requestedSchedule.CareActivityId];
|
||||
var primaryAction = activity.PrimaryAction()!;
|
||||
var schedule = plant.CareSchedules
|
||||
.FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId);
|
||||
if (schedule is null)
|
||||
var assignment = plant.CareScheduleAssignments
|
||||
.FirstOrDefault(item => item.PlantCareSchedule.CareActivityId == requestedSchedule.CareActivityId);
|
||||
var schedule = assignment?.PlantCareSchedule;
|
||||
if (assignment is null)
|
||||
{
|
||||
schedule = new PlantCareSchedule
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
CareActionId = primaryAction.Id,
|
||||
CareActivityId = activity.Id,
|
||||
CareAction = primaryAction,
|
||||
CareActivity = activity
|
||||
CareActivity = activity,
|
||||
Assignments =
|
||||
[
|
||||
new PlantCareScheduleAssignment
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
Plant = plant
|
||||
}
|
||||
]
|
||||
};
|
||||
plant.CareSchedules.Add(schedule);
|
||||
db.PlantCareSchedules.Add(schedule);
|
||||
}
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
schedule.CareActionId = primaryAction.Id;
|
||||
@@ -295,5 +358,9 @@ namespace plant_manager.Endpoints
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsGbifTaxon(PlantTaxon taxon) =>
|
||||
string.Equals(taxon.ExternalSource, "gbif", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(taxon.ExternalId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,64 +18,6 @@ namespace plant_manager.Endpoints
|
||||
return Results.Ok(taxa);
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
var validation = ValidateTaxonRequest(request);
|
||||
if (validation.Error is not null)
|
||||
{
|
||||
return EndpointHelpers.BadRequest(validation.Error);
|
||||
}
|
||||
|
||||
var taxon = new PlantTaxon
|
||||
{
|
||||
Name = validation.Name,
|
||||
Genus = validation.Genus,
|
||||
Species = validation.Species,
|
||||
Cultivar = validation.Cultivar,
|
||||
Variety = validation.Variety,
|
||||
Authority = validation.Authority,
|
||||
Family = validation.Family,
|
||||
CommonName = validation.CommonName,
|
||||
ExternalSource = validation.ExternalSource,
|
||||
ExternalId = validation.ExternalId
|
||||
};
|
||||
|
||||
db.PlantTaxa.Add(taxon);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/plant-taxa/{taxon.Id}", PlantTaxonDto.FromTaxon(taxon));
|
||||
});
|
||||
|
||||
app.MapPut("/api/plant-taxa/{id:int}", async (int id, SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
var validation = ValidateTaxonRequest(request);
|
||||
if (validation.Error is not null)
|
||||
{
|
||||
return EndpointHelpers.BadRequest(validation.Error);
|
||||
}
|
||||
|
||||
var taxon = await db.PlantTaxa.FindAsync(id);
|
||||
if (taxon is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
taxon.Name = validation.Name;
|
||||
taxon.Genus = validation.Genus;
|
||||
taxon.Species = validation.Species;
|
||||
taxon.Cultivar = validation.Cultivar;
|
||||
taxon.Variety = validation.Variety;
|
||||
taxon.Authority = validation.Authority;
|
||||
taxon.Family = validation.Family;
|
||||
taxon.CommonName = validation.CommonName;
|
||||
taxon.ExternalSource = validation.ExternalSource;
|
||||
taxon.ExternalId = validation.ExternalId;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(PlantTaxonDto.FromTaxon(taxon));
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -151,40 +93,6 @@ namespace plant_manager.Endpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static (
|
||||
string Name,
|
||||
string Genus,
|
||||
string Species,
|
||||
string? Cultivar,
|
||||
string? Variety,
|
||||
string? Authority,
|
||||
string? Family,
|
||||
string? CommonName,
|
||||
string? ExternalSource,
|
||||
string? ExternalId,
|
||||
string? Error) ValidateTaxonRequest(SavePlantTaxonRequest request)
|
||||
{
|
||||
if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Name, genus, and species are required.", out var name, out _)
|
||||
|| !EndpointHelpers.TryNormalizeRequired(request.Genus, "Name, genus, and species are required.", out var genus, out _)
|
||||
|| !EndpointHelpers.TryNormalizeRequired(request.Species, "Name, genus, and species are required.", out var species, out _))
|
||||
{
|
||||
return (string.Empty, string.Empty, string.Empty, null, null, null, null, null, null, null, "Name, genus, and species are required.");
|
||||
}
|
||||
|
||||
return (
|
||||
name,
|
||||
genus,
|
||||
species,
|
||||
EndpointHelpers.NormalizeOptional(request.Cultivar),
|
||||
EndpointHelpers.NormalizeOptional(request.Variety),
|
||||
EndpointHelpers.NormalizeOptional(request.Authority),
|
||||
EndpointHelpers.NormalizeOptional(request.Family),
|
||||
EndpointHelpers.NormalizeOptional(request.CommonName),
|
||||
EndpointHelpers.NormalizeOptional(request.ExternalSource),
|
||||
EndpointHelpers.NormalizeOptional(request.ExternalId),
|
||||
null);
|
||||
}
|
||||
|
||||
private static string? FirstScientificNamePart(string scientificName) =>
|
||||
scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.Migrate();
|
||||
await db.SeedDevelopmentDataAsync();
|
||||
|
||||
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
|
||||
await plantInfoDb.EnsureSearchSchemaAsync();
|
||||
|
||||
Reference in New Issue
Block a user