From cc6f40222e786de9a4f94783ed9212f0fe5feb3f Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Sun, 14 Jun 2026 22:37:22 +0000 Subject: [PATCH] add care workflow and schedule management --- .gitignore | 3 + plant-manager-web/src/App.tsx | 63 ++- plant-manager-web/src/api.ts | 51 +- .../src/components/ActivitiesView.tsx | 51 +- plant-manager-web/src/components/CareView.tsx | 376 +++++++++++++ plant-manager-web/src/components/HomeView.tsx | 366 +++---------- .../src/components/PlantManagementView.tsx | 2 +- .../src/components/SchedulesView.tsx | 161 ++++-- plant-manager-web/src/components/TaxaView.tsx | 131 +---- plant-manager-web/src/domain.ts | 62 ++- plant-manager-web/src/form-state.ts | 82 +-- plant-manager-web/src/styles.css | 138 +++++ plant-manager-web/src/use-app-actions.ts | 109 ++-- plant-manager-web/src/use-app-editors.ts | 78 +-- plant-manager-web/src/use-dashboard-data.ts | 14 +- plant-manager/Contracts.cs | 155 ++++-- plant-manager/Data/ApplicationDbContext.cs | 45 +- ... 20260614222157_InitialCreate.Designer.cs} | 106 +++- ...ate.cs => 20260614222157_InitialCreate.cs} | 127 +++-- .../ApplicationDbContextModelSnapshot.cs | 104 +++- plant-manager/Data/Models/CareActivity.cs | 1 + plant-manager/Data/Models/CareDismissal.cs | 14 + plant-manager/Data/Models/Plant.cs | 3 +- .../Data/Models/PlantCareSchedule.cs | 3 +- .../Models/PlantCareScheduleAssignment.cs | 11 + plant-manager/Data/Models/PlantTaxon.cs | 4 +- plant-manager/Data/SeedData.cs | 514 ++++++++++++++++++ plant-manager/Endpoints/ActionLogEndpoints.cs | 74 +++ .../Endpoints/CareActivityEndpoints.cs | 6 + plant-manager/Endpoints/CareTaskEndpoints.cs | 231 ++++++-- plant-manager/Endpoints/ExportEndpoints.cs | 24 +- plant-manager/Endpoints/ImportEndpoints.cs | 82 +-- .../Endpoints/PlantCareScheduleEndpoints.cs | 271 +++++---- plant-manager/Endpoints/PlantEndpoints.cs | 103 +++- .../Endpoints/PlantTaxonEndpoints.cs | 92 ---- plant-manager/Program.cs | 1 + 36 files changed, 2543 insertions(+), 1115 deletions(-) create mode 100644 plant-manager-web/src/components/CareView.tsx rename plant-manager/Data/Migrations/{20260612235128_InitialCreate.Designer.cs => 20260614222157_InitialCreate.Designer.cs} (89%) rename plant-manager/Data/Migrations/{20260612235128_InitialCreate.cs => 20260614222157_InitialCreate.cs} (89%) create mode 100644 plant-manager/Data/Models/CareDismissal.cs create mode 100644 plant-manager/Data/Models/PlantCareScheduleAssignment.cs create mode 100644 plant-manager/Data/SeedData.cs diff --git a/.gitignore b/.gitignore index fe0adc8..e9dafb5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ .env .codex/ data/ +plant-manager/App_Data/ +core.* +**/core.* # User-specific files *.rsuser diff --git a/plant-manager-web/src/App.tsx b/plant-manager-web/src/App.tsx index 3f7931b..5930a87 100644 --- a/plant-manager-web/src/App.tsx +++ b/plant-manager-web/src/App.tsx @@ -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() {

Care

+
@@ -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' ? ( + void dismissCare(payload)} + onLogCare={(payload, requireDueSchedule) => void logCare(payload, requireDueSchedule)} + /> ) : view === 'taxa' ? ( 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() { void completeBulkTasks(tasks)} - onCompleteTask={(task) => void completeTask(task)} - onOpenPlant={openPlantDetail} + onOpenCare={() => setView('care')} + onOpenPlants={() => setView('plant-management')} /> )} diff --git a/plant-manager-web/src/api.ts b/plant-manager-web/src/api.ts index 6d40668..8d25df6 100644 --- a/plant-manager-web/src/api.ts +++ b/plant-manager-web/src/api.ts @@ -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('/api/plant-taxa', { - method: 'POST', - body: JSON.stringify(payload), - }); -} - export async function importPlantTaxon(payload: PlantInfoSearchResult) { return request('/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(`/api/plant-taxa/${id}`, { - method: 'PUT', - body: JSON.stringify(payload), - }); -} - export async function deletePlantTaxon(id: number) { return request(`/api/plant-taxa/${id}`, { method: 'DELETE', @@ -347,23 +334,47 @@ export async function getCareTasks() { return request('/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('/api/plant-care-schedules'); +} + +export async function createPlantCareSchedule(payload: BulkPlantCareSchedulePayload) { + return request('/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(`/api/plant-care-schedules/${id}`, { + method: 'PUT', body: JSON.stringify(payload), }); } +export async function deletePlantCareSchedule(id: number) { + return request(`/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), + }); +} diff --git a/plant-manager-web/src/components/ActivitiesView.tsx b/plant-manager-web/src/components/ActivitiesView.tsx index 7ec6e3c..892566e 100644 --- a/plant-manager-web/src/components/ActivitiesView.tsx +++ b/plant-manager-web/src/components/ActivitiesView.tsx @@ -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({

{activityAction.name}

{activityAction.description ?? 'No description'}

{activityAction.resources.length > 0 ? ( -

- {activityAction.resources.map((resource) => { - const amount = resource.quantity === null - ? '' - : ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`; - return `${resource.name}${amount}`; - }).join(', ')} -

+
+ {activityAction.resources.map((resource) => ( +
+

{formatActivityResource(resource)}

+ {resource.producedByRecipe ? ( +
+
{resource.producedByRecipe.name}
+ {resource.producedByRecipe.components.length > 0 ? ( +
    + {resource.producedByRecipe.components.map((component) => ( +
  1. + {formatRecipeComponent(component)} +
  2. + ))} +
+ ) : ( +

No recipe components configured.

+ )} + {resource.producedByRecipe.notes ? ( +

{resource.producedByRecipe.notes}

+ ) : null} +
+ ) : null} +
+ ))} +
) : (

No resources

)} @@ -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}`; +} diff --git a/plant-manager-web/src/components/CareView.tsx b/plant-manager-web/src/components/CareView.tsx new file mode 100644 index 0000000..25319fa --- /dev/null +++ b/plant-manager-web/src/components/CareView.tsx @@ -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('due'); + const [activityId, setActivityId] = useState(''); + const [selectedPlantIds, setSelectedPlantIds] = useState([]); + const [plantQuery, setPlantQuery] = useState(''); + const [performedOn, setPerformedOn] = useState(() => new Date().toISOString().slice(0, 10)); + const [notes, setNotes] = useState(''); + const [resourceEdits, setResourceEdits] = useState>({}); + + 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 ( + <> + +

{error ?? 'Choose care to perform, review the procedure, then log the work.'}

+
+ +
+
+
+

Care

+
+ {selectedPlantIds.length} selected +
+ +
+ {modeOptions.map((option) => ( + + ))} +
+ +
+ + + +
+ +
+
+
+

Plants

+ +
+ +
+ {!isLoading && visiblePlants.length === 0 ? ( +

{mode === 'adHoc' ? 'No plants match that search.' : 'No care tasks in this queue.'}

+ ) : null} + + {visiblePlants.map((plant) => { + const task = taskPlants.find((item) => item.plantId === plant.id); + return ( + + ); + })} +
+
+ +
+
+

Procedure

+
+ + {!selectedActivity ? ( +

Select an activity.

+ ) : ( +
+ {selectedActivity.actions.map((action) => ( +
+
+

{action.name}

+

{action.description ?? 'No description'}

+ {action.resources.length === 0 ? ( +

No resources configured.

+ ) : action.resources.map((resource) => ( +
+

{formatResource(resource)}

+
+ setResourceEdits((current) => ({ + ...current, + [resource.actionResourceId]: { + quantity: event.target.value, + unit: current[resource.actionResourceId]?.unit ?? resource.unit ?? '', + }, + }))} + /> + setResourceEdits((current) => ({ + ...current, + [resource.actionResourceId]: { + quantity: current[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity), + unit: event.target.value, + }, + }))} + /> +
+ {resource.producedByRecipe ? ( +
+
{resource.producedByRecipe.name}
+
    + {resource.producedByRecipe.components.map((component) => ( +
  1. {formatRecipeComponent(component)}
  2. + ))} +
+ {resource.producedByRecipe.notes ?

{resource.producedByRecipe.notes}

: null} +
+ ) : null} +
+ ))} +
+
+ ))} +
+ )} +
+
+ +