diff --git a/docs/schema-table.md b/docs/schema-table.md index e306d6f..a6a30fd 100644 --- a/docs/schema-table.md +++ b/docs/schema-table.md @@ -1,41 +1,110 @@ -# Plant-Man Minimal Schema +# Plant-Man Schema -This is the current working schema for the fresh React + Minimal API baseline. +Current working schema for the React + Minimal API app. -## `PlantTaxon` +## `Plant` -Reference data for a plant taxon. This may represent a species, cultivar, variety, hybrid, or other useful identification level. +A plant object tracked by the user. | Field | Type | Required | Notes | | - | - | - | - | | `id` | `int` | Yes | Primary key | -| `name` | `string` | Yes | Common name | +| `taxon_id` | `int` | No | Optional foreign key to `PlantTaxon` | +| `location_id` | `int` | No | Optional foreign key to `PlantLocation` | +| `nickname` | `string` | Yes | User-facing plant name | + +## `PlantTaxon` + +Reference data for plant identity. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `name` | `string` | Yes | Common or display name | | `genus` | `string` | Yes | Botanical genus | | `species` | `string` | Yes | Botanical species | | `cultivar` | `string` | No | Optional cultivar | | `variety` | `string` | No | Optional variety | | `authority` | `string` | No | Optional authority | -## `Plant` +## `PlantLocation` -A plant owned or tracked by the user. +Reference data for where plants live. | Field | Type | Required | Notes | | - | - | - | - | | `id` | `int` | Yes | Primary key | -| `taxon_id` | `int` | Yes | Foreign key to `PlantTaxon` | -| `nickname` | `string` | Yes | User-facing plant name | -| `location` | `string` | Yes | Where the plant lives | +| `name` | `string` | Yes | User-facing location name | +| `notes` | `string` | No | Optional details | +| `is_enabled` | `bool` | Yes | Whether the location is available for new assignments | + +## `CareAction` + +A reusable care verb, such as water, prune, fertilize, inspect, or repot. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `name` | `string` | Yes | User-facing action name | +| `description` | `string` | No | Optional explanation | +| `is_enabled` | `bool` | Yes | Whether the action is available for use | + +## `ActionResource` + +A resource used while performing care. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `name` | `string` | Yes | Resource name | +| `category` | `string` | No | Optional grouping, such as `Fertilizer`, `Medium`, `Treatment`, `Equipment`, or `Container` | +| `notes` | `string` | No | Optional details | +| `is_enabled` | `bool` | Yes | Whether the resource is available for use | + +## `CareActivity` + +A configurable care activity made from one or more care actions. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `name` | `string` | Yes | User-facing activity name | +| `notes` | `string` | No | Optional details | +| `is_enabled` | `bool` | Yes | Whether the activity is available for schedules and logs | + +## `CareActivityAction` + +Join data connecting a care activity to each included care action. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` | +| `care_action_id` | `int` | Yes | Foreign key to `CareAction` | +| `sort_order` | `int` | Yes | Stable display order for the activity builder | + +## `CareActivityActionResource` + +Join data connecting an activity action to the resources it uses. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `care_activity_id` | `int` | Yes | Part of the foreign key to `CareActivityAction` | +| `care_action_id` | `int` | Yes | Part of the foreign key to `CareActivityAction` | +| `action_resource_id` | `int` | Yes | Foreign key to `ActionResource` | +| `quantity` | `decimal` | No | Optional amount | +| `unit` | `string` | No | Optional unit | +| `notes` | `string` | No | Optional resource-specific instructions | ## `PlantCareSchedule` -A per-plant recurring schedule for one care action. +A per-plant recurring schedule for one care activity. Scheduler is the UI owner for these assignments. | Field | Type | Required | Notes | | - | - | - | - | | `id` | `int` | Yes | Primary key | | `plant_id` | `int` | Yes | Foreign key to `Plant` | -| `care_action_id` | `int` | Yes | Foreign key to `CareAction` | +| `care_action_id` | `int` | Yes | Snapshot/compatibility foreign key to the primary care action | +| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` | | `every_days` | `int` | Yes | Recurrence interval | | `is_enabled` | `bool` | Yes | Whether this schedule contributes to care tasks | @@ -47,50 +116,43 @@ A record of care performed for a plant. | - | - | - | - | | `id` | `int` | Yes | Primary key | | `plant_id` | `int` | Yes | Foreign key to `Plant` | -| `care_action_id` | `int` | Yes | Foreign key to `CareAction` | -| `action_name_snapshot` | `string` | Yes | Historical display name, such as `Water`, preserved if the action is renamed | +| `care_action_id` | `int` | Yes | Snapshot/compatibility foreign key to the primary care action | +| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` | +| `action_name_snapshot` | `string` | Yes | Historical display name | | `notes` | `string` | No | Optional observation | | `performed_on` | `date` | Yes | Date care was completed | -## `CareAction` - -A configurable type of care the user can log for a plant, such as watering, repotting, fertilizing, pruning, or inspection. - -| Field | Type | Required | Notes | -| - | - | - | - | -| `id` | `int` | Yes | Primary key | -| `name` | `string` | Yes | User-facing action name | -| `description` | `string` | No | Optional explanation of the action | -| `is_enabled` | `bool` | Yes | Whether the action is available for use | - -## `ActionResource` - -A resource used while performing an action. This can be a consumable, material, product, piece of equipment, container, light, or other item relevant to the action. - -| Field | Type | Required | Notes | -| - | - | - | - | -| `id` | `int` | Yes | Primary key | -| `name` | `string` | Yes | Resource name | -| `category` | `string` | No | Optional grouping, such as `Fertilizer`, `Medium`, `Treatment`, `Equipment`, or `Container` | -| `notes` | `string` | No | Optional details | -| `is_enabled` | `bool` | Yes | Whether the resource is available for use | - ## `ActionLogResource` -Join data connecting an action log to the resources used during that action. +Join data connecting an action log to the resources used. | Field | Type | Required | Notes | | - | - | - | - | | `action_log_id` | `int` | Yes | Foreign key to `ActionLog` | | `action_resource_id` | `int` | Yes | Foreign key to `ActionResource` | | `quantity` | `decimal` | No | Optional amount used | -| `unit` | `string` | No | Optional unit, such as `ml`, `tbsp`, or `g` | +| `unit` | `string` | No | Optional unit | -## Deferred Ideas +## `PlantFlagDefinition` -These concepts are still promising, but they are intentionally out of the first working baseline: +A reusable plant flag. -- user accounts -- groups or rooms -- grow media and blends -- richer taxon profiles +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `name` | `string` | Yes | Flag name | +| `color` | `string` | Yes | Display color | +| `is_enabled` | `bool` | Yes | Whether the flag can be assigned | + +## `PlantFlag` + +An assigned flag on a plant. + +| Field | Type | Required | Notes | +| - | - | - | - | +| `id` | `int` | Yes | Primary key | +| `plant_id` | `int` | Yes | Foreign key to `Plant` | +| `plant_flag_definition_id` | `int` | Yes | Foreign key to `PlantFlagDefinition` | +| `started_on` | `date` | Yes | Date the flag became active | +| `resolved_on` | `date` | No | Date the flag was resolved | +| `notes` | `string` | No | Optional details | diff --git a/plant-manager-web/src/App.tsx b/plant-manager-web/src/App.tsx index 68c1cf5..79bf521 100644 --- a/plant-manager-web/src/App.tsx +++ b/plant-manager-web/src/App.tsx @@ -2,51 +2,70 @@ import { Search } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { createActionResource, + createCareActivity, createCareAction, createPlant, + createPlantLocation, assignPlantFlag, + completeCareTasksBulk, createPlantFlag, createPlantTaxon, deleteActionResource, + deleteCareActivity, deleteCareAction, deletePlant, deletePlantFlag, + deletePlantLocation, deletePlantTaxon, getActionResources, + getCareActivities, getCareActions, getCareTasks, getPlants, getPlantFlags, + getPlantLocations, getPlantTaxa, - logCare, removePlantFlagAssignment, resolvePlantFlag, - updateActionLog, + savePlantCareSchedulesBulk, updateActionResource, + updateCareActivity, updateCareAction, updatePlant, updatePlantFlag, + updatePlantLocation, updatePlantTaxon, } from './api'; import { ActionsView } from './components/ActionsView'; +import { ActivitiesView } from './components/ActivitiesView'; import { FlagsView } from './components/FlagsView'; import { HomeView } from './components/HomeView'; +import { LocationsView } from './components/LocationsView'; +import { PlantManagementView } from './components/PlantManagementView'; import { PlantsView } from './components/PlantsView'; import { ResourcesView } from './components/ResourcesView'; +import { SchedulesView } from './components/SchedulesView'; import { TaxaView } from './components/TaxaView'; -import type { ActionResource, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantTaxon } from './domain'; +import type { ActionResource, CareActivity, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantLocation, PlantTaxon } from './domain'; import { emptyActionForm, - emptyCareLogForm, + emptyActivityForm, + emptyBulkScheduleForm, emptyFlagDefinitionForm, + emptyLocationForm, emptyPlantForm, emptyPlantFlagForm, emptyResourceForm, emptyTaxonForm, toActionForm, toActionPayload, + toActivityForm, + toActivityPayload, + toBulkSchedulePayload, toFlagDefinitionForm, toFlagDefinitionPayload, + toLocationForm, + toLocationPayload, toPlantFlagPayload, toPlantForm, toPlantPayload, @@ -55,10 +74,10 @@ import { toTaxonForm, toTaxonPayload, type ActionFormState, - type CareLogResourceFormState, - type CareLogFormState, + type ActivityFormState, + type BulkScheduleFormState, type FlagDefinitionFormState, - type PlantCareScheduleFormState, + type LocationFormState, type PlantFlagFormState, type PlantFormState, type ResourceFormState, @@ -70,35 +89,45 @@ export function App() { const [view, setView] = useState('home'); const [plants, setPlants] = useState([]); const [plantTaxa, setPlantTaxa] = useState([]); + const [plantLocations, setPlantLocations] = useState([]); const [careActions, setCareActions] = useState([]); const [actionResources, setActionResources] = useState([]); + const [careActivities, setCareActivities] = useState([]); const [plantFlagDefinitions, setPlantFlagDefinitions] = useState([]); const [careTasks, setCareTasks] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [editingPlantId, setEditingPlantId] = useState(null); const [editingTaxonId, setEditingTaxonId] = useState(null); + const [editingLocationId, setEditingLocationId] = useState(null); const [editingActionId, setEditingActionId] = useState(null); const [editingResourceId, setEditingResourceId] = useState(null); + const [editingActivityId, setEditingActivityId] = useState(null); const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState(null); - const [editingActionLogId, setEditingActionLogId] = useState(null); const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); - const [isCareLogEditorOpen, setIsCareLogEditorOpen] = useState(false); const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false); + const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false); const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false); + const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false); const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false); const [selectedPlantId, setSelectedPlantId] = useState(null); + const [selectedTaxonId, setSelectedTaxonId] = useState(null); + const [selectedLocationId, setSelectedLocationId] = useState(null); + const [selectedActionId, setSelectedActionId] = useState(null); + const [selectedResourceId, setSelectedResourceId] = useState(null); + const [selectedActivityId, setSelectedActivityId] = useState(null); + const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState(null); const [form, setForm] = useState(emptyPlantForm); const [taxonForm, setTaxonForm] = useState(emptyTaxonForm); + const [locationForm, setLocationForm] = useState(emptyLocationForm); const [actionForm, setActionForm] = useState(emptyActionForm); const [resourceForm, setResourceForm] = useState(emptyResourceForm); + const [activityForm, setActivityForm] = useState(emptyActivityForm); const [flagDefinitionForm, setFlagDefinitionForm] = useState(emptyFlagDefinitionForm); const [plantFlagForm, setPlantFlagForm] = useState(emptyPlantFlagForm); - const [careLogForm, setCareLogForm] = useState(emptyCareLogForm); + const [bulkScheduleForm, setBulkScheduleForm] = useState(emptyBulkScheduleForm); const [plantSearch, setPlantSearch] = useState(''); - const [quickTaxonForm, setQuickTaxonForm] = useState(emptyTaxonForm); - const [isCreatingPlantTaxon, setIsCreatingPlantTaxon] = useState(false); const [error, setError] = useState(null); async function loadDashboard() { @@ -107,15 +136,19 @@ export function App() { plantsResponse, tasksResponse, taxaResponse, + locationsResponse, actionsResponse, resourcesResponse, + activitiesResponse, flagsResponse, ] = await Promise.all([ getPlants(), getCareTasks(), getPlantTaxa(), + getPlantLocations(), getCareActions(), getActionResources(), + getCareActivities(), getPlantFlags(), ]); @@ -123,8 +156,10 @@ export function App() { setPlants(plantsResponse); setCareTasks(tasksResponse); setPlantTaxa(taxaResponse); + setPlantLocations(locationsResponse); setCareActions(actionsResponse); setActionResources(resourcesResponse); + setCareActivities(activitiesResponse); setPlantFlagDefinitions(flagsResponse); } catch { setError('Could not reach the Plant-Man API. Start the backend and refresh.'); @@ -163,11 +198,19 @@ export function App() { const activePlant = plants.find((plant) => plant.id === editingPlantId); const selectedPlant = plants.find((plant) => plant.id === selectedPlantId); const activeTaxon = plantTaxa.find((taxon) => taxon.id === editingTaxonId); + const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedTaxonId); + const activeLocation = plantLocations.find((location) => location.id === editingLocationId); + const selectedLocation = plantLocations.find((location) => location.id === selectedLocationId); const activeAction = careActions.find((action) => action.id === editingActionId); + const selectedAction = careActions.find((action) => action.id === selectedActionId); const activeResource = actionResources.find((resource) => resource.id === editingResourceId); + const selectedResource = actionResources.find((resource) => resource.id === selectedResourceId); + const activeActivity = careActivities.find((activity) => activity.id === editingActivityId); + const selectedActivity = careActivities.find((activity) => activity.id === selectedActivityId); const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId); + const selectedFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === selectedFlagDefinitionId); - function updateForm(field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) { + function updateForm(field: keyof PlantFormState, value: string) { setForm((current) => ({ ...current, [field]: value })); } @@ -175,6 +218,10 @@ export function App() { setTaxonForm((current) => ({ ...current, [field]: value })); } + function updateLocationForm(field: keyof LocationFormState, value: string | boolean) { + setLocationForm((current) => ({ ...current, [field]: value })); + } + function updateActionForm(field: keyof ActionFormState, value: string | boolean) { setActionForm((current) => ({ ...current, [field]: value })); } @@ -183,6 +230,10 @@ export function App() { setResourceForm((current) => ({ ...current, [field]: value })); } + function updateActivityForm(field: keyof ActivityFormState, value: ActivityFormState[keyof ActivityFormState]) { + setActivityForm((current) => ({ ...current, [field]: value })); + } + function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string | boolean) { setFlagDefinitionForm((current) => ({ ...current, [field]: value })); } @@ -191,71 +242,52 @@ export function App() { setPlantFlagForm((current) => ({ ...current, [field]: value })); } - function updateCareLogForm( - field: keyof CareLogFormState, - value: string | CareLogResourceFormState[], + function updateBulkScheduleForm( + field: keyof BulkScheduleFormState, + value: string | boolean | string[], ) { - setCareLogForm((current) => ({ ...current, [field]: value })); - } - - function updateQuickTaxonForm(field: keyof TaxonFormState, value: string) { - setQuickTaxonForm((current) => ({ ...current, [field]: value })); + setBulkScheduleForm((current) => ({ ...current, [field]: value })); } function startAddingPlant() { setEditingPlantId(null); - setForm({ - ...emptyPlantForm, - careSchedules: getDefaultPlantCareSchedules(careActions), - }); - setQuickTaxonForm(emptyTaxonForm); + setForm(emptyPlantForm); setIsPlantEditorOpen(true); - setIsCareLogEditorOpen(false); setView('plants'); } function openPlantDetail(plant: Plant) { setSelectedPlantId(plant.id); - setView('plants'); - } - - function closePlantDetail() { - setSelectedPlantId(null); + setEditingPlantId(null); + setForm(emptyPlantForm); + setIsPlantEditorOpen(false); + setView('plant-management'); } function startEditingPlant(plant: Plant) { - setSelectedPlantId(plant.id); + setSelectedPlantId(null); setEditingPlantId(plant.id); setForm(toPlantForm(plant)); - setQuickTaxonForm(emptyTaxonForm); setIsPlantEditorOpen(true); - setIsCareLogEditorOpen(false); setView('plants'); } function cancelEditing() { setEditingPlantId(null); setForm(emptyPlantForm); - setQuickTaxonForm(emptyTaxonForm); setIsPlantEditorOpen(false); } - function selectPlantTaxon(taxon: PlantTaxon) { - setForm((current) => ({ - ...current, - nickname: current.nickname.trim() ? current.nickname : taxon.name, - taxonId: String(taxon.id), - })); - } - 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); @@ -268,14 +300,38 @@ export function App() { setIsTaxonEditorOpen(false); } + function startAddingLocation() { + setEditingLocationId(null); + setSelectedLocationId(null); + setLocationForm(emptyLocationForm); + setIsLocationEditorOpen(true); + setView('locations'); + } + + function startEditingLocation(location: PlantLocation) { + setSelectedLocationId(null); + setEditingLocationId(location.id); + setLocationForm(toLocationForm(location)); + setIsLocationEditorOpen(true); + setView('locations'); + } + + function cancelEditingLocation() { + setEditingLocationId(null); + setLocationForm(emptyLocationForm); + setIsLocationEditorOpen(false); + } + function startAddingAction() { setEditingActionId(null); + setSelectedActionId(null); setActionForm(emptyActionForm); setIsActionEditorOpen(true); setView('actions'); } function startEditingAction(action: CareAction) { + setSelectedActionId(null); setEditingActionId(action.id); setActionForm(toActionForm(action)); setIsActionEditorOpen(true); @@ -290,12 +346,14 @@ export function App() { function startAddingResource() { setEditingResourceId(null); + setSelectedResourceId(null); setResourceForm(emptyResourceForm); setIsResourceEditorOpen(true); setView('resources'); } function startEditingResource(resource: ActionResource) { + setSelectedResourceId(null); setEditingResourceId(resource.id); setResourceForm(toResourceForm(resource)); setIsResourceEditorOpen(true); @@ -308,14 +366,38 @@ export function App() { setIsResourceEditorOpen(false); } + function startAddingActivity() { + setEditingActivityId(null); + setSelectedActivityId(null); + setActivityForm(emptyActivityForm); + setIsActivityEditorOpen(true); + setView('activities'); + } + + function startEditingActivity(activity: CareActivity) { + setSelectedActivityId(null); + setEditingActivityId(activity.id); + setActivityForm(toActivityForm(activity)); + setIsActivityEditorOpen(true); + setView('activities'); + } + + function cancelEditingActivity() { + setEditingActivityId(null); + setActivityForm(emptyActivityForm); + setIsActivityEditorOpen(false); + } + function startAddingFlagDefinition() { setEditingFlagDefinitionId(null); + setSelectedFlagDefinitionId(null); setFlagDefinitionForm(emptyFlagDefinitionForm); setIsFlagDefinitionEditorOpen(true); setView('flags'); } function startEditingFlagDefinition(flag: PlantFlagDefinition) { + setSelectedFlagDefinitionId(null); setEditingFlagDefinitionId(flag.id); setFlagDefinitionForm(toFlagDefinitionForm(flag)); setIsFlagDefinitionEditorOpen(true); @@ -328,37 +410,21 @@ export function App() { setIsFlagDefinitionEditorOpen(false); } - function startLoggingCare(plant?: Plant) { - const enabledAction = careActions.find((action) => action.isEnabled); - if (plant) { - setSelectedPlantId(plant.id); - } - setEditingActionLogId(null); - setCareLogForm((current) => ({ - ...current, - plantId: plant ? String(plant.id) : current.plantId, - careActionId: current.careActionId || (enabledAction ? String(enabledAction.id) : ''), - })); - setIsCareLogEditorOpen(true); - setIsPlantEditorOpen(false); - setView('plants'); - } - - function cancelCareLog() { - setEditingActionLogId(null); - setCareLogForm(emptyCareLogForm); - setIsCareLogEditorOpen(false); - } - async function savePlant() { - if (!form.nickname.trim() || !form.taxonId) { - setError('Nickname and taxon are required.'); + if (!form.nickname.trim()) { + setError('Plant name is required.'); return; } setIsSaving(true); try { - const payload = toPlantPayload(form); + const payload = editingPlantId === null + ? toPlantPayload(form) + : { + ...toPlantPayload(form), + taxonId: activePlant?.taxonId ?? null, + locationId: activePlant?.locationId ?? null, + }; if (editingPlantId === null) { await createPlant(payload); } else { @@ -373,25 +439,43 @@ export function App() { } } - async function createAndSelectPlantTaxon() { - if (!quickTaxonForm.name.trim() || !quickTaxonForm.genus.trim() || !quickTaxonForm.species.trim()) { - setError('Name, genus, and species are required for a new taxon.'); + async function updateManagedPlant(nextValues: { + taxonId?: number | null; + locationId?: number | null; + }) { + if (!selectedPlant) { return; } - setIsCreatingPlantTaxon(true); + setIsSaving(true); try { - const taxon = await createPlantTaxon(toTaxonPayload(quickTaxonForm)); - selectPlantTaxon(taxon); - setQuickTaxonForm(emptyTaxonForm); + await updatePlant(selectedPlant.id, { + nickname: selectedPlant.nickname, + taxonId: nextValues.taxonId === undefined ? selectedPlant.taxonId : nextValues.taxonId, + locationId: nextValues.locationId === undefined ? selectedPlant.locationId : nextValues.locationId, + careSchedules: null, + }); await loadDashboard(); } catch { - setError('Could not create the taxon.'); + setError('Could not update the plant assignment.'); } finally { - setIsCreatingPlantTaxon(false); + setIsSaving(false); } } + function selectManagedPlant(plantId: string) { + setSelectedPlantId(plantId ? Number(plantId) : null); + setPlantFlagForm(emptyPlantFlagForm); + } + + function setManagedPlantTaxon(taxonId: string) { + void updateManagedPlant({ taxonId: taxonId ? Number(taxonId) : null }); + } + + function setManagedPlantLocation(locationId: string) { + void updateManagedPlant({ locationId: locationId ? Number(locationId) : null }); + } + async function removePlant(plant: Plant) { const confirmed = window.confirm(`Delete ${plant.nickname}? This also removes its care log.`); if (!confirmed) { @@ -458,6 +542,50 @@ export function App() { } } + async function saveLocation() { + if (!locationForm.name.trim()) { + setError('Location name is required.'); + return; + } + + setIsSaving(true); + try { + const payload = toLocationPayload(locationForm); + if (editingLocationId === null) { + await createPlantLocation(payload); + } else { + await updatePlantLocation(editingLocationId, payload); + } + cancelEditingLocation(); + await loadDashboard(); + } catch { + setError('Could not save the location.'); + } finally { + setIsSaving(false); + } + } + + async function removeLocation(location: PlantLocation) { + const confirmed = window.confirm(`Delete ${location.name}? Locations in use will be disabled instead.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deletePlantLocation(location.id); + if (editingLocationId === location.id) { + cancelEditingLocation(); + } + await loadDashboard(); + } catch { + await loadDashboard(); + setError('Could not delete the location. If it is in use, it was disabled instead.'); + } finally { + setIsSaving(false); + } + } + async function saveAction() { if (!actionForm.name.trim()) { setError('Action name is required.'); @@ -545,6 +673,50 @@ export function App() { } } + async function saveActivity() { + if (!activityForm.name.trim() || activityForm.actions.length === 0) { + setError('Activity name and at least one action are required.'); + return; + } + + setIsSaving(true); + try { + const payload = toActivityPayload(activityForm); + if (editingActivityId === null) { + await createCareActivity(payload); + } else { + await updateCareActivity(editingActivityId, payload); + } + cancelEditingActivity(); + await loadDashboard(); + } catch { + setError('Could not save the activity.'); + } finally { + setIsSaving(false); + } + } + + async function removeActivity(activity: CareActivity) { + const confirmed = window.confirm(`Delete ${activity.name}? Activities in use will be disabled instead.`); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + await deleteCareActivity(activity.id); + if (editingActivityId === activity.id) { + cancelEditingActivity(); + } + await loadDashboard(); + } catch { + await loadDashboard(); + setError('Could not delete the activity. If it is in use, it was disabled instead.'); + } finally { + setIsSaving(false); + } + } + async function saveFlagDefinition() { if (!flagDefinitionForm.name.trim()) { setError('Flag name is required.'); @@ -568,6 +740,24 @@ export function App() { } } + async function saveBulkSchedule() { + if (!bulkScheduleForm.careActivityId || bulkScheduleForm.plantIds.length === 0) { + setError('Select an activity and at least one plant.'); + return; + } + + setIsSaving(true); + try { + await savePlantCareSchedulesBulk(toBulkSchedulePayload(bulkScheduleForm)); + setBulkScheduleForm(emptyBulkScheduleForm); + await loadDashboard(); + } catch { + setError('Could not apply the care schedule.'); + } finally { + setIsSaving(false); + } + } + async function removeFlagDefinition(flag: PlantFlagDefinition) { const confirmed = window.confirm(`Delete ${flag.name}? Flags assigned to plants will be disabled instead.`); if (!confirmed) { @@ -601,7 +791,7 @@ export function App() { setPlantFlagForm(emptyPlantFlagForm); await loadDashboard(); } catch { - setError('Could not assign the plant flag.'); + setError('Could not attach the plant flag.'); } finally { setIsSaving(false); } @@ -644,55 +834,53 @@ export function App() { } } - function completeTask(task: CareTask) { + async function completeTask(task: CareTask) { const plant = plants.find((item) => item.id === task.plantId); - setEditingActionLogId(null); - setSelectedPlantId(task.plantId); - setCareLogForm({ - plantId: String(task.plantId), - careActionId: String(task.careActionId), - performedOn: getTodayInputDate(), - notes: '', - resources: [], - }); - setIsCareLogEditorOpen(true); - setIsPlantEditorOpen(false); if (plant && !plantMatchesSearch(plant, plantSearch)) { setPlantSearch(''); } - setView('plants'); + + setIsSaving(true); + try { + await completeCareTasksBulk({ + careActivityId: task.careActivityId, + plantIds: [task.plantId], + performedOn: getTodayInputDate(), + notes: '', + resources: [], + }); + await loadDashboard(); + } catch { + setError('Could not log the care task.'); + } finally { + setIsSaving(false); + } } - async function saveCareLog() { - if (!careLogForm.plantId || !careLogForm.careActionId) { - setError('Plant and action are required to log care.'); + async function completeBulkTasks(tasks: CareTask[]) { + const dueTasks = tasks.filter((task) => task.status === 'due'); + if (dueTasks.length === 0) { + return; + } + + const action = dueTasks[0].action; + const confirmed = window.confirm(`Log ${action} for ${dueTasks.length} due plants?`); + if (!confirmed) { return; } setIsSaving(true); try { - const payload = { - plantId: Number(careLogForm.plantId), - careActionId: Number(careLogForm.careActionId), - notes: careLogForm.notes.trim() || null, - performedOn: careLogForm.performedOn || null, - resources: careLogForm.resources.map((resource) => ({ - actionResourceId: Number(resource.actionResourceId), - quantity: resource.quantity.trim() ? Number(resource.quantity) : null, - unit: resource.unit.trim() || null, - })), - }; - - if (editingActionLogId === null) { - await logCare(payload); - } else { - await updateActionLog(editingActionLogId, payload); - } - - cancelCareLog(); + await completeCareTasksBulk({ + careActivityId: dueTasks[0].careActivityId, + plantIds: dueTasks.map((task) => task.plantId), + performedOn: getTodayInputDate(), + notes: '', + resources: [], + }); await loadDashboard(); } catch { - setError('Could not log care.'); + setError('Could not log the due tasks.'); } finally { setIsSaving(false); } @@ -723,55 +911,96 @@ export function App() {
-
- - +

{error ?? 'Create and maintain plant objects.'}

+ - {selectedPlant ? ( -
-
-
-

Plant focus

-

{selectedPlant.nickname}

-
-
- - - -
-
- -
-
- Taxon - {selectedPlantTaxon ? formatTaxon(selectedPlantTaxon) : selectedPlant.taxon} -
-
- Location - {selectedPlant.location || 'No location'} -
-
- Next care - {selectedPlant.nextCare} -
-
- Active flags - {selectedPlantActiveFlags.length === 0 ? ( - None - ) : ( -
- {selectedPlantActiveFlags.map((flag) => ( - - {flag.name} - - ))} -
- )} -
-
- -
-
-
- -

Schedules

-
- {selectedPlantSchedules.length === 0 ? ( -

No schedules enabled.

- ) : ( -
- {selectedPlantSchedules.map((schedule) => ( -
-
-

{schedule.action}

-

- Every {schedule.everyDays} days - Last {schedule.lastPerformed} -

-
- - {statusLabel[schedule.status]} - -
- ))} -
- )} -
- -
-
- -

Next Tasks

-
- {selectedPlantTasks.length === 0 ? ( -

No upcoming tasks.

- ) : ( -
- {selectedPlantTasks.map((task) => ( -
-
-

{task.action}

-

{task.due}

-
-
- - {statusLabel[task.status]} - - -
-
- ))} -
- )} -
- -
-
- -

Plant Flags

-
- -
- - - - - -
- - {selectedPlantActiveFlags.length === 0 ? ( -

No active flags.

- ) : ( -
- {selectedPlantActiveFlags.map((flag) => ( -
-
-

- - {flag.name} - -

-

- {flag.category} - {flag.severity} - Started {formatDate(flag.startedOn)} - {flag.notes ? ` - ${flag.notes}` : ''} -

-
-
- - -
-
- ))} -
- )} - - {selectedPlantResolvedFlags.length > 0 ? ( -
- {selectedPlantResolvedFlags.slice(0, 4).map((flag) => ( -
-
-

{flag.name}

-

- Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''} - {flag.notes ? ` - ${flag.notes}` : ''} -

-
-
- ))} -
- ) : null} -
- -
-
- ) : null} - - {isCareLogEditorOpen ? ( -
-
-
-

Manual entry

-

{editingActionLogId === null ? 'Log care' : 'Edit care log'}

-
- -
- -
- - - - -
- Resources - {enabledActionResources.length === 0 ? ( -

No enabled resources.

- ) : null} - {enabledActionResources.map((resource) => { - const resourceId = String(resource.id); - const selectedResource = careLogForm.resources.find( - (item) => item.actionResourceId === resourceId, - ); - - return ( -
- - {selectedResource ? ( -
- - -
- ) : null} -
- ); - })} -
-
- -
- - {editingActionLogId === null ? null : ( - - )} -
-
- ) : null} - {isPlantEditorOpen ? ( -
-
-
-

{activePlantName ? 'Editing' : 'New plant'}

-

{activePlantName ?? 'Plant details'}

+
+
+
+

{activePlantName ? 'Editing' : 'New plant'}

+

{activePlantName ?? 'Plant details'}

+
+
- -
-
- -
+
-
- -
- Care schedules - {enabledCareActions.length === 0 ? ( -

No enabled actions.

- ) : null} - {enabledCareActions.map((action) => { - const actionId = String(action.id); - const selectedSchedule = form.careSchedules.find( - (schedule) => schedule.careActionId === actionId, - ); - - return ( -
- - {selectedSchedule?.isEnabled ? ( -
- -
- ) : null} -
- ); - })} -
-
- -
-
-

Missing from the list?

-

Create taxon

-
-
- - -
- -
-
- - -
-
+
+ + +
+ ) : null}
@@ -696,29 +103,11 @@ export function PlantsView({

{plant.nickname}

-

{plant.taxon} - {plant.location}

- {plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? ( -
- {plant.flags - .filter((flag) => flag.resolvedOn === null) - .map((flag) => ( - - {flag.name} - - ))} -
- ) : null}
- - @@ -730,19 +119,3 @@ export function PlantsView({ ); } - -const statusLabel: Record = { - due: 'Due', - soon: 'Soon', - ok: 'Ok', - unscheduled: 'Unscheduled', -}; - -function formatDate(date: string) { - const [year, month, day] = date.split('-'); - if (!year || !month || !day) { - return date; - } - - return `${month}/${day}/${year}`; -} diff --git a/plant-manager-web/src/components/ResourcesView.tsx b/plant-manager-web/src/components/ResourcesView.tsx index 3807b93..478c1cf 100644 --- a/plant-manager-web/src/components/ResourcesView.tsx +++ b/plant-manager-web/src/components/ResourcesView.tsx @@ -1,4 +1,4 @@ -import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; +import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react'; import type { ActionResource } from '../domain'; import type { ResourceFormState } from '../form-state'; @@ -9,11 +9,14 @@ type ResourcesViewProps = { isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; + selectedResource?: ActionResource; onCancel: () => void; + onCloseDetail: () => void; onDelete: (resource: ActionResource) => void; onEdit: (resource: ActionResource) => void; onFieldChange: (field: keyof ResourceFormState, value: string | boolean) => void; onNew: () => void; + onOpenDetail: (resource: ActionResource) => void; onSave: () => void; resources: ActionResource[]; }; @@ -25,11 +28,14 @@ export function ResourcesView({ isEditorOpen, isLoading, isSaving, + selectedResource, onCancel, + onCloseDetail, onDelete, onEdit, onFieldChange, onNew, + onOpenDetail, onSave, resources, }: ResourcesViewProps) { @@ -51,6 +57,39 @@ export function ResourcesView({
+ {selectedResource && !isEditorOpen ? ( +
+
+
+

Resource detail

+

{selectedResource.name}

+
+
+ + +
+
+
+
+ Category + {selectedResource.category ?? 'Uncategorized'} +
+
+ Notes + {selectedResource.notes ?? 'No notes'} +
+
+ Status + {selectedResource.isEnabled ? 'Enabled' : 'Disabled'} +
+
+
+ ) : null} + {isEditorOpen ? (
@@ -130,6 +169,9 @@ export function ResourcesView({

+ diff --git a/plant-manager-web/src/components/SchedulesView.tsx b/plant-manager-web/src/components/SchedulesView.tsx new file mode 100644 index 0000000..415a3dc --- /dev/null +++ b/plant-manager-web/src/components/SchedulesView.tsx @@ -0,0 +1,138 @@ +import { Save } from 'lucide-react'; +import type { CareActivity, Plant } from '../domain'; +import type { BulkScheduleFormState } from '../form-state'; + +type SchedulesViewProps = { + activities: CareActivity[]; + error: string | null; + form: BulkScheduleFormState; + isLoading: boolean; + isSaving: boolean; + plants: Plant[]; + onFieldChange: (field: keyof BulkScheduleFormState, value: string | boolean | string[]) => void; + onSave: () => void; +}; + +export function SchedulesView({ + activities, + error, + form, + isLoading, + isSaving, + plants, + onFieldChange, + onSave, +}: SchedulesViewProps) { + const enabledActivities = activities.filter((activity) => activity.isEnabled); + const selectedPlantIds = new Set(form.plantIds); + const allVisibleSelected = plants.length > 0 && plants.every((plant) => selectedPlantIds.has(String(plant.id))); + + return ( + <> +
+
+

Care schedules

+

+ {isLoading ? 'Loading schedules' : 'Bulk schedule assignment'} +

+

{error ?? 'Apply one care interval to several plants at once.'}

+
+
+ +
+
+
+

Bulk edit

+

Apply schedule

+
+
+ +
+ + + +
+ +
+
+

Plants

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

No plants available.

+ ) : null} + + {plants.map((plant) => ( + + ))} +
+
+ +
+ +
+
+ + ); +} diff --git a/plant-manager-web/src/components/TaxaView.tsx b/plant-manager-web/src/components/TaxaView.tsx index eae8839..eacfd53 100644 --- a/plant-manager-web/src/components/TaxaView.tsx +++ b/plant-manager-web/src/components/TaxaView.tsx @@ -1,4 +1,4 @@ -import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; +import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react'; import type { PlantTaxon } from '../domain'; import type { TaxonFormState } from '../form-state'; import { formatTaxon } from '../form-state'; @@ -10,12 +10,15 @@ type TaxaViewProps = { isEditorOpen: boolean; isLoading: boolean; isSaving: boolean; + selectedTaxon?: PlantTaxon; taxa: PlantTaxon[]; onCancel: () => void; + onCloseDetail: () => void; onDelete: (taxon: PlantTaxon) => void; onEdit: (taxon: PlantTaxon) => void; onFieldChange: (field: keyof TaxonFormState, value: string) => void; onNew: () => void; + onOpenDetail: (taxon: PlantTaxon) => void; onSave: () => void; }; @@ -26,12 +29,15 @@ export function TaxaView({ isEditorOpen, isLoading, isSaving, + selectedTaxon, taxa, onCancel, + onCloseDetail, onDelete, onEdit, onFieldChange, onNew, + onOpenDetail, onSave, }: TaxaViewProps) { return ( @@ -50,6 +56,51 @@ export function TaxaView({
+ {selectedTaxon && !isEditorOpen ? ( +
+
+
+

Taxon detail

+

{formatTaxon(selectedTaxon)}

+
+
+ + +
+
+
+
+ Common name + {selectedTaxon.name} +
+
+ Genus + {selectedTaxon.genus} +
+
+ Species + {selectedTaxon.species} +
+
+ Cultivar + {selectedTaxon.cultivar ?? 'None'} +
+
+ Variety + {selectedTaxon.variety ?? 'None'} +
+
+ Authority + {selectedTaxon.authority ?? 'None'} +
+
+
+ ) : null} + {isEditorOpen ? (
@@ -132,10 +183,12 @@ export function TaxaView({ {taxa.map((taxon) => (
-

{taxon.name}

-

{formatTaxon(taxon)}

+

{formatTaxon(taxon)}

+ diff --git a/plant-manager-web/src/domain.ts b/plant-manager-web/src/domain.ts index edc0284..22e17ba 100644 --- a/plant-manager-web/src/domain.ts +++ b/plant-manager-web/src/domain.ts @@ -3,8 +3,9 @@ export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled'; export type Plant = { id: number; nickname: string; - taxonId: number; + taxonId: number | null; taxon: string; + locationId: number | null; location: string; nextCare: string; status: CareStatus; @@ -14,6 +15,7 @@ export type Plant = { export type PlantCareSchedule = { id: number; + careActivityId: number; careActionId: number; action: string; everyDays: number; @@ -34,6 +36,13 @@ export type PlantTaxon = { authority: string | null; }; +export type PlantLocation = { + id: number; + name: string; + notes: string | null; + isEnabled: boolean; +}; + export type CareAction = { id: number; name: string; @@ -49,10 +58,36 @@ export type ActionResource = { isEnabled: boolean; }; +export type CareActivity = { + id: number; + name: string; + careActionId: number; + action: string; + actions: CareActivityAction[]; + notes: string | null; + isEnabled: boolean; +}; + +export type CareActivityAction = { + careActionId: number; + name: string; + description: string | null; + sortOrder: number; + resources: CareActivityActionResource[]; +}; + +export type CareActivityActionResource = { + actionResourceId: number; + name: string; + category: string | null; + quantity: number | null; + unit: string | null; + notes: string | null; +}; + export type PlantFlagDefinition = { id: number; name: string; - category: string; color: string; isEnabled: boolean; }; @@ -61,31 +96,28 @@ export type PlantFlag = { id: number; plantFlagDefinitionId: number; name: string; - category: string; color: string; - severity: 'low' | 'medium' | 'high'; startedOn: string; resolvedOn: string | null; notes: string | null; }; -export type ActionLogResource = { - actionResourceId: number; - name: string; - category: string | null; - quantity: number | null; - unit: string | null; -}; - export type PlantPayload = { nickname: string; - taxonId: number; - location: string; - careSchedules: PlantCareSchedulePayload[]; + taxonId: number | null; + locationId: number | null; + careSchedules: PlantCareSchedulePayload[] | null; }; export type PlantCareSchedulePayload = { - careActionId: number; + careActivityId: number; + everyDays: number; + isEnabled: boolean; +}; + +export type BulkPlantCareSchedulePayload = { + plantIds: number[]; + careActivityId: number; everyDays: number; isEnabled: boolean; }; @@ -99,6 +131,12 @@ export type PlantTaxonPayload = { authority: string | null; }; +export type PlantLocationPayload = { + name: string; + notes: string | null; + isEnabled: boolean; +}; + export type CareActionPayload = { name: string; description: string | null; @@ -112,23 +150,40 @@ export type ActionResourcePayload = { isEnabled: boolean; }; +export type CareActivityPayload = { + name: string; + actions: CareActivityActionPayload[]; + notes: string | null; + isEnabled: boolean; +}; + +export type CareActivityActionPayload = { + careActionId: number; + resources: CareActivityActionResourcePayload[]; +}; + +export type CareActivityActionResourcePayload = { + actionResourceId: number; + quantity: number | null; + unit: string | null; + notes: string | null; +}; + export type PlantFlagDefinitionPayload = { name: string; - category: string | null; color: string | null; isEnabled: boolean; }; export type AssignPlantFlagPayload = { plantFlagDefinitionId: number; - severity: string | null; startedOn: string | null; notes: string | null; }; -export type CareLogPayload = { - plantId: number; - careActionId: number; +export type BulkCompleteCareTasksPayload = { + careActivityId: number; + plantIds: number[]; notes: string | null; performedOn: string | null; resources: CareLogResourcePayload[]; @@ -140,21 +195,11 @@ export type CareLogResourcePayload = { unit: string | null; }; -export type ActionLog = { - id: number; - plantId: number; - plantName: string; - careActionId: number; - action: string; - notes: string | null; - performedOn: string; - resources: ActionLogResource[]; -}; - export type CareTask = { id: number; plantId: number; plantName: string; + careActivityId: number; careActionId: number; action: string; due: string; diff --git a/plant-manager-web/src/form-state.ts b/plant-manager-web/src/form-state.ts index 0ecf6ad..4a78243 100644 --- a/plant-manager-web/src/form-state.ts +++ b/plant-manager-web/src/form-state.ts @@ -1,28 +1,24 @@ import type { ActionResource, ActionResourcePayload, + CareActivity, + CareActivityPayload, CareAction, CareActionPayload, + BulkPlantCareSchedulePayload, Plant, AssignPlantFlagPayload, PlantPayload, PlantFlagDefinition, PlantFlagDefinitionPayload, + PlantLocation, + PlantLocationPayload, PlantTaxon, PlantTaxonPayload, } from './domain'; export const emptyPlantForm = { nickname: '', - taxonId: '', - location: '', - careSchedules: [] as PlantCareScheduleFormState[], -}; - -export type PlantCareScheduleFormState = { - careActionId: string; - everyDays: string; - isEnabled: boolean; }; export const emptyTaxonForm = { @@ -34,6 +30,12 @@ export const emptyTaxonForm = { authority: '', }; +export const emptyLocationForm = { + name: '', + notes: '', + isEnabled: true, +}; + export const emptyActionForm = { name: '', description: '', @@ -47,66 +49,83 @@ export const emptyResourceForm = { isEnabled: true, }; +export const emptyActivityForm = { + name: '', + actions: [] as CareActivityActionFormState[], + notes: '', + isEnabled: true, +}; + +export type CareActivityActionResourceFormState = { + actionResourceId: string; + quantity: string; + unit: string; + notes: string; +}; + +export type CareActivityActionFormState = { + careActionId: string; + resources: CareActivityActionResourceFormState[]; +}; + export const emptyFlagDefinitionForm = { name: '', - category: 'Pest', color: '#f2f2f2', isEnabled: true, }; export const emptyPlantFlagForm = { plantFlagDefinitionId: '', - severity: 'medium', startedOn: '', notes: '', }; -export type CareLogResourceFormState = { - actionResourceId: string; - quantity: string; - unit: string; -}; - -export const emptyCareLogForm = { - plantId: '', - careActionId: '', - performedOn: '', - notes: '', - resources: [] as CareLogResourceFormState[], +export const emptyBulkScheduleForm = { + careActivityId: '', + everyDays: '7', + isEnabled: true, + plantIds: [] as string[], }; export type PlantFormState = typeof emptyPlantForm; export type TaxonFormState = typeof emptyTaxonForm; +export type LocationFormState = typeof emptyLocationForm; export type ActionFormState = typeof emptyActionForm; export type ResourceFormState = typeof emptyResourceForm; +export type ActivityFormState = typeof emptyActivityForm; export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm; export type PlantFlagFormState = typeof emptyPlantFlagForm; -export type CareLogFormState = typeof emptyCareLogForm; -export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources' | 'flags'; +export type BulkScheduleFormState = typeof emptyBulkScheduleForm; +export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'actions' | 'resources' | 'activities' | 'flags'; export function toPlantForm(plant: Plant): PlantFormState { return { nickname: plant.nickname, - taxonId: String(plant.taxonId), - location: plant.location, - careSchedules: plant.careSchedules.map((schedule) => ({ - careActionId: String(schedule.careActionId), - everyDays: String(schedule.everyDays), - isEnabled: schedule.isEnabled, - })), }; } export function toPlantPayload(form: PlantFormState): PlantPayload { return { nickname: form.nickname.trim(), - taxonId: Number(form.taxonId), - location: form.location.trim(), - careSchedules: form.careSchedules.map((schedule) => ({ - careActionId: Number(schedule.careActionId), - everyDays: Number(schedule.everyDays), - isEnabled: schedule.isEnabled, - })), + taxonId: null, + locationId: null, + careSchedules: null, + }; +} + +export function toLocationForm(location: PlantLocation): LocationFormState { + return { + name: location.name, + notes: location.notes ?? '', + isEnabled: location.isEnabled, + }; +} + +export function toLocationPayload(form: LocationFormState): PlantLocationPayload { + return { + name: form.name.trim(), + notes: form.notes.trim() || null, + isEnabled: form.isEnabled, }; } @@ -166,10 +185,43 @@ export function toResourcePayload(form: ResourceFormState): ActionResourcePayloa }; } +export function toActivityForm(activity: CareActivity): ActivityFormState { + return { + name: activity.name, + actions: activity.actions.map((action) => ({ + careActionId: String(action.careActionId), + resources: action.resources.map((resource) => ({ + actionResourceId: String(resource.actionResourceId), + quantity: resource.quantity === null ? '' : String(resource.quantity), + unit: resource.unit ?? '', + notes: resource.notes ?? '', + })), + })), + notes: activity.notes ?? '', + isEnabled: activity.isEnabled, + }; +} + +export function toActivityPayload(form: ActivityFormState): CareActivityPayload { + return { + name: form.name.trim(), + actions: form.actions.map((action) => ({ + careActionId: Number(action.careActionId), + resources: action.resources.map((resource) => ({ + actionResourceId: Number(resource.actionResourceId), + quantity: resource.quantity.trim() ? Number(resource.quantity) : null, + unit: resource.unit.trim() || null, + notes: resource.notes.trim() || null, + })), + })), + notes: form.notes.trim() || null, + isEnabled: form.isEnabled, + }; +} + export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState { return { name: flag.name, - category: flag.category, color: flag.color, isEnabled: flag.isEnabled, }; @@ -178,7 +230,6 @@ export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionF export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFlagDefinitionPayload { return { name: form.name.trim(), - category: form.category.trim() || null, color: form.color.trim() || null, isEnabled: form.isEnabled, }; @@ -187,12 +238,20 @@ export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFla export function toPlantFlagPayload(form: PlantFlagFormState): AssignPlantFlagPayload { return { plantFlagDefinitionId: Number(form.plantFlagDefinitionId), - severity: form.severity || null, startedOn: form.startedOn || null, notes: form.notes.trim() || null, }; } +export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCareSchedulePayload { + return { + plantIds: form.plantIds.map((id) => Number(id)), + careActivityId: Number(form.careActivityId), + everyDays: Number(form.everyDays), + isEnabled: form.isEnabled, + }; +} + export function formatTaxon(taxon: PlantTaxon) { const botanical = `${taxon.genus} ${taxon.species}`.trim(); return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`; diff --git a/plant-manager-web/src/styles.css b/plant-manager-web/src/styles.css index 0967a0a..0d8c797 100644 --- a/plant-manager-web/src/styles.css +++ b/plant-manager-web/src/styles.css @@ -1,21 +1,24 @@ :root { - color: #111; - background: #fff; - font-family: Arial, Helvetica, sans-serif; + color: #161616; + background: #f4f4f4; + font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; - --border: #a7a7a7; - --rule: #8f8f8f; - --muted: #555; + --border: #e0e0e0; + --rule: #c6c6c6; + --muted: #6f6f6f; --panel: #fff; - --side: #f7f7f7; - --row: #f7f7f7; - --hover: #f1f5fb; - --link: #0645ad; + --side: #f4f4f4; + --row: #f4f4f4; + --hover: #e8f0fe; + --link: #0f62fe; + --focus: #0f62fe; + --field: #f4f4f4; + --layer: #fff; --warning: #8a5a00; - --danger: #b00000; - --ok: #126126; + --danger: #da1e28; + --ok: #198038; } * { @@ -27,7 +30,7 @@ body { min-width: 320px; min-height: 100vh; overflow-x: hidden; - background: #fff; + background: #f4f4f4; font-size: 13px; } @@ -54,8 +57,8 @@ button:disabled { button:focus-visible, input:focus-visible, select:focus-visible { - outline: 2px solid #111; - outline-offset: 1px; + outline: 2px solid var(--focus); + outline-offset: -2px; } h1, @@ -112,11 +115,12 @@ p { grid-template-columns: minmax(190px, 260px) minmax(260px, 680px) minmax(120px, 1fr); align-items: center; gap: 18px; - min-height: 74px; + min-height: 64px; padding: 10px 22px; - border-bottom: 1px solid var(--rule); - background: #fff; - box-shadow: 0 1px 0 #e8e8e8 inset, 0 1px 3px rgb(0 0 0 / 8%); + border-bottom: 0; + background: #161616; + color: #f4f4f4; + box-shadow: none; } .catalog-brand { @@ -125,23 +129,23 @@ p { } .catalog-logo { - color: #111; + color: #fff; font-size: 1.65rem; - font-weight: 800; + font-weight: 600; line-height: 1; } .catalog-subtitle, .catalog-contact span { - color: #555; + color: #c6c6c6; font-size: 0.78rem; - font-weight: 700; + font-weight: 500; } .catalog-contact { display: grid; justify-items: end; - color: #111; + color: #fff; white-space: nowrap; } @@ -159,9 +163,10 @@ p { max-width: 680px; min-height: 36px; padding: 0 12px; - border: 1px solid #777; - background: #fff; - color: var(--link); + border: 0; + border-bottom: 1px solid #8d8d8d; + background: #393939; + color: #78a9ff; } .search-field input { @@ -170,12 +175,12 @@ p { border: 0; outline: 0; background: transparent; - color: #111; + color: #fff; font-size: 0.95rem; } .search-field input::placeholder { - color: var(--muted); + color: #c6c6c6; } .catalog-layout { @@ -191,28 +196,56 @@ p { .catalog-sidebar { position: sticky; - top: 74px; + top: 64px; min-width: 0; - min-height: calc(100vh - 74px); + min-height: calc(100vh - 64px); padding: 16px 16px 24px 22px; - border-right: 1px solid var(--rule); + border-right: 1px solid var(--border); background: var(--side); } .catalog-sidebar h2, .catalog-help h3 { margin: 0 0 8px; - color: #111; + color: #161616; font-size: 0.95rem; } .catalog-nav { display: grid; min-width: 0; - gap: 2px; + gap: 14px; +} + +.nav-group { + display: grid; + gap: 0; +} + +.nav-group h3 { + margin: 0 0 3px; + padding: 0 12px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 500; + text-transform: uppercase; +} + +.catalog-nav button { + position: relative; + min-width: 0; + min-height: 36px; + padding: 0 12px; + border: 0; + background: transparent; + color: #525252; + font-size: 0.9rem; + font-weight: 400; + text-align: left; + text-decoration: none; + overflow-wrap: anywhere; } -.catalog-nav button, .text-button { min-width: 0; min-height: 0; @@ -227,15 +260,29 @@ p { overflow-wrap: anywhere; } -.catalog-nav button:hover, +.catalog-nav button:hover { + background: #e8e8e8; + color: #161616; +} + .text-button:hover { - color: #002f7a; + color: #0043ce; } .catalog-nav button[aria-current='page'] { - color: #111; - font-weight: 700; - text-decoration: none; + background: #e0e0e0; + color: #161616; + font-weight: 500; +} + +.catalog-nav button[aria-current='page']::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: var(--link); + content: ''; } .catalog-help { @@ -262,12 +309,12 @@ p { justify-content: space-between; gap: 14px; margin-bottom: 12px; - border-bottom: 2px solid #111; + border-bottom: 1px solid var(--border); } .catalog-page-title h1 { margin: 0 0 6px; - font-weight: 700; + font-weight: 500; } .content { @@ -280,9 +327,9 @@ p { .eyebrow { margin: 0 0 3px; - color: #555; + color: var(--muted); font-size: 0.72rem; - font-weight: 700; + font-weight: 500; text-transform: uppercase; } @@ -303,7 +350,7 @@ p { align-items: center; gap: 10px; padding: 10px 12px; - border-top: 3px solid var(--rule); + border-top: 0; } .summary-panel h2 { @@ -335,7 +382,7 @@ p { gap: 10px; min-height: 28px; padding-bottom: 3px; - border-bottom: 1px solid #111; + border-bottom: 1px solid var(--border); } .primary-action, @@ -346,17 +393,19 @@ p { align-items: center; justify-content: center; gap: 8px; - border: 1px solid #777; + border: 0; border-radius: 0; - background: #f2f2f2; - color: #111; - box-shadow: inset 0 1px 0 #fff; - font-weight: 700; + background: #e0e0e0; + color: #161616; + box-shadow: none; + font-weight: 500; } .primary-action { min-height: 34px; padding: 0 12px; + background: var(--link); + color: #fff; } .small-action { @@ -381,10 +430,13 @@ p { color: var(--danger); } -.primary-action:hover:not(:disabled), +.primary-action:hover:not(:disabled) { + background: #0353e9; +} + .small-action:hover:not(:disabled), .icon-button:hover:not(:disabled) { - background: #e5e5e5; + background: #d0d0d0; } .plant-mark svg, @@ -438,6 +490,11 @@ p { padding: 7px 8px; } +.task-row-group { + border-top: 1px solid var(--rule); + background: #f4f4f4; +} + .plant-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -446,6 +503,29 @@ p { padding: 7px 8px; } +.check-row { + cursor: pointer; +} + +.check-row > span { + display: flex; + align-items: center; + gap: 8px; +} + +.check-row input { + width: 15px; + height: 15px; + accent-color: var(--link); +} + +.check-row small { + display: block; + color: #444; + font-size: 0.86rem; + line-height: 1.25; +} + .plant-card { display: grid; grid-template-columns: @@ -499,16 +579,16 @@ p { } dt { - color: #555; + color: var(--muted); font-size: 0.72rem; - font-weight: 700; + font-weight: 500; line-height: 1; } dd { margin: 3px 0 0; font-size: 0.84rem; - font-weight: 700; + font-weight: 500; line-height: 1.15; overflow-wrap: anywhere; } @@ -524,7 +604,7 @@ dd { .empty-state { padding: 8px; border-bottom: 1px solid var(--border); - background: #fafafa; + background: #f4f4f4; } .status-dot { @@ -554,11 +634,11 @@ dd { border-radius: 0; background: #fff; font-size: 0.72rem; - font-weight: 700; + font-weight: 500; } .status-pill.due { - background: #fff0f0; + background: #fff1f1; color: var(--danger); } @@ -568,17 +648,16 @@ dd { } .status-pill.ok { - background: #eef9ee; + background: #defbe6; color: var(--ok); } .status-pill.unscheduled { - background: #eee; - color: #555; + background: #e0e0e0; + color: var(--muted); } -.plant-form, -.quick-taxon-fields { +.plant-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; @@ -586,28 +665,27 @@ dd { } .plant-form label, -.quick-taxon label, .flag-assignment-form label { display: grid; gap: 4px; - color: #333; + color: #525252; font-size: 0.78rem; - font-weight: 800; + font-weight: 500; } .plant-form input, .plant-form select, -.quick-taxon input, .flag-assignment-form input, .flag-assignment-form select { width: 100%; - min-height: 30px; - padding: 0 7px; - border: 1px solid #888; + min-height: 34px; + padding: 0 8px; + border: 0; + border-bottom: 1px solid #8d8d8d; border-radius: 0; - background: #fff; - color: #111; - box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%); + background: var(--field); + color: #161616; + box-shadow: none; } .plant-form input[type='color'] { @@ -625,15 +703,15 @@ dd { .plant-form .toggle-field { align-self: end; padding: 5px 7px; - border: 1px solid #888; - background: #f7f7f7; + border: 1px solid var(--border); + background: #f4f4f4; } .plant-form .toggle-field input, .check-option input { width: 15px; min-height: 15px; - accent-color: #111; + accent-color: var(--link); } .taxon-picker { @@ -644,19 +722,19 @@ dd { .resource-picker { display: grid; grid-column: 1 / -1; - gap: 6px; + gap: 10px; min-width: 0; margin: 0; - padding: 8px; + padding: 12px; border: 1px solid var(--border); background: #fff; } .resource-picker legend { padding: 0 4px; - color: #111; + color: #161616; font-size: 0.82rem; - font-weight: 700; + font-weight: 500; } .resource-entry { @@ -665,7 +743,7 @@ dd { align-items: center; gap: 8px; padding: 5px 0; - border-bottom: 1px solid #ddd; + border-bottom: 1px solid var(--border); } .resource-entry:last-child { @@ -678,16 +756,96 @@ dd { gap: 8px; } -.schedule-interval { - grid-template-columns: minmax(90px, 120px); +.activity-action-list, +.activity-resource-list { + display: grid; + gap: 6px; + min-width: 0; } -.quick-taxon { +.activity-builder-header { display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr) 28px; gap: 12px; - padding-top: 10px; - border-top: 1px solid var(--border); - background: #fafafa; + padding: 0 12px 4px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 500; + text-transform: uppercase; +} + +.activity-config-row { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 0; + border: 0; + border-bottom: 1px solid var(--border); + background: #fff; +} + +.activity-resource-header, +.activity-resource-row, +.log-resource-header, +.log-resource-row { + display: grid; + align-items: end; + gap: 8px; +} + +.activity-config-row > .icon-button { + min-width: 24px; + min-height: 24px; + margin-top: 8px; + margin-right: 12px; +} + +.activity-resource-header, +.activity-resource-row { + grid-template-columns: minmax(160px, 1.2fr) minmax(70px, 90px) minmax(80px, 110px) minmax(140px, 1fr) auto; +} + +.log-resource-header, +.activity-resource-header { + padding: 8px 0 0; + color: var(--muted); + font-size: 0.78rem; + font-weight: 500; + text-transform: uppercase; +} + +.activity-resource-row, +.log-resource-row { + min-height: 32px; + padding: 0; +} + +.log-resource-header, +.log-resource-row { + grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto; +} + +.log-resource-row .icon-button, +.activity-resource-row .icon-button { + min-width: 24px; + min-height: 24px; +} + +.activity-resource-list { + padding-bottom: 8px; +} + +.activity-resource-list > .small-action { + justify-self: start; +} + +.activity-action-cell { + padding: 8px 0 8px 12px; +} + +.schedule-interval { + grid-template-columns: minmax(90px, 120px); } .plant-detail-meta, @@ -715,13 +873,13 @@ dd { } .plant-detail-meta span { - color: #555; + color: var(--muted); font-size: 0.72rem; - font-weight: 700; + font-weight: 500; } .plant-detail-meta strong { - color: #111; + color: #161616; font-size: 0.9rem; line-height: 1.2; } @@ -745,7 +903,7 @@ dd { gap: 6px; min-height: 28px; padding-bottom: 3px; - border-bottom: 1px solid #111; + border-bottom: 1px solid var(--border); } .detail-section-heading h3 { @@ -782,16 +940,16 @@ dd { align-items: center; padding: 0 7px; border: 1px solid #999; - color: #111; + color: #161616; font-size: 0.74rem; - font-weight: 700; + font-weight: 500; line-height: 1; } .flag-assignment-form { padding: 8px; border-bottom: 1px solid var(--border); - background: #fafafa; + background: #f4f4f4; } .flag-assignment-form label { @@ -855,15 +1013,26 @@ dd { display: flex; width: 100%; max-width: 100%; - gap: 6px; + gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; padding-bottom: 2px; } + .nav-group { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + } + + .nav-group h3 { + display: none; + } + .catalog-nav button { flex: 0 0 auto; - padding: 4px 2px; + padding: 0 10px; font-size: 0.78rem; white-space: nowrap; } @@ -882,9 +1051,11 @@ dd { .detail-row, .plant-detail-grid, .plant-form, - .quick-taxon-fields, .resource-entry, .resource-amount, + .activity-config-row, + .activity-resource-header, + .activity-resource-row, .flag-assignment-form { grid-template-columns: 1fr; } diff --git a/plant-manager/Contracts.cs b/plant-manager/Contracts.cs index e6001e5..f8fe26b 100644 --- a/plant-manager/Contracts.cs +++ b/plant-manager/Contracts.cs @@ -4,18 +4,24 @@ namespace plant_manager { public record CreatePlantRequest( string Nickname, - int TaxonId, - string? Location, + int? TaxonId, + int? LocationId, IReadOnlyList? CareSchedules); public record UpdatePlantRequest( string Nickname, - int TaxonId, - string? Location, + int? TaxonId, + int? LocationId, IReadOnlyList? CareSchedules); public record SavePlantCareScheduleRequest( - int CareActionId, + int CareActivityId, + int? EveryDays, + bool IsEnabled); + + public record BulkSavePlantCareScheduleRequest( + IReadOnlyList PlantIds, + int CareActivityId, int? EveryDays, bool IsEnabled); @@ -27,6 +33,11 @@ namespace plant_manager string? Variety, string? Authority); + public record SavePlantLocationRequest( + string Name, + string? Notes, + bool IsEnabled); + public record SaveCareActionRequest( string Name, string? Description, @@ -38,34 +49,85 @@ namespace plant_manager string? Notes, bool IsEnabled); - public record SavePlantFlagDefinitionRequest( + public record SaveCareActivityRequest( + string Name, + IReadOnlyList Actions, + string? Notes, + bool IsEnabled); + + public record SaveCareActivityActionRequest( + int CareActionId, + IReadOnlyList? Resources); + + public record SaveCareActivityActionResourceRequest( + int ActionResourceId, + decimal? Quantity, + string? Unit, + string? Notes); + + public record CareActivityActionResourceDto( + int ActionResourceId, string Name, string? Category, + decimal? Quantity, + string? Unit, + string? Notes) + { + public static CareActivityActionResourceDto FromCareActivityActionResource( + CareActivityActionResource resource) => + new( + resource.ActionResourceId, + resource.ActionResource.Name, + resource.ActionResource.Category, + resource.Quantity, + resource.Unit, + resource.Notes); + } + + public record CareActivityActionDto( + int CareActionId, + string Name, + string? Description, + int SortOrder, + IReadOnlyList Resources) + { + public static CareActivityActionDto FromCareActivityAction(CareActivityAction activityAction) => + new( + activityAction.CareActionId, + activityAction.CareAction.Name, + activityAction.CareAction.Description, + activityAction.SortOrder, + activityAction.Resources + .OrderBy(resource => resource.ActionResource.Name) + .Select(CareActivityActionResourceDto.FromCareActivityActionResource) + .ToList()); + } + + public record SavePlantFlagDefinitionRequest( + string Name, string? Color, bool IsEnabled); public record AssignPlantFlagRequest( int PlantFlagDefinitionId, - string? Severity, DateOnly? StartedOn, string? Notes); public record UpdatePlantFlagRequest( - string? Severity, DateOnly? StartedOn, DateOnly? ResolvedOn, string? Notes); public record CreateActionLogRequest( int PlantId, - int CareActionId, + int CareActivityId, string? Notes, DateOnly? PerformedOn, IReadOnlyList? Resources); public record UpdateActionLogRequest( int PlantId, - int CareActionId, + int CareActivityId, string? Notes, DateOnly PerformedOn, IReadOnlyList? Resources); @@ -75,6 +137,13 @@ namespace plant_manager decimal? Quantity, string? Unit); + public record BulkCompleteCareTasksRequest( + int CareActivityId, + IReadOnlyList PlantIds, + DateOnly? PerformedOn, + string? Notes, + IReadOnlyList? Resources); + public record PlantTaxonDto( int Id, string Name, @@ -95,6 +164,16 @@ namespace plant_manager taxon.Authority); } + public record PlantLocationDto( + int Id, + string Name, + string? Notes, + bool IsEnabled) + { + public static PlantLocationDto FromLocation(PlantLocation location) => + new(location.Id, location.Name, location.Notes, location.IsEnabled); + } + public record CareActionDto( int Id, string Name, @@ -116,11 +195,35 @@ namespace plant_manager new(resource.Id, resource.Name, resource.Category, resource.Notes, resource.IsEnabled); } + public record CareActivityDto( + int Id, + string Name, + int CareActionId, + string Action, + IReadOnlyList Actions, + string? Notes, + bool IsEnabled) + { + public static CareActivityDto FromCareActivity(CareActivity activity) => + new( + activity.Id, + activity.Name, + activity.PrimaryAction()?.Id ?? 0, + activity.PrimaryAction()?.Name ?? activity.Name, + activity.Actions + .OrderBy(action => action.SortOrder) + .Select(CareActivityActionDto.FromCareActivityAction) + .ToList(), + activity.Notes, + activity.IsEnabled); + } + public record PlantDto( int Id, string Nickname, - int TaxonId, + int? TaxonId, string Taxon, + int? LocationId, string Location, string NextCare, string Status, @@ -131,10 +234,10 @@ namespace plant_manager { var today = DateOnly.FromDateTime(DateTime.UtcNow); var schedules = plant.CareSchedules - .OrderBy(schedule => schedule.CareAction.Name) + .OrderBy(schedule => schedule.CareActivity.Name) .Select(schedule => PlantCareScheduleDto.FromSchedule( schedule, - GetLatestPerformedOn(plant, schedule.CareActionId), + GetLatestPerformedOn(plant, schedule.CareActivityId), today)) .ToList(); var nextCare = schedules @@ -148,8 +251,9 @@ namespace plant_manager plant.Id, plant.Nickname, plant.TaxonId, - $"{plant.Taxon.Genus} {plant.Taxon.Species}", - plant.Location, + plant.Taxon is null ? "Unassigned" : $"{plant.Taxon.Genus} {plant.Taxon.Species}", + plant.LocationId, + plant.Location?.Name ?? "Unassigned", PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"), PlantCareFormatter.GetStatus(nextCare, today), plant.Flags @@ -161,15 +265,16 @@ namespace plant_manager schedules); } - private static DateOnly? GetLatestPerformedOn(Plant plant, int careActionId) => + private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) => plant.ActionLogs - .Where(log => log.CareActionId == careActionId) + .Where(log => log.CareActivityId == careActivityId) .Select(log => (DateOnly?)log.PerformedOn) .Max(); } public record PlantCareScheduleDto( int Id, + int CareActivityId, int CareActionId, string Action, int EveryDays, @@ -188,8 +293,9 @@ namespace plant_manager return new PlantCareScheduleDto( schedule.Id, + schedule.CareActivityId, schedule.CareActionId, - schedule.CareAction.Name, + schedule.CareActivity.Name, schedule.EveryDays, lastPerformedOn, PlantCareFormatter.FormatRelativeDate(lastPerformedOn, today, "Never"), @@ -199,10 +305,26 @@ namespace plant_manager } } + internal static class CareActivityExtensions + { + public static CareAction? PrimaryAction(this CareActivity activity) => + activity.Actions + .OrderBy(action => action.SortOrder) + .Select(action => action.CareAction) + .FirstOrDefault(); + + public static int PrimaryActionId(this CareActivity activity) => + activity.Actions + .OrderBy(action => action.SortOrder) + .Select(action => action.CareActionId) + .FirstOrDefault(); + } + public record CareTaskDto( int Id, int PlantId, string PlantName, + int CareActivityId, int CareActionId, string Action, string Due, @@ -219,8 +341,9 @@ namespace plant_manager schedule.Id, schedule.PlantId, schedule.Plant.Nickname, + schedule.CareActivityId, schedule.CareActionId, - schedule.CareAction.Name, + schedule.CareActivity.Name, PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"), PlantCareFormatter.GetStatus(nextCare, today)); } @@ -229,7 +352,6 @@ namespace plant_manager public record PlantFlagDefinitionDto( int Id, string Name, - string Category, string Color, bool IsEnabled) { @@ -237,7 +359,6 @@ namespace plant_manager new( definition.Id, definition.Name, - definition.Category, definition.Color, definition.IsEnabled); } @@ -246,9 +367,7 @@ namespace plant_manager int Id, int PlantFlagDefinitionId, string Name, - string Category, string Color, - string Severity, DateOnly StartedOn, DateOnly? ResolvedOn, string? Notes) @@ -258,9 +377,7 @@ namespace plant_manager flag.Id, flag.PlantFlagDefinitionId, flag.Definition.Name, - flag.Definition.Category, flag.Definition.Color, - flag.Severity, flag.StartedOn, flag.ResolvedOn, flag.Notes); @@ -270,6 +387,7 @@ namespace plant_manager int Id, int PlantId, string PlantName, + int CareActivityId, int CareActionId, string Action, string? Notes, @@ -281,6 +399,7 @@ namespace plant_manager log.Id, log.PlantId, log.Plant.Nickname, + log.CareActivityId, log.CareActionId, log.ActionNameSnapshot, log.Notes, diff --git a/plant-manager/Data/ApplicationDbContext.cs b/plant-manager/Data/ApplicationDbContext.cs index 20974fa..618b989 100644 --- a/plant-manager/Data/ApplicationDbContext.cs +++ b/plant-manager/Data/ApplicationDbContext.cs @@ -6,9 +6,13 @@ namespace plant_manager.Data public class ApplicationDbContext(DbContextOptions options) : DbContext(options) { public DbSet PlantTaxa { get; set; } + public DbSet PlantLocations { get; set; } public DbSet Plants { get; set; } public DbSet CareActions { get; set; } public DbSet ActionResources { get; set; } + public DbSet CareActivities { get; set; } + public DbSet CareActivityActions { get; set; } + public DbSet CareActivityActionResources { get; set; } public DbSet ActionLogs { get; set; } public DbSet ActionLogResources { get; set; } public DbSet PlantCareSchedules { get; set; } @@ -36,11 +40,25 @@ namespace plant_manager.Data { entity.HasKey(e => e.Id); entity.Property(e => e.Nickname).HasMaxLength(120).IsRequired(); - entity.Property(e => e.Location).HasMaxLength(120).IsRequired(); entity.HasOne(e => e.Taxon) .WithMany() .HasForeignKey(e => e.TaxonId) - .OnDelete(DeleteBehavior.Restrict); + .OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.Location) + .WithMany(e => e.Plants) + .HasForeignKey(e => e.LocationId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.Name).HasMaxLength(120).IsRequired(); + entity.Property(e => e.Notes).HasMaxLength(1000); + entity.Property(e => e.IsEnabled).IsRequired(); + entity.HasIndex(e => e.Name).IsUnique(); }); modelBuilder.Entity(entity => @@ -66,6 +84,48 @@ namespace plant_manager.Data entity.HasIndex(e => e.Name).IsUnique(); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id) + .ValueGeneratedOnAdd(); + entity.Property(e => e.Name).HasMaxLength(120).IsRequired(); + entity.Property(e => e.Notes).HasMaxLength(1000); + entity.Property(e => e.IsEnabled).IsRequired(); + entity.HasIndex(e => e.Name).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.CareActivityId, e.CareActionId }); + entity.Property(e => e.SortOrder).IsRequired(); + entity.HasIndex(e => new { e.CareActivityId, e.SortOrder }).IsUnique(); + entity.HasOne(e => e.CareActivity) + .WithMany(e => e.Actions) + .HasForeignKey(e => e.CareActivityId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.CareAction) + .WithMany(e => e.CareActivityActions) + .HasForeignKey(e => e.CareActionId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => new { e.CareActivityId, e.CareActionId, e.ActionResourceId }); + entity.Property(e => e.Quantity).HasPrecision(10, 2); + entity.Property(e => e.Unit).HasMaxLength(40); + entity.Property(e => e.Notes).HasMaxLength(1000); + entity.HasOne(e => e.CareActivityAction) + .WithMany(e => e.Resources) + .HasForeignKey(e => new { e.CareActivityId, e.CareActionId }) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.ActionResource) + .WithMany(e => e.CareActivityActionResources) + .HasForeignKey(e => e.ActionResourceId) + .OnDelete(DeleteBehavior.Restrict); + }); + modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); @@ -79,6 +139,10 @@ namespace plant_manager.Data .WithMany(e => e.ActionLogs) .HasForeignKey(e => e.CareActionId) .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.CareActivity) + .WithMany(e => e.ActionLogs) + .HasForeignKey(e => e.CareActivityId) + .OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(entity => @@ -103,7 +167,8 @@ namespace plant_manager.Data .ValueGeneratedOnAdd(); entity.Property(e => e.EveryDays).IsRequired(); entity.Property(e => e.IsEnabled).IsRequired(); - entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique(); + 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) @@ -112,6 +177,10 @@ namespace plant_manager.Data .WithMany(e => e.PlantCareSchedules) .HasForeignKey(e => e.CareActionId) .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.CareActivity) + .WithMany(e => e.PlantCareSchedules) + .HasForeignKey(e => e.CareActivityId) + .OnDelete(DeleteBehavior.Restrict); }); modelBuilder.Entity(entity => @@ -120,7 +189,6 @@ namespace plant_manager.Data entity.Property(e => e.Id) .ValueGeneratedOnAdd(); entity.Property(e => e.Name).HasMaxLength(120).IsRequired(); - entity.Property(e => e.Category).HasMaxLength(80).IsRequired(); entity.Property(e => e.Color).HasMaxLength(20).IsRequired(); entity.Property(e => e.IsEnabled).IsRequired(); entity.HasIndex(e => e.Name).IsUnique(); @@ -131,7 +199,6 @@ namespace plant_manager.Data entity.HasKey(e => e.Id); entity.Property(e => e.Id) .ValueGeneratedOnAdd(); - entity.Property(e => e.Severity).HasMaxLength(20).IsRequired(); entity.Property(e => e.Notes).HasMaxLength(1000); entity.HasIndex(e => new { e.PlantId, e.PlantFlagDefinitionId, e.ResolvedOn }); entity.HasOne(e => e.Plant) diff --git a/plant-manager/Data/DatabaseSeeder.cs b/plant-manager/Data/DatabaseSeeder.cs index 4660d45..1c54a30 100644 --- a/plant-manager/Data/DatabaseSeeder.cs +++ b/plant-manager/Data/DatabaseSeeder.cs @@ -26,13 +26,23 @@ namespace plant_manager.Data new() { Name = "Pruners", Category = "Equipment", Notes = "Cutting tool for pruning or cleanup." } ]; + private static readonly StarterCareActivity[] StarterCareActivities = + [ + new("Water", [new("Water", ["Water"])]), + new("Repot with Potting Mix", [new("Repot", ["Potting Mix"])]), + new("Fertilize", [new("Fertilize", ["Fertilizer"])]), + new("Prune", [new("Prune", ["Pruners"])]), + new("Inspect", [new("Inspect", [])]) + ]; + private static readonly PlantFlagDefinition[] StarterFlagDefinitions = [ - new() { Name = "Spider mites", Category = "Pest", Color = "#ffe4e1" }, - new() { Name = "Fungus gnats", Category = "Pest", Color = "#fff4cc" }, - new() { Name = "Quarantine", Category = "Workflow", Color = "#e8eef8" }, - new() { Name = "Needs repotting", Category = "Condition", Color = "#e9f5e7" }, - new() { Name = "Watch closely", Category = "Workflow", Color = "#eeeeee" } + new() { Name = "Spider mites", Color = "#ffe4e1" }, + new() { Name = "Fungus gnats", Color = "#fff4cc" }, + new() { Name = "Dying", Color = "#ffd6d6" }, + new() { Name = "Quarantine", Color = "#e8eef8" }, + new() { Name = "Needs repotting", Color = "#e9f5e7" }, + new() { Name = "Watch closely", Color = "#eeeeee" } ]; private static readonly StarterTaxon[] StarterTaxa = @@ -57,14 +67,42 @@ namespace plant_manager.Data new("Money Tree", "Pachira", "aquatica") ]; + private static readonly StarterPlant[] StarterPlants = + [ + new("Chinese Money Plant 1", "Chinese Money Plant", "Plant cart"), + new("Chinese Money Plant 2", "Chinese Money Plant", "Plant cart"), + new("Chinese Money Plant 3", "Chinese Money Plant", "Plant cart"), + new("Croton Petra", "Croton Petra", "Plant cart"), + new("Parallel Peperomia", "Parallel Peperomia", "Plant cart"), + new("Marble Peperomia", "Marble Peperomia", "Plant cart"), + new("Silver Squill", "Silver Squill", "Plant cart"), + new("Fiddle-leaf Fig", "Fiddle-leaf Fig", "Living room"), + new("Ficus Audrey", "Ficus Audrey", "Plant cart"), + new("Dumbcane 1", "Dumbcane", "Plant cart"), + new("Dumbcane 2", "Dumbcane", "Plant cart"), + new("Kris Plant", "Kris Plant", "Plant cart"), + new("Lucky Bamboo", "Lucky Bamboo", "Plant cart"), + new("Common Ivy 1", "Common Ivy", "Plant cart"), + new("Common Ivy 2", "Common Ivy", "Plant cart"), + new("Peacock Plant 1", "Peacock Plant", "Plant cart"), + new("Peacock Plant 2", "Peacock Plant", "Plant cart"), + new("False Shamrock", "False Shamrock", "Plant cart"), + new("Pinstripe Plant", "Pinstripe Plant", "Computer desk"), + new("Monstera Thai Constellation", "Monstera Thai Constellation", "Plant cart"), + new("Pothos", "Pothos", "Plant cart"), + new("Moth orchid", "Moth orchid", "Plant cart"), + new("Money Tree", "Money Tree", "Computer desk") + ]; + public static void Seed(ApplicationDbContext db) { SeedCareActions(db); SeedActionResources(db); + SeedCareActivities(db); SeedPlantFlags(db); SeedStarterTaxa(db); + SeedPlantLocations(db); SeedStarterPlants(db); - SeedDefaultCareSchedules(db); } private static void SeedCareActions(ApplicationDbContext db) @@ -93,33 +131,6 @@ namespace plant_manager.Data db.SaveChanges(); } - private static void SeedDefaultCareSchedules(ApplicationDbContext db) - { - var waterAction = db.CareActions - .FirstOrDefault(action => action.Name == "Water"); - if (waterAction is null) - { - return; - } - - var plantsMissingWaterSchedule = db.Plants - .Include(plant => plant.CareSchedules) - .Where(plant => !plant.CareSchedules.Any(schedule => schedule.CareActionId == waterAction.Id)) - .ToList(); - - foreach (var plant in plantsMissingWaterSchedule) - { - plant.CareSchedules.Add(new PlantCareSchedule - { - CareActionId = waterAction.Id, - EveryDays = 7, - IsEnabled = true - }); - } - - db.SaveChanges(); - } - private static void SeedActionResources(ApplicationDbContext db) { var existingResourceNames = db.ActionResources @@ -147,6 +158,65 @@ namespace plant_manager.Data db.SaveChanges(); } + private static void SeedCareActivities(ApplicationDbContext db) + { + var existingActivityNames = db.CareActivities + .Select(activity => activity.Name) + .ToList(); + var actionsByName = db.CareActions.ToDictionary(action => action.Name, StringComparer.OrdinalIgnoreCase); + var resourcesByName = db.ActionResources.ToDictionary(resource => resource.Name, StringComparer.OrdinalIgnoreCase); + + var missingActivities = StarterCareActivities + .Where(starterActivity => !existingActivityNames.Any(existingName => + string.Equals(existingName, starterActivity.Name, StringComparison.OrdinalIgnoreCase))) + .Select(starterActivity => + { + var actions = starterActivity.Actions + .Select((starterAction, index) => actionsByName.TryGetValue(starterAction.Name, out var action) + ? new CareActivityAction + { + CareActionId = action.Id, + CareAction = action, + SortOrder = index, + Resources = starterAction.ResourceNames + .Select(resourceName => resourcesByName.TryGetValue(resourceName, out var resource) + ? new CareActivityActionResource + { + ActionResourceId = resource.Id, + ActionResource = resource + } + : null) + .OfType() + .ToList() + } + : null) + .OfType() + .ToList(); + if (actions.Count != starterActivity.Actions.Count + || actions.Zip(starterActivity.Actions).Any(pair => pair.First.Resources.Count != pair.Second.ResourceNames.Count)) + { + return null; + } + + return new CareActivity + { + Name = starterActivity.Name, + IsEnabled = true, + Actions = actions + }; + }) + .OfType() + .ToList(); + + if (missingActivities.Count == 0) + { + return; + } + + db.CareActivities.AddRange(missingActivities); + db.SaveChanges(); + } + private static void SeedPlantFlags(ApplicationDbContext db) { var existingFlagNames = db.PlantFlagDefinitions @@ -159,7 +229,6 @@ namespace plant_manager.Data .Select(starterFlag => new PlantFlagDefinition { Name = starterFlag.Name, - Category = starterFlag.Category, Color = starterFlag.Color, IsEnabled = starterFlag.IsEnabled }) @@ -207,78 +276,100 @@ namespace plant_manager.Data } var taxaByName = db.PlantTaxa.ToDictionary(taxon => taxon.Name, StringComparer.OrdinalIgnoreCase); + var locationsByName = db.PlantLocations.ToDictionary(location => location.Name, StringComparer.OrdinalIgnoreCase); + var flagsByName = db.PlantFlagDefinitions.ToDictionary(flag => flag.Name, StringComparer.OrdinalIgnoreCase); PlantTaxon Taxon(string name) => taxaByName[name]; + PlantLocation Location(string name) => locationsByName[name]; - var today = DateOnly.FromDateTime(DateTime.UtcNow); + var starterPlants = StarterPlants + .Select(starterPlant => new Plant + { + Nickname = starterPlant.Nickname, + Taxon = Taxon(starterPlant.TaxonName), + Location = Location(starterPlant.Location) + }) + .ToList(); - var starterPlants = new[] - { - new - { - Plant = new Plant - { - Nickname = "Pothos", - Taxon = Taxon("Pothos"), - Location = "Living room" - }, - InitialWateredOn = today.AddDays(-7), - WaterIntervalDays = 7 - }, - new - { - Plant = new Plant - { - Nickname = "Money Tree", - Taxon = Taxon("Money Tree"), - Location = "Bedroom" - }, - InitialWateredOn = today.AddDays(-13), - WaterIntervalDays = 14 - }, - new - { - Plant = new Plant - { - Nickname = "Chinese Money Plant", - Taxon = Taxon("Chinese Money Plant"), - Location = "Kitchen" - }, - InitialWateredOn = today.AddDays(-2), - WaterIntervalDays = 6 - } - }; - - db.Plants.AddRange(starterPlants.Select(starterPlant => starterPlant.Plant)); + db.Plants.AddRange(starterPlants); db.SaveChanges(); - var waterAction = db.CareActions.FirstOrDefault(action => action.Name == "Water"); - if (waterAction is null) + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var starterFlags = new List(); + + void AddFlag(string plantNickname, string flagName) + { + if (!flagsByName.TryGetValue(flagName, out var flag)) + { + return; + } + + var plant = starterPlants.FirstOrDefault(item => + string.Equals(item.Nickname, plantNickname, StringComparison.OrdinalIgnoreCase)); + if (plant is null) + { + return; + } + + starterFlags.Add(new PlantFlag + { + PlantId = plant.Id, + PlantFlagDefinitionId = flag.Id, + StartedOn = today + }); + } + + AddFlag("Pinstripe Plant", "Dying"); + AddFlag("Pinstripe Plant", "Spider mites"); + AddFlag("Pinstripe Plant", "Quarantine"); + AddFlag("Chinese Money Plant 1", "Quarantine"); + AddFlag("Chinese Money Plant 2", "Quarantine"); + AddFlag("Chinese Money Plant 3", "Quarantine"); + AddFlag("Ficus Audrey", "Needs repotting"); + + db.PlantFlags.AddRange(starterFlags); + db.SaveChanges(); + } + + private static void SeedPlantLocations(ApplicationDbContext db) + { + var starterLocationNames = StarterPlants + .Select(plant => plant.Location) + .Append("Unassigned") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var existingLocationNames = db.PlantLocations + .Select(location => location.Name) + .ToList(); + + var missingLocations = starterLocationNames + .Where(starterLocation => !existingLocationNames.Any(existingName => + string.Equals(existingName, starterLocation, StringComparison.OrdinalIgnoreCase))) + .Select(starterLocation => new PlantLocation + { + Name = starterLocation, + IsEnabled = true + }) + .ToList(); + + if (missingLocations.Count == 0) { return; } - foreach (var starterPlant in starterPlants) - { - starterPlant.Plant.CareSchedules.Add(new PlantCareSchedule - { - CareActionId = waterAction.Id, - EveryDays = starterPlant.WaterIntervalDays, - IsEnabled = true - }); - db.ActionLogs.Add(new ActionLog - { - PlantId = starterPlant.Plant.Id, - CareActionId = waterAction.Id, - ActionNameSnapshot = waterAction.Name, - Notes = "Starter watering history.", - PerformedOn = starterPlant.InitialWateredOn - }); - } - + db.PlantLocations.AddRange(missingLocations); db.SaveChanges(); } private sealed record StarterTaxon(string Name, string Genus, string Species); + + private sealed record StarterCareActivity(string Name, IReadOnlyList Actions); + + private sealed record StarterCareActivityAction(string Name, IReadOnlyList ResourceNames); + + private sealed record StarterPlant( + string Nickname, + string TaxonName, + string Location); } } diff --git a/plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs b/plant-manager/Data/Migrations/20260520180017_InitialCreate.Designer.cs similarity index 65% rename from plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs rename to plant-manager/Data/Migrations/20260520180017_InitialCreate.Designer.cs index a0d4f65..eff107c 100644 --- a/plant-manager/Data/Migrations/20260518153034_InitialCreate.Designer.cs +++ b/plant-manager/Data/Migrations/20260520180017_InitialCreate.Designer.cs @@ -11,7 +11,7 @@ using plant_manager.Data; namespace plant_manager.Data.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260518153034_InitialCreate")] + [Migration("20260520180017_InitialCreate")] partial class InitialCreate { /// @@ -34,6 +34,9 @@ namespace plant_manager.Data.Migrations b.Property("CareActionId") .HasColumnType("INTEGER"); + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + b.Property("Notes") .HasMaxLength(1000) .HasColumnType("TEXT"); @@ -48,6 +51,8 @@ namespace plant_manager.Data.Migrations b.HasIndex("CareActionId"); + b.HasIndex("CareActivityId"); + b.HasIndex("PlantId"); b.ToTable("ActionLogs"); @@ -132,27 +137,104 @@ namespace plant_manager.Data.Migrations b.ToTable("CareActions"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActivities"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("CareActivityId", "CareActionId"); + + b.HasIndex("CareActionId"); + + b.HasIndex("CareActivityId", "SortOrder") + .IsUnique(); + + b.ToTable("CareActivityActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("CareActivityId", "CareActionId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("CareActivityActionResources"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Location") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); + b.Property("LocationId") + .HasColumnType("INTEGER"); b.Property("Nickname") .IsRequired() .HasMaxLength(120) .HasColumnType("TEXT"); - b.Property("TaxonId") + b.Property("TaxonId") .HasColumnType("INTEGER"); b.HasKey("Id"); + b.HasIndex("LocationId"); + b.HasIndex("TaxonId"); b.ToTable("Plants"); @@ -167,6 +249,9 @@ namespace plant_manager.Data.Migrations b.Property("CareActionId") .HasColumnType("INTEGER"); + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + b.Property("EveryDays") .HasColumnType("INTEGER"); @@ -180,7 +265,11 @@ namespace plant_manager.Data.Migrations b.HasIndex("CareActionId"); - b.HasIndex("PlantId", "CareActionId") + b.HasIndex("CareActivityId"); + + b.HasIndex("PlantId", "CareActionId"); + + b.HasIndex("PlantId", "CareActivityId") .IsUnique(); b.ToTable("PlantCareSchedules"); @@ -205,11 +294,6 @@ namespace plant_manager.Data.Migrations b.Property("ResolvedOn") .HasColumnType("TEXT"); - b.Property("Severity") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("TEXT"); - b.Property("StartedOn") .HasColumnType("TEXT"); @@ -228,11 +312,6 @@ namespace plant_manager.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Category") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("TEXT"); - b.Property("Color") .IsRequired() .HasMaxLength(20) @@ -254,6 +333,32 @@ namespace plant_manager.Data.Migrations b.ToTable("PlantFlagDefinitions"); }); + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantLocations"); + }); + modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b => { b.Property("Id") @@ -300,6 +405,12 @@ namespace plant_manager.Data.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("ActionLogs") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("plant_manager.Data.Models.Plant", "Plant") .WithMany("ActionLogs") .HasForeignKey("PlantId") @@ -308,6 +419,8 @@ namespace plant_manager.Data.Migrations b.Navigation("CareAction"); + b.Navigation("CareActivity"); + b.Navigation("Plant"); }); @@ -330,13 +443,57 @@ namespace plant_manager.Data.Migrations b.Navigation("ActionResource"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("CareActivityActions") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("Actions") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("CareActivity"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("CareActivityActionResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction") + .WithMany("Resources") + .HasForeignKey("CareActivityId", "CareActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActionResource"); + + b.Navigation("CareActivityAction"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { + b.HasOne("plant_manager.Data.Models.PlantLocation", "Location") + .WithMany("Plants") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon") .WithMany() .HasForeignKey("TaxonId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Location"); b.Navigation("Taxon"); }); @@ -349,6 +506,12 @@ namespace plant_manager.Data.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("plant_manager.Data.Models.Plant", "Plant") .WithMany("CareSchedules") .HasForeignKey("PlantId") @@ -357,6 +520,8 @@ namespace plant_manager.Data.Migrations b.Navigation("CareAction"); + b.Navigation("CareActivity"); + b.Navigation("Plant"); }); @@ -387,15 +552,33 @@ namespace plant_manager.Data.Migrations modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => { b.Navigation("ActionLogResources"); + + b.Navigation("CareActivityActionResources"); }); modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => { b.Navigation("ActionLogs"); + b.Navigation("CareActivityActions"); + b.Navigation("PlantCareSchedules"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("Actions"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Navigation("Resources"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { b.Navigation("ActionLogs"); @@ -409,6 +592,11 @@ namespace plant_manager.Data.Migrations { b.Navigation("PlantFlags"); }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Navigation("Plants"); + }); #pragma warning restore 612, 618 } } diff --git a/plant-manager/Data/Migrations/20260518153034_InitialCreate.cs b/plant-manager/Data/Migrations/20260520180017_InitialCreate.cs similarity index 62% rename from plant-manager/Data/Migrations/20260518153034_InitialCreate.cs rename to plant-manager/Data/Migrations/20260520180017_InitialCreate.cs index 3d51c1f..76dd9e4 100644 --- a/plant-manager/Data/Migrations/20260518153034_InitialCreate.cs +++ b/plant-manager/Data/Migrations/20260520180017_InitialCreate.cs @@ -42,6 +42,21 @@ namespace plant_manager.Data.Migrations table.PrimaryKey("PK_CareActions", x => x.Id); }); + migrationBuilder.CreateTable( + name: "CareActivities", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CareActivities", x => x.Id); + }); + migrationBuilder.CreateTable( name: "PlantFlagDefinitions", columns: table => new @@ -49,7 +64,6 @@ namespace plant_manager.Data.Migrations Id = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), - Category = table.Column(type: "TEXT", maxLength: 80, nullable: false), Color = table.Column(type: "TEXT", maxLength: 20, nullable: false), IsEnabled = table.Column(type: "INTEGER", nullable: false) }, @@ -58,6 +72,21 @@ namespace plant_manager.Data.Migrations table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id); }); + migrationBuilder.CreateTable( + name: "PlantLocations", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + IsEnabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PlantLocations", x => x.Id); + }); + migrationBuilder.CreateTable( name: "PlantTaxa", columns: table => new @@ -76,25 +105,84 @@ namespace plant_manager.Data.Migrations table.PrimaryKey("PK_PlantTaxa", x => x.Id); }); + migrationBuilder.CreateTable( + name: "CareActivityActions", + columns: table => new + { + CareActivityId = table.Column(type: "INTEGER", nullable: false), + CareActionId = table.Column(type: "INTEGER", nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CareActivityActions", x => new { x.CareActivityId, x.CareActionId }); + table.ForeignKey( + name: "FK_CareActivityActions_CareActions_CareActionId", + column: x => x.CareActionId, + principalTable: "CareActions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CareActivityActions_CareActivities_CareActivityId", + column: x => x.CareActivityId, + principalTable: "CareActivities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "Plants", columns: table => new { Id = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), - TaxonId = table.Column(type: "INTEGER", nullable: false), - Nickname = table.Column(type: "TEXT", maxLength: 120, nullable: false), - Location = table.Column(type: "TEXT", maxLength: 120, nullable: false) + TaxonId = table.Column(type: "INTEGER", nullable: true), + LocationId = table.Column(type: "INTEGER", nullable: true), + Nickname = table.Column(type: "TEXT", maxLength: 120, nullable: false) }, constraints: table => { table.PrimaryKey("PK_Plants", x => x.Id); + table.ForeignKey( + name: "FK_Plants_PlantLocations_LocationId", + column: x => x.LocationId, + principalTable: "PlantLocations", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); table.ForeignKey( name: "FK_Plants_PlantTaxa_TaxonId", column: x => x.TaxonId, principalTable: "PlantTaxa", principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "CareActivityActionResources", + columns: table => new + { + CareActivityId = table.Column(type: "INTEGER", nullable: false), + CareActionId = table.Column(type: "INTEGER", nullable: false), + ActionResourceId = table.Column(type: "INTEGER", nullable: false), + Quantity = table.Column(type: "TEXT", precision: 10, scale: 2, nullable: true), + Unit = table.Column(type: "TEXT", maxLength: 40, nullable: true), + Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CareActivityActionResources", x => new { x.CareActivityId, x.CareActionId, x.ActionResourceId }); + table.ForeignKey( + name: "FK_CareActivityActionResources_ActionResources_ActionResourceId", + column: x => x.ActionResourceId, + principalTable: "ActionResources", + principalColumn: "Id", onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_CareActivityActionResources_CareActivityActions_CareActivityId_CareActionId", + columns: x => new { x.CareActivityId, x.CareActionId }, + principalTable: "CareActivityActions", + principalColumns: new[] { "CareActivityId", "CareActionId" }, + onDelete: ReferentialAction.Cascade); }); migrationBuilder.CreateTable( @@ -105,6 +193,7 @@ namespace plant_manager.Data.Migrations .Annotation("Sqlite:Autoincrement", true), PlantId = table.Column(type: "INTEGER", nullable: false), CareActionId = table.Column(type: "INTEGER", nullable: false), + CareActivityId = table.Column(type: "INTEGER", nullable: false), ActionNameSnapshot = table.Column(type: "TEXT", maxLength: 80, nullable: false), Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true), PerformedOn = table.Column(type: "TEXT", nullable: false) @@ -118,6 +207,12 @@ namespace plant_manager.Data.Migrations principalTable: "CareActions", principalColumn: "Id", onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ActionLogs_CareActivities_CareActivityId", + column: x => x.CareActivityId, + principalTable: "CareActivities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); table.ForeignKey( name: "FK_ActionLogs_Plants_PlantId", column: x => x.PlantId, @@ -134,6 +229,7 @@ namespace plant_manager.Data.Migrations .Annotation("Sqlite:Autoincrement", true), PlantId = table.Column(type: "INTEGER", nullable: false), CareActionId = table.Column(type: "INTEGER", nullable: false), + CareActivityId = table.Column(type: "INTEGER", nullable: false), EveryDays = table.Column(type: "INTEGER", nullable: false), IsEnabled = table.Column(type: "INTEGER", nullable: false) }, @@ -146,6 +242,12 @@ namespace plant_manager.Data.Migrations 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); table.ForeignKey( name: "FK_PlantCareSchedules_Plants_PlantId", column: x => x.PlantId, @@ -162,7 +264,6 @@ namespace plant_manager.Data.Migrations .Annotation("Sqlite:Autoincrement", true), PlantId = table.Column(type: "INTEGER", nullable: false), PlantFlagDefinitionId = table.Column(type: "INTEGER", nullable: false), - Severity = table.Column(type: "TEXT", maxLength: 20, nullable: false), StartedOn = table.Column(type: "TEXT", nullable: false), ResolvedOn = table.Column(type: "TEXT", nullable: true), Notes = table.Column(type: "TEXT", maxLength: 1000, nullable: true) @@ -220,6 +321,11 @@ namespace plant_manager.Data.Migrations table: "ActionLogs", column: "CareActionId"); + migrationBuilder.CreateIndex( + name: "IX_ActionLogs_CareActivityId", + table: "ActionLogs", + column: "CareActivityId"); + migrationBuilder.CreateIndex( name: "IX_ActionLogs_PlantId", table: "ActionLogs", @@ -237,15 +343,47 @@ namespace plant_manager.Data.Migrations column: "Name", unique: true); + migrationBuilder.CreateIndex( + name: "IX_CareActivities_Name", + table: "CareActivities", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CareActivityActionResources_ActionResourceId", + table: "CareActivityActionResources", + column: "ActionResourceId"); + + migrationBuilder.CreateIndex( + name: "IX_CareActivityActions_CareActionId", + table: "CareActivityActions", + column: "CareActionId"); + + migrationBuilder.CreateIndex( + name: "IX_CareActivityActions_CareActivityId_SortOrder", + table: "CareActivityActions", + columns: new[] { "CareActivityId", "SortOrder" }, + unique: true); + migrationBuilder.CreateIndex( name: "IX_PlantCareSchedules_CareActionId", table: "PlantCareSchedules", column: "CareActionId"); + migrationBuilder.CreateIndex( + name: "IX_PlantCareSchedules_CareActivityId", + table: "PlantCareSchedules", + column: "CareActivityId"); + migrationBuilder.CreateIndex( name: "IX_PlantCareSchedules_PlantId_CareActionId", table: "PlantCareSchedules", - columns: new[] { "PlantId", "CareActionId" }, + columns: new[] { "PlantId", "CareActionId" }); + + migrationBuilder.CreateIndex( + name: "IX_PlantCareSchedules_PlantId_CareActivityId", + table: "PlantCareSchedules", + columns: new[] { "PlantId", "CareActivityId" }, unique: true); migrationBuilder.CreateIndex( @@ -264,6 +402,17 @@ namespace plant_manager.Data.Migrations table: "PlantFlags", columns: new[] { "PlantId", "PlantFlagDefinitionId", "ResolvedOn" }); + migrationBuilder.CreateIndex( + name: "IX_PlantLocations_Name", + table: "PlantLocations", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Plants_LocationId", + table: "Plants", + column: "LocationId"); + migrationBuilder.CreateIndex( name: "IX_Plants_TaxonId", table: "Plants", @@ -276,6 +425,9 @@ namespace plant_manager.Data.Migrations migrationBuilder.DropTable( name: "ActionLogResources"); + migrationBuilder.DropTable( + name: "CareActivityActionResources"); + migrationBuilder.DropTable( name: "PlantCareSchedules"); @@ -288,14 +440,23 @@ namespace plant_manager.Data.Migrations migrationBuilder.DropTable( name: "ActionResources"); + migrationBuilder.DropTable( + name: "CareActivityActions"); + migrationBuilder.DropTable( name: "PlantFlagDefinitions"); + migrationBuilder.DropTable( + name: "Plants"); + migrationBuilder.DropTable( name: "CareActions"); migrationBuilder.DropTable( - name: "Plants"); + name: "CareActivities"); + + migrationBuilder.DropTable( + name: "PlantLocations"); migrationBuilder.DropTable( name: "PlantTaxa"); diff --git a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs index baef2bc..72e2068 100644 --- a/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/plant-manager/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -31,6 +31,9 @@ namespace plant_manager.Data.Migrations b.Property("CareActionId") .HasColumnType("INTEGER"); + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + b.Property("Notes") .HasMaxLength(1000) .HasColumnType("TEXT"); @@ -45,6 +48,8 @@ namespace plant_manager.Data.Migrations b.HasIndex("CareActionId"); + b.HasIndex("CareActivityId"); + b.HasIndex("PlantId"); b.ToTable("ActionLogs"); @@ -129,27 +134,104 @@ namespace plant_manager.Data.Migrations b.ToTable("CareActions"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CareActivities"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("CareActivityId", "CareActionId"); + + b.HasIndex("CareActionId"); + + b.HasIndex("CareActivityId", "SortOrder") + .IsUnique(); + + b.ToTable("CareActivityActions"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + + b.Property("CareActionId") + .HasColumnType("INTEGER"); + + b.Property("ActionResourceId") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasPrecision(10, 2) + .HasColumnType("TEXT"); + + b.Property("Unit") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.HasKey("CareActivityId", "CareActionId", "ActionResourceId"); + + b.HasIndex("ActionResourceId"); + + b.ToTable("CareActivityActionResources"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Location") - .IsRequired() - .HasMaxLength(120) - .HasColumnType("TEXT"); + b.Property("LocationId") + .HasColumnType("INTEGER"); b.Property("Nickname") .IsRequired() .HasMaxLength(120) .HasColumnType("TEXT"); - b.Property("TaxonId") + b.Property("TaxonId") .HasColumnType("INTEGER"); b.HasKey("Id"); + b.HasIndex("LocationId"); + b.HasIndex("TaxonId"); b.ToTable("Plants"); @@ -164,6 +246,9 @@ namespace plant_manager.Data.Migrations b.Property("CareActionId") .HasColumnType("INTEGER"); + b.Property("CareActivityId") + .HasColumnType("INTEGER"); + b.Property("EveryDays") .HasColumnType("INTEGER"); @@ -177,7 +262,11 @@ namespace plant_manager.Data.Migrations b.HasIndex("CareActionId"); - b.HasIndex("PlantId", "CareActionId") + b.HasIndex("CareActivityId"); + + b.HasIndex("PlantId", "CareActionId"); + + b.HasIndex("PlantId", "CareActivityId") .IsUnique(); b.ToTable("PlantCareSchedules"); @@ -202,11 +291,6 @@ namespace plant_manager.Data.Migrations b.Property("ResolvedOn") .HasColumnType("TEXT"); - b.Property("Severity") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("TEXT"); - b.Property("StartedOn") .HasColumnType("TEXT"); @@ -225,11 +309,6 @@ namespace plant_manager.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Category") - .IsRequired() - .HasMaxLength(80) - .HasColumnType("TEXT"); - b.Property("Color") .IsRequired() .HasMaxLength(20) @@ -251,6 +330,32 @@ namespace plant_manager.Data.Migrations b.ToTable("PlantFlagDefinitions"); }); + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PlantLocations"); + }); + modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b => { b.Property("Id") @@ -297,6 +402,12 @@ namespace plant_manager.Data.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("ActionLogs") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("plant_manager.Data.Models.Plant", "Plant") .WithMany("ActionLogs") .HasForeignKey("PlantId") @@ -305,6 +416,8 @@ namespace plant_manager.Data.Migrations b.Navigation("CareAction"); + b.Navigation("CareActivity"); + b.Navigation("Plant"); }); @@ -327,13 +440,57 @@ namespace plant_manager.Data.Migrations b.Navigation("ActionResource"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") + .WithMany("CareActivityActions") + .HasForeignKey("CareActionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("Actions") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareAction"); + + b.Navigation("CareActivity"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b => + { + b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource") + .WithMany("CareActivityActionResources") + .HasForeignKey("ActionResourceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction") + .WithMany("Resources") + .HasForeignKey("CareActivityId", "CareActionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActionResource"); + + b.Navigation("CareActivityAction"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { + b.HasOne("plant_manager.Data.Models.PlantLocation", "Location") + .WithMany("Plants") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon") .WithMany() .HasForeignKey("TaxonId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Location"); b.Navigation("Taxon"); }); @@ -346,6 +503,12 @@ namespace plant_manager.Data.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity") + .WithMany("PlantCareSchedules") + .HasForeignKey("CareActivityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + b.HasOne("plant_manager.Data.Models.Plant", "Plant") .WithMany("CareSchedules") .HasForeignKey("PlantId") @@ -354,6 +517,8 @@ namespace plant_manager.Data.Migrations b.Navigation("CareAction"); + b.Navigation("CareActivity"); + b.Navigation("Plant"); }); @@ -384,15 +549,33 @@ namespace plant_manager.Data.Migrations modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b => { b.Navigation("ActionLogResources"); + + b.Navigation("CareActivityActionResources"); }); modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => { b.Navigation("ActionLogs"); + b.Navigation("CareActivityActions"); + b.Navigation("PlantCareSchedules"); }); + modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("Actions"); + + b.Navigation("PlantCareSchedules"); + }); + + modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b => + { + b.Navigation("Resources"); + }); + modelBuilder.Entity("plant_manager.Data.Models.Plant", b => { b.Navigation("ActionLogs"); @@ -406,6 +589,11 @@ namespace plant_manager.Data.Migrations { b.Navigation("PlantFlags"); }); + + modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => + { + b.Navigation("Plants"); + }); #pragma warning restore 612, 618 } } diff --git a/plant-manager/Data/Models/ActionLog.cs b/plant-manager/Data/Models/ActionLog.cs index a95f64f..ef1e53e 100644 --- a/plant-manager/Data/Models/ActionLog.cs +++ b/plant-manager/Data/Models/ActionLog.cs @@ -5,12 +5,14 @@ namespace plant_manager.Data.Models public int Id { get; set; } public int PlantId { get; set; } public int CareActionId { get; set; } + public int CareActivityId { get; set; } public string ActionNameSnapshot { get; set; } = string.Empty; public string? Notes { get; set; } public DateOnly PerformedOn { get; set; } public Plant Plant { get; set; } = null!; public CareAction CareAction { get; set; } = null!; + public CareActivity CareActivity { get; set; } = null!; public List Resources { get; set; } = []; } } diff --git a/plant-manager/Data/Models/ActionResource.cs b/plant-manager/Data/Models/ActionResource.cs index bbc0da8..b85f096 100644 --- a/plant-manager/Data/Models/ActionResource.cs +++ b/plant-manager/Data/Models/ActionResource.cs @@ -9,6 +9,7 @@ namespace plant_manager.Data.Models public string? Notes { get; set; } public bool IsEnabled { get; set; } = true; + public List CareActivityActionResources { get; set; } = []; public List ActionLogResources { get; set; } = []; } } diff --git a/plant-manager/Data/Models/CareAction.cs b/plant-manager/Data/Models/CareAction.cs index 3cab33f..9a96a23 100644 --- a/plant-manager/Data/Models/CareAction.cs +++ b/plant-manager/Data/Models/CareAction.cs @@ -8,6 +8,7 @@ namespace plant_manager.Data.Models public string? Description { get; set; } public bool IsEnabled { get; set; } = true; + public List CareActivityActions { get; set; } = []; public List ActionLogs { get; set; } = []; public List PlantCareSchedules { get; set; } = []; } diff --git a/plant-manager/Data/Models/CareActivity.cs b/plant-manager/Data/Models/CareActivity.cs new file mode 100644 index 0000000..413a492 --- /dev/null +++ b/plant-manager/Data/Models/CareActivity.cs @@ -0,0 +1,15 @@ +namespace plant_manager.Data.Models +{ + public class CareActivity + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + public string? Notes { get; set; } + public bool IsEnabled { get; set; } = true; + + public List Actions { get; set; } = []; + public List ActionLogs { get; set; } = []; + public List PlantCareSchedules { get; set; } = []; + } +} diff --git a/plant-manager/Data/Models/CareActivityAction.cs b/plant-manager/Data/Models/CareActivityAction.cs new file mode 100644 index 0000000..2fbd6b9 --- /dev/null +++ b/plant-manager/Data/Models/CareActivityAction.cs @@ -0,0 +1,13 @@ +namespace plant_manager.Data.Models +{ + public class CareActivityAction + { + public int CareActivityId { get; set; } + public int CareActionId { get; set; } + public int SortOrder { get; set; } + + public CareActivity CareActivity { get; set; } = null!; + public CareAction CareAction { get; set; } = null!; + public List Resources { get; set; } = []; + } +} diff --git a/plant-manager/Data/Models/CareActivityActionResource.cs b/plant-manager/Data/Models/CareActivityActionResource.cs new file mode 100644 index 0000000..5756a56 --- /dev/null +++ b/plant-manager/Data/Models/CareActivityActionResource.cs @@ -0,0 +1,15 @@ +namespace plant_manager.Data.Models +{ + public class CareActivityActionResource + { + public int CareActivityId { get; set; } + public int CareActionId { get; set; } + public int ActionResourceId { get; set; } + public decimal? Quantity { get; set; } + public string? Unit { get; set; } + public string? Notes { get; set; } + + public CareActivityAction CareActivityAction { get; set; } = null!; + public ActionResource ActionResource { get; set; } = null!; + } +} diff --git a/plant-manager/Data/Models/Plant.cs b/plant-manager/Data/Models/Plant.cs index 7c62692..25c0135 100644 --- a/plant-manager/Data/Models/Plant.cs +++ b/plant-manager/Data/Models/Plant.cs @@ -3,11 +3,12 @@ namespace plant_manager.Data.Models public class Plant { public int Id { get; set; } - public int TaxonId { get; set; } + public int? TaxonId { get; set; } + public int? LocationId { get; set; } public string Nickname { get; set; } = string.Empty; - public string Location { get; set; } = string.Empty; - public PlantTaxon Taxon { get; set; } = null!; + public PlantTaxon? Taxon { get; set; } + public PlantLocation? Location { get; set; } public List ActionLogs { get; set; } = []; public List CareSchedules { get; set; } = []; public List Flags { get; set; } = []; diff --git a/plant-manager/Data/Models/PlantCareSchedule.cs b/plant-manager/Data/Models/PlantCareSchedule.cs index 1de6a67..f1a3bed 100644 --- a/plant-manager/Data/Models/PlantCareSchedule.cs +++ b/plant-manager/Data/Models/PlantCareSchedule.cs @@ -5,10 +5,12 @@ namespace plant_manager.Data.Models 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; public bool IsEnabled { get; set; } = true; public Plant Plant { get; set; } = null!; public CareAction CareAction { get; set; } = null!; + public CareActivity CareActivity { get; set; } = null!; } } diff --git a/plant-manager/Data/Models/PlantFlag.cs b/plant-manager/Data/Models/PlantFlag.cs index 1b458c2..2ea6033 100644 --- a/plant-manager/Data/Models/PlantFlag.cs +++ b/plant-manager/Data/Models/PlantFlag.cs @@ -5,7 +5,6 @@ namespace plant_manager.Data.Models public int Id { get; set; } public int PlantId { get; set; } public int PlantFlagDefinitionId { get; set; } - public string Severity { get; set; } = "medium"; public DateOnly StartedOn { get; set; } public DateOnly? ResolvedOn { get; set; } public string? Notes { get; set; } diff --git a/plant-manager/Data/Models/PlantFlagDefinition.cs b/plant-manager/Data/Models/PlantFlagDefinition.cs index cfd1b90..afedbc6 100644 --- a/plant-manager/Data/Models/PlantFlagDefinition.cs +++ b/plant-manager/Data/Models/PlantFlagDefinition.cs @@ -4,7 +4,6 @@ namespace plant_manager.Data.Models { public int Id { get; set; } public string Name { get; set; } = string.Empty; - public string Category { get; set; } = string.Empty; public string Color { get; set; } = "#f2f2f2"; public bool IsEnabled { get; set; } = true; diff --git a/plant-manager/Data/Models/PlantLocation.cs b/plant-manager/Data/Models/PlantLocation.cs new file mode 100644 index 0000000..8098b01 --- /dev/null +++ b/plant-manager/Data/Models/PlantLocation.cs @@ -0,0 +1,13 @@ +namespace plant_manager.Data.Models +{ + public class PlantLocation + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + public string? Notes { get; set; } + public bool IsEnabled { get; set; } = true; + + public List Plants { get; set; } = []; + } +} diff --git a/plant-manager/Endpoints/ActionLogEndpoints.cs b/plant-manager/Endpoints/ActionLogEndpoints.cs index f14c91d..2577ab6 100644 --- a/plant-manager/Endpoints/ActionLogEndpoints.cs +++ b/plant-manager/Endpoints/ActionLogEndpoints.cs @@ -12,6 +12,7 @@ namespace plant_manager.Endpoints { var logs = await db.ActionLogs .Include(log => log.Plant) + .Include(log => log.CareActivity) .Include(log => log.Resources) .ThenInclude(resource => resource.ActionResource) .OrderByDescending(log => log.PerformedOn) @@ -29,19 +30,26 @@ namespace plant_manager.Endpoints return Results.BadRequest(new { error = "Plant was not found." }); } - var action = await db.CareActions.FindAsync(request.CareActionId); - if (action is null) + 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 action was not found." }); + return Results.BadRequest(new { error = "Care activity was not found." }); } - if (!action.IsEnabled) + var primaryAction = activity.PrimaryAction(); + if (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled)) { - return Results.BadRequest(new { error = "Disabled actions cannot be logged." }); + return Results.BadRequest(new { error = "Disabled activities cannot be logged." }); } var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow); - var (resources, resourceError) = await BuildLogResources(request.Resources, db); + var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db); if (resourceError is not null) { return Results.BadRequest(new { error = resourceError }); @@ -50,9 +58,11 @@ namespace plant_manager.Endpoints var log = new ActionLog { PlantId = plant.Id, - CareActionId = action.Id, - CareAction = action, - ActionNameSnapshot = action.Name, + CareActionId = primaryAction.Id, + CareActivityId = activity.Id, + CareAction = primaryAction, + CareActivity = activity, + ActionNameSnapshot = activity.Name, Notes = request.Notes?.Trim(), PerformedOn = performedOn, Resources = resources @@ -70,6 +80,7 @@ namespace plant_manager.Endpoints { var log = await db.ActionLogs .Include(item => item.Plant) + .Include(item => item.CareActivity) .Include(item => item.Resources) .FirstOrDefaultAsync(item => item.Id == id); if (log is null) @@ -77,27 +88,31 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var oldPlantId = log.PlantId; - var oldCareActionId = log.CareActionId; - var plant = await db.Plants.FindAsync(request.PlantId); if (plant is null) { return Results.BadRequest(new { error = "Plant was not found." }); } - var action = await db.CareActions.FindAsync(request.CareActionId); - if (action is null) + 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 action was not found." }); + return Results.BadRequest(new { error = "Care activity was not found." }); } - if (!action.IsEnabled) + var primaryAction = activity.PrimaryAction(); + if (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled)) { - return Results.BadRequest(new { error = "Disabled actions cannot be logged." }); + return Results.BadRequest(new { error = "Disabled activities cannot be logged." }); } - var (resources, resourceError) = await BuildLogResources(request.Resources, db); + var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db); if (resourceError is not null) { return Results.BadRequest(new { error = resourceError }); @@ -107,9 +122,11 @@ namespace plant_manager.Endpoints log.PlantId = plant.Id; log.Plant = plant; - log.CareActionId = action.Id; - log.CareAction = action; - log.ActionNameSnapshot = action.Name; + log.CareActionId = primaryAction.Id; + log.CareActivityId = activity.Id; + log.CareAction = primaryAction; + log.CareActivity = activity; + log.ActionNameSnapshot = activity.Name; log.Notes = request.Notes?.Trim(); log.PerformedOn = request.PerformedOn; log.Resources = resources; @@ -136,12 +153,23 @@ namespace plant_manager.Endpoints private static async Task<(List Resources, string? Error)> BuildLogResources( IReadOnlyList? requestResources, + CareActivity activity, ApplicationDbContext db) { var requestedResources = requestResources? .GroupBy(resource => resource.ActionResourceId) .Select(group => group.First()) .ToList() ?? []; + foreach (var configuredResource in GetConfiguredResources(activity)) + { + if (!requestedResources.Any(resource => resource.ActionResourceId == configuredResource.ActionResourceId)) + { + requestedResources.Add(new ActionLogResourceRequest( + configuredResource.ActionResourceId, + configuredResource.Quantity, + configuredResource.Unit)); + } + } if (requestedResources.Any(resource => resource.Quantity < 0)) { @@ -176,5 +204,11 @@ namespace plant_manager.Endpoints .ToList(), null); } + private static IEnumerable GetConfiguredResources(CareActivity activity) => + activity.Actions + .SelectMany(action => action.Resources) + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()); + } } diff --git a/plant-manager/Endpoints/ActionResourceEndpoints.cs b/plant-manager/Endpoints/ActionResourceEndpoints.cs index fc2cf1f..4b0a3b3 100644 --- a/plant-manager/Endpoints/ActionResourceEndpoints.cs +++ b/plant-manager/Endpoints/ActionResourceEndpoints.cs @@ -87,12 +87,13 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var hasLogs = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id); - if (hasLogs) + var isInUse = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id) + || await db.CareActivityActionResources.AnyAsync(activityResource => activityResource.ActionResourceId == id); + if (isInUse) { resource.IsEnabled = false; await db.SaveChangesAsync(); - return Results.Conflict(new { error = "Resource has care history, so it was disabled instead of deleted." }); + return Results.Conflict(new { error = "Resource is in use, so it was disabled instead of deleted." }); } db.ActionResources.Remove(resource); diff --git a/plant-manager/Endpoints/CareActivityEndpoints.cs b/plant-manager/Endpoints/CareActivityEndpoints.cs new file mode 100644 index 0000000..505619b --- /dev/null +++ b/plant-manager/Endpoints/CareActivityEndpoints.cs @@ -0,0 +1,215 @@ +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; +using plant_manager.Data.Models; + +namespace plant_manager.Endpoints +{ + public static class CareActivityEndpoints + { + public static void MapCareActivityEndpoints(this WebApplication app) + { + app.MapGet("/api/care-activities", async (ApplicationDbContext db) => + { + var activities = await db.CareActivities + .Include(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) + .OrderByDescending(activity => activity.IsEnabled) + .ThenBy(activity => activity.Name) + .Select(activity => CareActivityDto.FromCareActivity(activity)) + .ToListAsync(); + + return Results.Ok(activities); + }); + + app.MapPost("/api/care-activities", async (SaveCareActivityRequest request, ApplicationDbContext db) => + { + var validation = await ValidateRequest(request, db); + if (validation.Error is not null) + { + return Results.BadRequest(new { error = validation.Error }); + } + + var name = request.Name.Trim(); + var exists = await db.CareActivities.AnyAsync(activity => activity.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "An activity with this name already exists." }); + } + + var activity = new CareActivity + { + Name = name, + Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + IsEnabled = request.IsEnabled, + Actions = validation.Actions + }; + + db.CareActivities.Add(activity); + await db.SaveChangesAsync(); + + return Results.Created($"/api/care-activities/{activity.Id}", CareActivityDto.FromCareActivity(activity)); + }); + + app.MapPut("/api/care-activities/{id:int}", async (int id, SaveCareActivityRequest request, ApplicationDbContext db) => + { + 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 == id); + if (activity is null) + { + return Results.NotFound(); + } + + var validation = await ValidateRequest(request, db); + if (validation.Error is not null) + { + return Results.BadRequest(new { error = validation.Error }); + } + + var name = request.Name.Trim(); + var exists = await db.CareActivities.AnyAsync(item => + item.Id != id && item.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "An activity with this name already exists." }); + } + + activity.Name = name; + activity.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + activity.IsEnabled = request.IsEnabled; + db.CareActivityActions.RemoveRange(activity.Actions); + activity.Actions = validation.Actions; + + await db.SaveChangesAsync(); + + return Results.Ok(CareActivityDto.FromCareActivity(activity)); + }); + + app.MapDelete("/api/care-activities/{id:int}", async (int id, ApplicationDbContext db) => + { + var activity = await db.CareActivities.FindAsync(id); + if (activity is null) + { + return Results.NotFound(); + } + + var hasHistory = await db.ActionLogs.AnyAsync(log => log.CareActivityId == id) + || await db.PlantCareSchedules.AnyAsync(schedule => schedule.CareActivityId == id); + if (hasHistory) + { + activity.IsEnabled = false; + await db.SaveChangesAsync(); + return Results.Conflict(new { error = "Activity is in use, so it was disabled instead of deleted." }); + } + + db.CareActivities.Remove(activity); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }); + } + + private static async Task<(List Actions, string? Error)> ValidateRequest( + SaveCareActivityRequest request, + ApplicationDbContext db) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return ([], "Activity name is required."); + } + + var requestedActions = request.Actions? + .GroupBy(action => action.CareActionId) + .Select(group => group.First()) + .ToList(); + if (requestedActions is null || requestedActions.Count == 0) + { + return ([], "Select at least one care action."); + } + + if (requestedActions.Any(action => action.CareActionId <= 0)) + { + return ([], "Select a care action for every activity action."); + } + + var actionIds = requestedActions + .Select(action => action.CareActionId) + .ToList(); + var actionsById = await db.CareActions + .Where(action => actionIds.Contains(action.Id)) + .ToDictionaryAsync(action => action.Id); + if (actionsById.Count != actionIds.Count) + { + return ([], "One or more care actions were not found."); + } + + if (actionsById.Values.Any(action => !action.IsEnabled)) + { + return ([], "Disabled actions cannot be used in activities."); + } + + var requestedResources = requestedActions + .SelectMany(action => action.Resources ?? []) + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()) + .ToList(); + if (requestedResources.Any(resource => resource.ActionResourceId <= 0)) + { + return ([], "Select a resource for every activity resource."); + } + + if (requestedResources.Any(resource => resource.Quantity < 0)) + { + return ([], "Resource quantities cannot be negative."); + } + + var resourceIds = requestedResources + .Select(resource => resource.ActionResourceId) + .ToList(); + var resourcesById = await db.ActionResources + .Where(resource => resourceIds.Contains(resource.Id)) + .ToDictionaryAsync(resource => resource.Id); + if (resourcesById.Count != resourceIds.Count) + { + return ([], "One or more resources were not found."); + } + + if (resourcesById.Values.Any(resource => !resource.IsEnabled)) + { + return ([], "Disabled resources cannot be used in activities."); + } + + var activityActions = requestedActions + .Select((actionRequest, index) => new CareActivityAction + { + CareActionId = actionRequest.CareActionId, + CareAction = actionsById[actionRequest.CareActionId], + SortOrder = index, + Resources = (actionRequest.Resources ?? []) + .Where(resource => resource.ActionResourceId > 0) + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()) + .Select(resource => new CareActivityActionResource + { + CareActionId = actionRequest.CareActionId, + ActionResourceId = resource.ActionResourceId, + ActionResource = resourcesById[resource.ActionResourceId], + Quantity = resource.Quantity, + Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim(), + Notes = string.IsNullOrWhiteSpace(resource.Notes) ? null : resource.Notes.Trim() + }) + .ToList() + }) + .ToList(); + + return (activityActions, null); + } + } +} diff --git a/plant-manager/Endpoints/CareTaskEndpoints.cs b/plant-manager/Endpoints/CareTaskEndpoints.cs index cca6a43..5eb3e08 100644 --- a/plant-manager/Endpoints/CareTaskEndpoints.cs +++ b/plant-manager/Endpoints/CareTaskEndpoints.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using plant_manager.Data; +using plant_manager.Data.Models; namespace plant_manager.Endpoints { @@ -13,27 +14,37 @@ namespace plant_manager.Endpoints var schedules = await db.PlantCareSchedules .Include(schedule => schedule.Plant) .Include(schedule => schedule.CareAction) - .Where(schedule => schedule.IsEnabled && schedule.CareAction.IsEnabled) + .Include(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) + .Where(schedule => + schedule.IsEnabled + && schedule.CareActivity.Actions.All(action => action.CareAction.IsEnabled) + && schedule.CareActivity.IsEnabled) .OrderBy(schedule => schedule.Plant.Nickname) - .ThenBy(schedule => schedule.CareAction.Name) + .ThenBy(schedule => schedule.CareActivity.Name) .ToListAsync(); var latestLogs = await db.ActionLogs - .GroupBy(log => new { log.PlantId, log.CareActionId }) + .GroupBy(log => new { log.PlantId, log.CareActivityId }) .Select(group => new { group.Key.PlantId, - group.Key.CareActionId, + group.Key.CareActivityId, LastPerformedOn = group.Max(log => log.PerformedOn) }) .ToListAsync(); var latestLogLookup = latestLogs.ToDictionary( - log => (log.PlantId, log.CareActionId), + log => (log.PlantId, log.CareActivityId), log => (DateOnly?)log.LastPerformedOn); var tasks = schedules .Select(schedule => CareTaskDto.FromSchedule( schedule, - latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActionId)), + latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)), today)) .Where(task => task.Status is "due" or "soon") .ToList(); @@ -43,6 +54,150 @@ namespace plant_manager.Endpoints app.MapGet("/api/care-tasks/upcoming", GetUpcomingCareTasks); app.MapGet("/api/care-tasks/today", GetUpcomingCareTasks); + + app.MapPost("/api/care-tasks/complete-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 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 (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled)) + { + return Results.BadRequest(new { error = "Disabled activities cannot be logged." }); + } + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var schedules = await db.PlantCareSchedules + .Include(schedule => schedule.Plant) + .Where(schedule => + schedule.IsEnabled + && schedule.CareActivityId == activity.Id + && plantIds.Contains(schedule.PlantId)) + .ToListAsync(); + var schedulePlantIds = schedules + .Select(schedule => schedule.PlantId) + .ToHashSet(); + if (schedulePlantIds.Count != plantIds.Count) + { + return Results.BadRequest(new { error = "One or more plants do not have this enabled schedule." }); + } + + var latestLogs = await db.ActionLogs + .Where(log => log.CareActivityId == activity.Id && plantIds.Contains(log.PlantId)) + .GroupBy(log => log.PlantId) + .Select(group => new + { + PlantId = group.Key, + LastPerformedOn = group.Max(log => log.PerformedOn) + }) + .ToListAsync(); + var latestLogLookup = latestLogs.ToDictionary( + log => log.PlantId, + log => (DateOnly?)log.LastPerformedOn); + var duePlantIds = schedules + .Where(schedule => + PlantCareFormatter.GetStatus( + PlantCareFormatter.GetNextCareDate( + latestLogLookup.GetValueOrDefault(schedule.PlantId), + schedule.EveryDays), + today) == "due") + .Select(schedule => schedule.PlantId) + .ToHashSet(); + + if (duePlantIds.Count != plantIds.Count) + { + return Results.BadRequest(new { error = "Only due care tasks can be completed in bulk." }); + } + + var resourceIds = request.Resources? + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()) + .ToList() ?? []; + foreach (var configuredResource in GetConfiguredResources(activity)) + { + if (!resourceIds.Any(resource => resource.ActionResourceId == configuredResource.ActionResourceId)) + { + resourceIds.Add(new ActionLogResourceRequest( + configuredResource.ActionResourceId, + configuredResource.Quantity, + configuredResource.Unit)); + } + } + if (resourceIds.Any(resource => resource.Quantity < 0)) + { + return Results.BadRequest(new { error = "Resource quantities cannot be negative." }); + } + + var requestedResourceIds = resourceIds + .Select(resource => resource.ActionResourceId) + .ToList(); + var resourcesById = await db.ActionResources + .Where(resource => requestedResourceIds.Contains(resource.Id)) + .ToDictionaryAsync(resource => resource.Id); + if (resourcesById.Count != requestedResourceIds.Count) + { + return Results.BadRequest(new { error = "One or more resources were not found." }); + } + + if (resourcesById.Values.Any(resource => !resource.IsEnabled)) + { + return Results.BadRequest(new { error = "Disabled resources cannot be logged." }); + } + + 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 + { + PlantId = schedule.PlantId, + CareActionId = primaryAction.Id, + CareActivityId = activity.Id, + ActionNameSnapshot = activity.Name, + Notes = notes, + PerformedOn = performedOn, + Resources = resourceIds + .Select(resource => new ActionLogResource + { + ActionResourceId = resource.ActionResourceId, + Quantity = resource.Quantity, + Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim() + }) + .ToList() + }) + .ToList(); + + db.ActionLogs.AddRange(logs); + await db.SaveChangesAsync(); + + return Results.Ok(new { completed = logs.Count }); + }); } + + private static IEnumerable GetConfiguredResources(CareActivity activity) => + activity.Actions + .SelectMany(action => action.Resources) + .GroupBy(resource => resource.ActionResourceId) + .Select(group => group.First()); } } diff --git a/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs b/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs new file mode 100644 index 0000000..d73dc37 --- /dev/null +++ b/plant-manager/Endpoints/PlantCareScheduleEndpoints.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; +using plant_manager.Data.Models; + +namespace plant_manager.Endpoints +{ + public static class PlantCareScheduleEndpoints + { + public static void MapPlantCareScheduleEndpoints(this WebApplication app) + { + app.MapPost("/api/plant-care-schedules/bulk", 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." }); + } + + var activity = await db.CareActivities + .Include(item => item.Actions) + .ThenInclude(action => action.CareAction) + .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 (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled)) + { + return Results.BadRequest(new { error = "Disabled activities cannot be scheduled." }); + } + + var plants = await db.Plants + .Include(plant => plant.CareSchedules) + .Where(plant => plantIds.Contains(plant.Id)) + .ToListAsync(); + if (plants.Count != plantIds.Count) + { + return Results.BadRequest(new { error = "One or more plants were not found." }); + } + + var everyDays = Math.Clamp(request.EveryDays ?? 7, 1, 365); + 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; + schedule.EveryDays = everyDays; + schedule.IsEnabled = request.IsEnabled; + } + + await db.SaveChangesAsync(); + + return Results.Ok(new { updated = plants.Count }); + }); + } + } +} diff --git a/plant-manager/Endpoints/PlantEndpoints.cs b/plant-manager/Endpoints/PlantEndpoints.cs index 40f01fc..c80d51c 100644 --- a/plant-manager/Endpoints/PlantEndpoints.cs +++ b/plant-manager/Endpoints/PlantEndpoints.cs @@ -12,8 +12,18 @@ namespace plant_manager.Endpoints { var plants = await db.Plants .Include(plant => plant.Taxon) + .Include(plant => plant.Location) .Include(plant => plant.CareSchedules) .ThenInclude(schedule => schedule.CareAction) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) .Include(plant => plant.ActionLogs) .Include(plant => plant.Flags) .ThenInclude(flag => flag.Definition) @@ -27,8 +37,18 @@ namespace plant_manager.Endpoints { var plant = await db.Plants .Include(item => item.Taxon) + .Include(item => item.Location) .Include(plant => plant.CareSchedules) .ThenInclude(schedule => schedule.CareAction) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(plant => plant.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) .Include(plant => plant.ActionLogs) .Include(plant => plant.Flags) .ThenInclude(flag => flag.Definition) @@ -46,24 +66,33 @@ namespace plant_manager.Endpoints return Results.BadRequest(new { error = "Nickname is required." }); } - var taxon = await db.PlantTaxa.FindAsync(request.TaxonId); - if (taxon is null) + var taxon = request.TaxonId is null ? null : await db.PlantTaxa.FindAsync(request.TaxonId); + if (request.TaxonId is not null && taxon is null) { return Results.BadRequest(new { error = "Taxon was not found." }); } + var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId); + if (request.LocationId is not null && location is null) + { + return Results.BadRequest(new { error = "Location was not found." }); + } + var plant = new Plant { Nickname = request.Nickname.Trim(), - Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(), - TaxonId = request.TaxonId + TaxonId = request.TaxonId, + LocationId = request.LocationId }; db.Plants.Add(plant); await db.SaveChangesAsync(); plant.Taxon = taxon; - var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db); + plant.Location = location; + var scheduleError = request.CareSchedules is null + ? null + : await ApplyCareSchedules(plant, request.CareSchedules, db); if (scheduleError is not null) { return Results.BadRequest(new { error = scheduleError }); @@ -83,8 +112,18 @@ namespace plant_manager.Endpoints var plant = await db.Plants .Include(item => item.Taxon) + .Include(item => item.Location) .Include(item => item.CareSchedules) .ThenInclude(schedule => schedule.CareAction) + .Include(item => item.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(item => item.CareSchedules) + .ThenInclude(schedule => schedule.CareActivity) + .ThenInclude(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) .Include(item => item.ActionLogs) .Include(item => item.Flags) .ThenInclude(flag => flag.Definition) @@ -95,17 +134,26 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - var taxon = await db.PlantTaxa.FindAsync(request.TaxonId); - if (taxon is null) + var taxon = request.TaxonId is null ? null : await db.PlantTaxa.FindAsync(request.TaxonId); + if (request.TaxonId is not null && taxon is null) { return Results.BadRequest(new { error = "Taxon was not found." }); } + var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId); + if (request.LocationId is not null && location is null) + { + return Results.BadRequest(new { error = "Location was not found." }); + } + plant.Nickname = request.Nickname.Trim(); - plant.Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(); plant.TaxonId = request.TaxonId; + plant.LocationId = request.LocationId; plant.Taxon = taxon; - var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db); + plant.Location = location; + var scheduleError = request.CareSchedules is null + ? null + : await ApplyCareSchedules(plant, request.CareSchedules, db); if (scheduleError is not null) { return Results.BadRequest(new { error = scheduleError }); @@ -139,9 +187,9 @@ namespace plant_manager.Endpoints var schedules = requestedSchedules?.ToList(); if (schedules is null) { - var waterAction = await db.CareActions - .FirstOrDefaultAsync(action => action.Name.ToLower() == "water"); - if (waterAction is null) + var waterActivity = await db.CareActivities + .FirstOrDefaultAsync(activity => activity.Name.ToLower() == "water"); + if (waterActivity is null) { return null; } @@ -149,50 +197,71 @@ namespace plant_manager.Endpoints schedules = [ new SavePlantCareScheduleRequest( - waterAction.Id, + waterActivity.Id, 7, true) ]; } var normalizedSchedules = schedules - .GroupBy(schedule => schedule.CareActionId) + .GroupBy(schedule => schedule.CareActivityId) .Select(group => group.First()) - .Where(schedule => schedule.CareActionId > 0) + .Where(schedule => schedule.CareActivityId > 0) .ToList(); - var actionIds = normalizedSchedules - .Select(schedule => schedule.CareActionId) + var activityIds = normalizedSchedules + .Select(schedule => schedule.CareActivityId) .ToList(); - var actionsById = await db.CareActions - .Where(action => actionIds.Contains(action.Id)) - .ToDictionaryAsync(action => action.Id); + var activitiesById = await db.CareActivities + .Include(activity => activity.Actions) + .ThenInclude(action => action.CareAction) + .Include(activity => activity.Actions) + .ThenInclude(action => action.Resources) + .ThenInclude(resource => resource.ActionResource) + .Where(activity => activityIds.Contains(activity.Id)) + .ToDictionaryAsync(activity => activity.Id); - if (actionsById.Count != actionIds.Count) + if (activitiesById.Count != activityIds.Count) { - return "One or more care actions were not found."; + return "One or more care activities were not found."; } - var requestedActionIds = actionIds.ToHashSet(); + if (activitiesById.Values.Any(activity => + !activity.IsEnabled + || activity.PrimaryAction() is null + || activity.Actions.Any(action => !action.CareAction.IsEnabled))) + { + return "Disabled activities cannot be scheduled."; + } + + var requestedActivityIds = activityIds.ToHashSet(); var schedulesToRemove = plant.CareSchedules - .Where(schedule => !requestedActionIds.Contains(schedule.CareActionId)) + .Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId)) .ToList(); db.PlantCareSchedules.RemoveRange(schedulesToRemove); foreach (var requestedSchedule in normalizedSchedules) { + var activity = activitiesById[requestedSchedule.CareActivityId]; + var primaryAction = activity.PrimaryAction()!; var schedule = plant.CareSchedules - .FirstOrDefault(item => item.CareActionId == requestedSchedule.CareActionId); + .FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId); if (schedule is null) { schedule = new PlantCareSchedule { PlantId = plant.Id, - CareActionId = requestedSchedule.CareActionId, - CareAction = actionsById[requestedSchedule.CareActionId] + CareActionId = primaryAction.Id, + CareActivityId = activity.Id, + CareAction = primaryAction, + CareActivity = activity }; plant.CareSchedules.Add(schedule); } + schedule.CareActionId = primaryAction.Id; + schedule.CareActivityId = activity.Id; + schedule.CareAction = primaryAction; + schedule.CareActivity = activity; schedule.EveryDays = Math.Clamp(requestedSchedule.EveryDays ?? 7, 1, 365); schedule.IsEnabled = requestedSchedule.IsEnabled; } diff --git a/plant-manager/Endpoints/PlantFlagEndpoints.cs b/plant-manager/Endpoints/PlantFlagEndpoints.cs index 0d94d31..304b892 100644 --- a/plant-manager/Endpoints/PlantFlagEndpoints.cs +++ b/plant-manager/Endpoints/PlantFlagEndpoints.cs @@ -6,20 +6,12 @@ namespace plant_manager.Endpoints { public static class PlantFlagEndpoints { - private static readonly HashSet ValidSeverities = new(StringComparer.OrdinalIgnoreCase) - { - "low", - "medium", - "high" - }; - public static void MapPlantFlagEndpoints(this WebApplication app) { app.MapGet("/api/plant-flags", async (ApplicationDbContext db) => { var definitions = await db.PlantFlagDefinitions .OrderByDescending(definition => definition.IsEnabled) - .ThenBy(definition => definition.Category) .ThenBy(definition => definition.Name) .Select(definition => PlantFlagDefinitionDto.FromDefinition(definition)) .ToListAsync(); @@ -45,7 +37,6 @@ namespace plant_manager.Endpoints var definition = new PlantFlagDefinition { Name = name, - Category = NormalizeCategory(request.Category), Color = NormalizeColor(request.Color), IsEnabled = request.IsEnabled }; @@ -78,7 +69,6 @@ namespace plant_manager.Endpoints } definition.Name = name; - definition.Category = NormalizeCategory(request.Category); definition.Color = NormalizeColor(request.Color); definition.IsEnabled = request.IsEnabled; @@ -140,7 +130,6 @@ namespace plant_manager.Endpoints PlantId = plantId, PlantFlagDefinitionId = request.PlantFlagDefinitionId, Definition = definition, - Severity = NormalizeSeverity(request.Severity), StartedOn = request.StartedOn ?? DateOnly.FromDateTime(DateTime.UtcNow), Notes = NormalizeNotes(request.Notes) }; @@ -165,7 +154,6 @@ namespace plant_manager.Endpoints return Results.NotFound(); } - flag.Severity = NormalizeSeverity(request.Severity); flag.StartedOn = request.StartedOn ?? flag.StartedOn; flag.ResolvedOn = request.ResolvedOn; flag.Notes = NormalizeNotes(request.Notes); @@ -213,23 +201,9 @@ namespace plant_manager.Endpoints }); } - private static string NormalizeCategory(string? category) => - string.IsNullOrWhiteSpace(category) ? "General" : category.Trim(); - private static string NormalizeColor(string? color) => string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim(); - private static string NormalizeSeverity(string? severity) - { - if (string.IsNullOrWhiteSpace(severity)) - { - return "medium"; - } - - var normalized = severity.Trim().ToLower(); - return ValidSeverities.Contains(normalized) ? normalized : "medium"; - } - private static string? NormalizeNotes(string? notes) => string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(); } diff --git a/plant-manager/Endpoints/PlantLocationEndpoints.cs b/plant-manager/Endpoints/PlantLocationEndpoints.cs new file mode 100644 index 0000000..eaef1e3 --- /dev/null +++ b/plant-manager/Endpoints/PlantLocationEndpoints.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using plant_manager.Data; +using plant_manager.Data.Models; + +namespace plant_manager.Endpoints +{ + public static class PlantLocationEndpoints + { + public static void MapPlantLocationEndpoints(this WebApplication app) + { + app.MapGet("/api/plant-locations", async (ApplicationDbContext db) => + { + var locations = await db.PlantLocations + .OrderByDescending(location => location.IsEnabled) + .ThenBy(location => location.Name) + .Select(location => PlantLocationDto.FromLocation(location)) + .ToListAsync(); + + return Results.Ok(locations); + }); + + app.MapPost("/api/plant-locations", async (SavePlantLocationRequest request, ApplicationDbContext db) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "Location name is required." }); + } + + var name = request.Name.Trim(); + var exists = await db.PlantLocations.AnyAsync(location => location.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "A location with this name already exists." }); + } + + var location = new PlantLocation + { + Name = name, + Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), + IsEnabled = request.IsEnabled + }; + + db.PlantLocations.Add(location); + await db.SaveChangesAsync(); + + return Results.Created($"/api/plant-locations/{location.Id}", PlantLocationDto.FromLocation(location)); + }); + + app.MapPut("/api/plant-locations/{id:int}", async (int id, SavePlantLocationRequest request, ApplicationDbContext db) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "Location name is required." }); + } + + var location = await db.PlantLocations.FindAsync(id); + if (location is null) + { + return Results.NotFound(); + } + + var name = request.Name.Trim(); + var exists = await db.PlantLocations.AnyAsync(item => + item.Id != id && item.Name.ToLower() == name.ToLower()); + if (exists) + { + return Results.Conflict(new { error = "A location with this name already exists." }); + } + + location.Name = name; + location.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(); + location.IsEnabled = request.IsEnabled; + + await db.SaveChangesAsync(); + + return Results.Ok(PlantLocationDto.FromLocation(location)); + }); + + app.MapDelete("/api/plant-locations/{id:int}", async (int id, ApplicationDbContext db) => + { + var location = await db.PlantLocations.FindAsync(id); + if (location is null) + { + return Results.NotFound(); + } + + var isInUse = await db.Plants.AnyAsync(plant => plant.LocationId == id); + if (isInUse) + { + location.IsEnabled = false; + await db.SaveChangesAsync(); + return Results.Conflict(new { error = "Location is in use, so it was disabled instead of deleted." }); + } + + db.PlantLocations.Remove(location); + await db.SaveChangesAsync(); + + return Results.NoContent(); + }); + } + } +} diff --git a/plant-manager/Endpoints/RootEndpoints.cs b/plant-manager/Endpoints/RootEndpoints.cs index 75720fd..d94ff44 100644 --- a/plant-manager/Endpoints/RootEndpoints.cs +++ b/plant-manager/Endpoints/RootEndpoints.cs @@ -13,8 +13,10 @@ namespace plant_manager.Endpoints "/api/health", "/api/plants", "/api/plant-taxa", + "/api/plant-locations", "/api/care-actions", "/api/action-resources", + "/api/care-activities", "/api/care-tasks/upcoming", "/api/action-logs" } diff --git a/plant-manager/Program.cs b/plant-manager/Program.cs index 57657e3..ead0bbb 100644 --- a/plant-manager/Program.cs +++ b/plant-manager/Program.cs @@ -10,7 +10,7 @@ builder.Services.AddCors(options => { options.AddPolicy(frontendPolicy, policy => { - policy.WithOrigins("http://localhost:5173") + policy.WithOrigins("http://localhost:5173", "http://localhost:5174") .AllowAnyHeader() .AllowAnyMethod(); }); @@ -42,8 +42,11 @@ using (var scope = app.Services.CreateScope()) app.MapRootEndpoints(); app.MapPlantEndpoints(); app.MapPlantTaxonEndpoints(); +app.MapPlantLocationEndpoints(); app.MapCareActionEndpoints(); app.MapActionResourceEndpoints(); +app.MapCareActivityEndpoints(); +app.MapPlantCareScheduleEndpoints(); app.MapCareTaskEndpoints(); app.MapActionLogEndpoints(); app.MapPlantFlagEndpoints();