add plant flags, schedules, action logs
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "dotnet restore plant-manager.sln && cd plant-manager-web && npm install",
|
||||
"postCreateCommand": "dotnet restore plant-manager.sln && dotnet tool restore && cd plant-manager-web && npm install",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
@@ -36,6 +36,7 @@
|
||||
"files.eol": "\n"
|
||||
},
|
||||
"extensions": [
|
||||
"openai.chatgpt",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-dotnettools.csdevkit",
|
||||
|
||||
@@ -43,6 +43,7 @@ The container runs dependency setup automatically:
|
||||
|
||||
```bash
|
||||
dotnet restore plant-manager.sln
|
||||
dotnet tool restore
|
||||
cd plant-manager-web && npm install
|
||||
```
|
||||
|
||||
@@ -81,12 +82,14 @@ SQLite is configured in `plant-manager/appsettings.json`:
|
||||
"DefaultConnection": "Data Source=App_Data/plant-man.db"
|
||||
```
|
||||
|
||||
On startup, the API creates the database with `EnsureCreated()` and seeds a few starter plants. This keeps the project easy to test while the model is still young.
|
||||
On startup, the API applies EF Core migrations with `Migrate()` and seeds a few starter plants. This keeps the schema versioned while the project is still easy to test.
|
||||
|
||||
The default starter taxons are tracked in [docs/starter-taxa.md](docs/starter-taxa.md).
|
||||
|
||||
Local SQLite files are ignored by Git.
|
||||
|
||||
If you have a local database created before migrations were added, delete `plant-manager/App_Data/plant-man.db` and restart the API to recreate it from migrations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
@@ -119,14 +122,24 @@ plant-manager-web/ React + Vite frontend
|
||||
- `POST /api/action-resources`
|
||||
- `PUT /api/action-resources/{id}`
|
||||
- `DELETE /api/action-resources/{id}`
|
||||
- `GET /api/care-tasks/today`
|
||||
- `GET /api/care-tasks/upcoming`
|
||||
- `GET /api/action-logs`
|
||||
- `POST /api/action-logs`
|
||||
- `PUT /api/action-logs/{id}`
|
||||
- `DELETE /api/action-logs/{id}`
|
||||
|
||||
Example action log request:
|
||||
|
||||
```json
|
||||
{
|
||||
"plantId": 1,
|
||||
"action": "Water"
|
||||
"careActionId": 1,
|
||||
"resources": [
|
||||
{
|
||||
"actionResourceId": 1,
|
||||
"quantity": 250,
|
||||
"unit": "ml"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
+14
-4
@@ -26,8 +26,18 @@ A plant owned or tracked by the user.
|
||||
| `taxon_id` | `int` | Yes | Foreign key to `PlantTaxon` |
|
||||
| `nickname` | `string` | Yes | User-facing plant name |
|
||||
| `location` | `string` | Yes | Where the plant lives |
|
||||
| `last_watered_on` | `date` | No | Last watering date |
|
||||
| `water_every_days` | `int` | Yes | Simple watering interval |
|
||||
|
||||
## `PlantCareSchedule`
|
||||
|
||||
A per-plant recurring schedule for one care action.
|
||||
|
||||
| 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` |
|
||||
| `every_days` | `int` | Yes | Recurrence interval |
|
||||
| `is_enabled` | `bool` | Yes | Whether this schedule contributes to care tasks |
|
||||
|
||||
## `ActionLog`
|
||||
|
||||
@@ -37,7 +47,8 @@ A record of care performed for a plant.
|
||||
| - | - | - | - |
|
||||
| `id` | `int` | Yes | Primary key |
|
||||
| `plant_id` | `int` | Yes | Foreign key to `Plant` |
|
||||
| `action` | `string` | Yes | Care action, such as `Water` |
|
||||
| `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 |
|
||||
| `notes` | `string` | No | Optional observation |
|
||||
| `performed_on` | `date` | Yes | Date care was completed |
|
||||
|
||||
@@ -82,5 +93,4 @@ These concepts are still promising, but they are intentionally out of the first
|
||||
- user accounts
|
||||
- groups or rooms
|
||||
- grow media and blends
|
||||
- recurring schedules beyond a simple watering interval
|
||||
- richer taxon profiles
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.7",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+532
-166
@@ -1,41 +1,53 @@
|
||||
import { BookOpen, Home, ListChecks, Package, Search, Sprout } from 'lucide-react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createActionResource,
|
||||
createCareAction,
|
||||
createPlant,
|
||||
assignPlantFlag,
|
||||
createPlantFlag,
|
||||
createPlantTaxon,
|
||||
deleteActionResource,
|
||||
deleteCareAction,
|
||||
deletePlant,
|
||||
deletePlantFlag,
|
||||
deletePlantTaxon,
|
||||
getActionLogs,
|
||||
getActionResources,
|
||||
getCareActions,
|
||||
getCareTasks,
|
||||
getPlants,
|
||||
getPlantFlags,
|
||||
getPlantTaxa,
|
||||
logCare,
|
||||
removePlantFlagAssignment,
|
||||
resolvePlantFlag,
|
||||
updateActionLog,
|
||||
updateActionResource,
|
||||
updateCareAction,
|
||||
updatePlant,
|
||||
updatePlantFlag,
|
||||
updatePlantTaxon,
|
||||
} from './api';
|
||||
import { ActionsView } from './components/ActionsView';
|
||||
import { FlagsView } from './components/FlagsView';
|
||||
import { HomeView } from './components/HomeView';
|
||||
import { PlantsView } from './components/PlantsView';
|
||||
import { ResourcesView } from './components/ResourcesView';
|
||||
import { TaxaView } from './components/TaxaView';
|
||||
import type { ActionLog, ActionResource, CareAction, CareTask, Plant, PlantTaxon } from './domain';
|
||||
import type { ActionResource, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantTaxon } from './domain';
|
||||
import {
|
||||
emptyActionForm,
|
||||
emptyCareLogForm,
|
||||
emptyFlagDefinitionForm,
|
||||
emptyPlantForm,
|
||||
emptyPlantFlagForm,
|
||||
emptyResourceForm,
|
||||
emptyTaxonForm,
|
||||
formatTaxon,
|
||||
toActionForm,
|
||||
toActionPayload,
|
||||
toFlagDefinitionForm,
|
||||
toFlagDefinitionPayload,
|
||||
toPlantFlagPayload,
|
||||
toPlantForm,
|
||||
toPlantPayload,
|
||||
toResourceForm,
|
||||
@@ -43,7 +55,11 @@ import {
|
||||
toTaxonForm,
|
||||
toTaxonPayload,
|
||||
type ActionFormState,
|
||||
type CareLogResourceFormState,
|
||||
type CareLogFormState,
|
||||
type FlagDefinitionFormState,
|
||||
type PlantCareScheduleFormState,
|
||||
type PlantFlagFormState,
|
||||
type PlantFormState,
|
||||
type ResourceFormState,
|
||||
type TaxonFormState,
|
||||
@@ -56,7 +72,7 @@ export function App() {
|
||||
const [plantTaxa, setPlantTaxa] = useState<PlantTaxon[]>([]);
|
||||
const [careActions, setCareActions] = useState<CareAction[]>([]);
|
||||
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
|
||||
const [actionLogs, setActionLogs] = useState<ActionLog[]>([]);
|
||||
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
|
||||
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -64,12 +80,23 @@ export function App() {
|
||||
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
|
||||
const [editingActionId, setEditingActionId] = useState<number | null>(null);
|
||||
const [editingResourceId, setEditingResourceId] = useState<number | null>(null);
|
||||
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
|
||||
const [editingActionLogId, setEditingActionLogId] = useState<number | null>(null);
|
||||
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
|
||||
const [isCareLogEditorOpen, setIsCareLogEditorOpen] = useState(false);
|
||||
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
|
||||
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
|
||||
const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false);
|
||||
const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false);
|
||||
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
|
||||
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
|
||||
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
|
||||
const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm);
|
||||
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
|
||||
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
|
||||
const [careLogForm, setCareLogForm] = useState<CareLogFormState>(emptyCareLogForm);
|
||||
const [plantTaxonSearch, setPlantTaxonSearch] = useState('');
|
||||
const [plantSearch, setPlantSearch] = useState('');
|
||||
const [quickTaxonForm, setQuickTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
|
||||
const [isCreatingPlantTaxon, setIsCreatingPlantTaxon] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -82,14 +109,14 @@ export function App() {
|
||||
taxaResponse,
|
||||
actionsResponse,
|
||||
resourcesResponse,
|
||||
logsResponse,
|
||||
flagsResponse,
|
||||
] = await Promise.all([
|
||||
getPlants(),
|
||||
getCareTasks(),
|
||||
getPlantTaxa(),
|
||||
getCareActions(),
|
||||
getActionResources(),
|
||||
getActionLogs(),
|
||||
getPlantFlags(),
|
||||
]);
|
||||
|
||||
setError(null);
|
||||
@@ -98,7 +125,7 @@ export function App() {
|
||||
setPlantTaxa(taxaResponse);
|
||||
setCareActions(actionsResponse);
|
||||
setActionResources(resourcesResponse);
|
||||
setActionLogs(logsResponse);
|
||||
setPlantFlagDefinitions(flagsResponse);
|
||||
} catch {
|
||||
setError('Could not reach the Plant-Man API. Start the backend and refresh.');
|
||||
} finally {
|
||||
@@ -107,20 +134,40 @@ export function App() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboard();
|
||||
queueMicrotask(() => {
|
||||
void loadDashboard();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const dueCount = useMemo(
|
||||
() => careTasks.filter((task) => task.status === 'due').length,
|
||||
[careTasks],
|
||||
);
|
||||
const filteredPlants = useMemo(
|
||||
() => filterPlants(plants, plantSearch),
|
||||
[plants, plantSearch],
|
||||
);
|
||||
const filteredPlantIds = useMemo(
|
||||
() => new Set(filteredPlants.map((plant) => plant.id)),
|
||||
[filteredPlants],
|
||||
);
|
||||
const filteredCareTasks = useMemo(
|
||||
() => filterCareTasks(careTasks, filteredPlantIds, plantSearch),
|
||||
[careTasks, filteredPlantIds, plantSearch],
|
||||
);
|
||||
const visibleDueCount = useMemo(
|
||||
() => filteredCareTasks.filter((task) => task.status === 'due').length,
|
||||
[filteredCareTasks],
|
||||
);
|
||||
|
||||
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 activeAction = careActions.find((action) => action.id === editingActionId);
|
||||
const activeResource = actionResources.find((resource) => resource.id === editingResourceId);
|
||||
const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId);
|
||||
|
||||
function updateForm(field: keyof PlantFormState, value: string) {
|
||||
function updateForm(field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
@@ -136,7 +183,18 @@ export function App() {
|
||||
setResourceForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateCareLogForm(field: keyof CareLogFormState, value: string) {
|
||||
function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string | boolean) {
|
||||
setFlagDefinitionForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updatePlantFlagForm(field: keyof PlantFlagFormState, value: string) {
|
||||
setPlantFlagForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateCareLogForm(
|
||||
field: keyof CareLogFormState,
|
||||
value: string | CareLogResourceFormState[],
|
||||
) {
|
||||
setCareLogForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
@@ -146,26 +204,40 @@ export function App() {
|
||||
|
||||
function startAddingPlant() {
|
||||
setEditingPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setPlantTaxonSearch('');
|
||||
setForm({
|
||||
...emptyPlantForm,
|
||||
careSchedules: getDefaultPlantCareSchedules(careActions),
|
||||
});
|
||||
setQuickTaxonForm(emptyTaxonForm);
|
||||
setIsPlantEditorOpen(true);
|
||||
setIsCareLogEditorOpen(false);
|
||||
setView('plants');
|
||||
}
|
||||
|
||||
function openPlantDetail(plant: Plant) {
|
||||
setSelectedPlantId(plant.id);
|
||||
setView('plants');
|
||||
}
|
||||
|
||||
function closePlantDetail() {
|
||||
setSelectedPlantId(null);
|
||||
}
|
||||
|
||||
function startEditingPlant(plant: Plant) {
|
||||
const taxon = plantTaxa.find((item) => item.id === plant.taxonId);
|
||||
setSelectedPlantId(plant.id);
|
||||
setEditingPlantId(plant.id);
|
||||
setForm(toPlantForm(plant));
|
||||
setPlantTaxonSearch(taxon ? formatTaxon(taxon) : plant.taxon);
|
||||
setQuickTaxonForm(emptyTaxonForm);
|
||||
setIsPlantEditorOpen(true);
|
||||
setIsCareLogEditorOpen(false);
|
||||
setView('plants');
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setPlantTaxonSearch('');
|
||||
setQuickTaxonForm(emptyTaxonForm);
|
||||
setIsPlantEditorOpen(false);
|
||||
}
|
||||
|
||||
function selectPlantTaxon(taxon: PlantTaxon) {
|
||||
@@ -174,70 +246,110 @@ export function App() {
|
||||
nickname: current.nickname.trim() ? current.nickname : taxon.name,
|
||||
taxonId: String(taxon.id),
|
||||
}));
|
||||
setPlantTaxonSearch(formatTaxon(taxon));
|
||||
}
|
||||
|
||||
function startAddingTaxon() {
|
||||
setEditingTaxonId(null);
|
||||
setTaxonForm(emptyTaxonForm);
|
||||
setIsTaxonEditorOpen(true);
|
||||
setView('taxa');
|
||||
}
|
||||
|
||||
function startEditingTaxon(taxon: PlantTaxon) {
|
||||
setEditingTaxonId(taxon.id);
|
||||
setTaxonForm(toTaxonForm(taxon));
|
||||
setIsTaxonEditorOpen(true);
|
||||
setView('taxa');
|
||||
}
|
||||
|
||||
function cancelEditingTaxon() {
|
||||
setEditingTaxonId(null);
|
||||
setTaxonForm(emptyTaxonForm);
|
||||
setIsTaxonEditorOpen(false);
|
||||
}
|
||||
|
||||
function startAddingAction() {
|
||||
setEditingActionId(null);
|
||||
setActionForm(emptyActionForm);
|
||||
setIsActionEditorOpen(true);
|
||||
setView('actions');
|
||||
}
|
||||
|
||||
function startEditingAction(action: CareAction) {
|
||||
setEditingActionId(action.id);
|
||||
setActionForm(toActionForm(action));
|
||||
setIsActionEditorOpen(true);
|
||||
setView('actions');
|
||||
}
|
||||
|
||||
function cancelEditingAction() {
|
||||
setEditingActionId(null);
|
||||
setActionForm(emptyActionForm);
|
||||
setIsActionEditorOpen(false);
|
||||
}
|
||||
|
||||
function startAddingResource() {
|
||||
setEditingResourceId(null);
|
||||
setResourceForm(emptyResourceForm);
|
||||
setIsResourceEditorOpen(true);
|
||||
setView('resources');
|
||||
}
|
||||
|
||||
function startEditingResource(resource: ActionResource) {
|
||||
setEditingResourceId(resource.id);
|
||||
setResourceForm(toResourceForm(resource));
|
||||
setIsResourceEditorOpen(true);
|
||||
setView('resources');
|
||||
}
|
||||
|
||||
function cancelEditingResource() {
|
||||
setEditingResourceId(null);
|
||||
setResourceForm(emptyResourceForm);
|
||||
setIsResourceEditorOpen(false);
|
||||
}
|
||||
|
||||
function startAddingFlagDefinition() {
|
||||
setEditingFlagDefinitionId(null);
|
||||
setFlagDefinitionForm(emptyFlagDefinitionForm);
|
||||
setIsFlagDefinitionEditorOpen(true);
|
||||
setView('flags');
|
||||
}
|
||||
|
||||
function startEditingFlagDefinition(flag: PlantFlagDefinition) {
|
||||
setEditingFlagDefinitionId(flag.id);
|
||||
setFlagDefinitionForm(toFlagDefinitionForm(flag));
|
||||
setIsFlagDefinitionEditorOpen(true);
|
||||
setView('flags');
|
||||
}
|
||||
|
||||
function cancelEditingFlagDefinition() {
|
||||
setEditingFlagDefinitionId(null);
|
||||
setFlagDefinitionForm(emptyFlagDefinitionForm);
|
||||
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,
|
||||
action: current.action || enabledAction?.name || 'Water',
|
||||
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.');
|
||||
@@ -289,6 +401,9 @@ export function App() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await deletePlant(plant.id);
|
||||
if (selectedPlantId === plant.id) {
|
||||
setSelectedPlantId(null);
|
||||
}
|
||||
if (editingPlantId === plant.id) {
|
||||
cancelEditing();
|
||||
}
|
||||
@@ -430,31 +545,151 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function completeTask(task: CareTask) {
|
||||
await logCare({
|
||||
plantId: task.plantId,
|
||||
action: task.action,
|
||||
notes: null,
|
||||
performedOn: null,
|
||||
async function saveFlagDefinition() {
|
||||
if (!flagDefinitionForm.name.trim()) {
|
||||
setError('Flag name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = toFlagDefinitionPayload(flagDefinitionForm);
|
||||
if (editingFlagDefinitionId === null) {
|
||||
await createPlantFlag(payload);
|
||||
} else {
|
||||
await updatePlantFlag(editingFlagDefinitionId, payload);
|
||||
}
|
||||
cancelEditingFlagDefinition();
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
setError('Could not save the plant flag.');
|
||||
} 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await deletePlantFlag(flag.id);
|
||||
if (editingFlagDefinitionId === flag.id) {
|
||||
cancelEditingFlagDefinition();
|
||||
}
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
await loadDashboard();
|
||||
setError('Could not delete the flag. If it is assigned to plants, it was disabled instead.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignFlagToSelectedPlant() {
|
||||
if (!selectedPlantId || !plantFlagForm.plantFlagDefinitionId) {
|
||||
setError('Select a plant and flag first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await assignPlantFlag(selectedPlantId, toPlantFlagPayload(plantFlagForm));
|
||||
setPlantFlagForm(emptyPlantFlagForm);
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
setError('Could not assign the plant flag.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAssignedPlantFlag(flag: PlantFlag) {
|
||||
if (!selectedPlantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await resolvePlantFlag(selectedPlantId, flag.id);
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
setError('Could not resolve the plant flag.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAssignedPlantFlag(flag: PlantFlag) {
|
||||
if (!selectedPlantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`Remove ${flag.name} from this plant?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await removePlantFlagAssignment(selectedPlantId, flag.id);
|
||||
await loadDashboard();
|
||||
} catch {
|
||||
setError('Could not remove the plant flag.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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: [],
|
||||
});
|
||||
await loadDashboard();
|
||||
setIsCareLogEditorOpen(true);
|
||||
setIsPlantEditorOpen(false);
|
||||
if (plant && !plantMatchesSearch(plant, plantSearch)) {
|
||||
setPlantSearch('');
|
||||
}
|
||||
setView('plants');
|
||||
}
|
||||
|
||||
async function saveCareLog() {
|
||||
if (!careLogForm.plantId || !careLogForm.action.trim()) {
|
||||
if (!careLogForm.plantId || !careLogForm.careActionId) {
|
||||
setError('Plant and action are required to log care.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await logCare({
|
||||
const payload = {
|
||||
plantId: Number(careLogForm.plantId),
|
||||
action: careLogForm.action.trim(),
|
||||
careActionId: Number(careLogForm.careActionId),
|
||||
notes: careLogForm.notes.trim() || null,
|
||||
performedOn: careLogForm.performedOn || null,
|
||||
});
|
||||
setCareLogForm(emptyCareLogForm);
|
||||
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 loadDashboard();
|
||||
} catch {
|
||||
setError('Could not log care.');
|
||||
@@ -465,146 +700,215 @@ export function App() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
<div>
|
||||
<p className="eyebrow">{getViewEyebrow(view)}</p>
|
||||
<h1>{getViewTitle(view)}</h1>
|
||||
<header className="catalog-header">
|
||||
<div className="catalog-brand">
|
||||
<span className="catalog-logo">Plant-Man</span>
|
||||
<span className="catalog-subtitle">Plant Care Supply</span>
|
||||
</div>
|
||||
<button className="icon-button" type="button" aria-label="Search plants">
|
||||
<label className="search-field">
|
||||
<Search size={20} />
|
||||
</button>
|
||||
<span className="sr-only">Search plants</span>
|
||||
<input
|
||||
value={plantSearch}
|
||||
type="search"
|
||||
placeholder="Search plants"
|
||||
onChange={(event) => setPlantSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="catalog-contact">
|
||||
<strong>{plants.length}</strong>
|
||||
<span>plants tracked</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content">
|
||||
{view === 'plants' ? (
|
||||
<PlantsView
|
||||
activePlantName={activePlant?.nickname}
|
||||
actionLogs={actionLogs}
|
||||
careActions={careActions}
|
||||
careLogForm={careLogForm}
|
||||
error={error}
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
plantTaxa={plantTaxa}
|
||||
plants={plants}
|
||||
onCancel={cancelEditing}
|
||||
onDelete={(plant) => void removePlant(plant)}
|
||||
onEdit={startEditingPlant}
|
||||
onLogCare={() => void saveCareLog()}
|
||||
onLogCareFieldChange={updateCareLogForm}
|
||||
onFieldChange={updateForm}
|
||||
onNew={startAddingPlant}
|
||||
onQuickTaxonFieldChange={updateQuickTaxonForm}
|
||||
onSave={() => void savePlant()}
|
||||
onSaveQuickTaxon={() => void createAndSelectPlantTaxon()}
|
||||
onSelectTaxon={selectPlantTaxon}
|
||||
onStartLogCare={startLoggingCare}
|
||||
onTaxonSearchChange={setPlantTaxonSearch}
|
||||
quickTaxonForm={quickTaxonForm}
|
||||
taxonSearch={plantTaxonSearch}
|
||||
isCreatingTaxon={isCreatingPlantTaxon}
|
||||
/>
|
||||
) : view === 'taxa' ? (
|
||||
<TaxaView
|
||||
activeTaxonName={activeTaxon?.name}
|
||||
error={error}
|
||||
form={taxonForm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
taxa={plantTaxa}
|
||||
onCancel={cancelEditingTaxon}
|
||||
onDelete={(taxon) => void removeTaxon(taxon)}
|
||||
onEdit={startEditingTaxon}
|
||||
onFieldChange={updateTaxonForm}
|
||||
onNew={startAddingTaxon}
|
||||
onSave={() => void saveTaxon()}
|
||||
/>
|
||||
) : view === 'actions' ? (
|
||||
<ActionsView
|
||||
activeActionName={activeAction?.name}
|
||||
actions={careActions}
|
||||
error={error}
|
||||
form={actionForm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
onCancel={cancelEditingAction}
|
||||
onDelete={(action) => void removeAction(action)}
|
||||
onEdit={startEditingAction}
|
||||
onFieldChange={updateActionForm}
|
||||
onNew={startAddingAction}
|
||||
onSave={() => void saveAction()}
|
||||
/>
|
||||
) : view === 'resources' ? (
|
||||
<ResourcesView
|
||||
activeResourceName={activeResource?.name}
|
||||
error={error}
|
||||
form={resourceForm}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
onCancel={cancelEditingResource}
|
||||
onDelete={(resource) => void removeResource(resource)}
|
||||
onEdit={startEditingResource}
|
||||
onFieldChange={updateResourceForm}
|
||||
onNew={startAddingResource}
|
||||
onSave={() => void saveResource()}
|
||||
resources={actionResources}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
careTasks={careTasks}
|
||||
dueCount={dueCount}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
plants={plants}
|
||||
onCompleteTask={(task) => void completeTask(task)}
|
||||
onNewPlant={startAddingPlant}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<div className="catalog-layout">
|
||||
<aside className="catalog-sidebar">
|
||||
<h2>Choose a Category</h2>
|
||||
<nav className="catalog-nav" aria-label="Primary navigation">
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'home' ? 'page' : undefined}
|
||||
onClick={() => setView('home')}
|
||||
>
|
||||
Care Queue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'plants' ? 'page' : undefined}
|
||||
onClick={() => setView('plants')}
|
||||
>
|
||||
Plants
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'taxa' ? 'page' : undefined}
|
||||
onClick={() => setView('taxa')}
|
||||
>
|
||||
Plant Taxa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'actions' ? 'page' : undefined}
|
||||
onClick={() => setView('actions')}
|
||||
>
|
||||
Care Actions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'resources' ? 'page' : undefined}
|
||||
onClick={() => setView('resources')}
|
||||
>
|
||||
Resources
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'flags' ? 'page' : undefined}
|
||||
onClick={() => setView('flags')}
|
||||
>
|
||||
Plant Flags
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav className="bottom-nav" aria-label="Primary navigation">
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'home' ? 'page' : undefined}
|
||||
onClick={() => setView('home')}
|
||||
>
|
||||
<Home size={19} />
|
||||
Home
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'plants' ? 'page' : undefined}
|
||||
onClick={() => setView('plants')}
|
||||
>
|
||||
<Sprout size={19} />
|
||||
Plants
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'taxa' ? 'page' : undefined}
|
||||
onClick={() => setView('taxa')}
|
||||
>
|
||||
<BookOpen size={19} />
|
||||
Taxa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'actions' ? 'page' : undefined}
|
||||
onClick={() => setView('actions')}
|
||||
>
|
||||
<ListChecks size={19} />
|
||||
Actions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'resources' ? 'page' : undefined}
|
||||
onClick={() => setView('resources')}
|
||||
>
|
||||
<Package size={19} />
|
||||
Resources
|
||||
</button>
|
||||
</nav>
|
||||
<section className="catalog-help" aria-label="Catalog status">
|
||||
<h3>Catalog Status</h3>
|
||||
<p>{dueCount} due</p>
|
||||
<p>{careActions.filter((action) => action.isEnabled).length} active actions</p>
|
||||
<p>{actionResources.filter((resource) => resource.isEnabled).length} active resources</p>
|
||||
<p>{plantFlagDefinitions.filter((flag) => flag.isEnabled).length} active flags</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="catalog-main">
|
||||
<div className="catalog-page-title">
|
||||
<p className="eyebrow">{getViewEyebrow(view)}</p>
|
||||
<h1>{getViewTitle(view)}</h1>
|
||||
</div>
|
||||
|
||||
<main className="content">
|
||||
{view === 'plants' ? (
|
||||
<PlantsView
|
||||
activePlantName={activePlant?.nickname}
|
||||
actionResources={actionResources}
|
||||
careActions={careActions}
|
||||
careTasks={filteredCareTasks}
|
||||
careLogForm={careLogForm}
|
||||
editingActionLogId={editingActionLogId}
|
||||
error={error}
|
||||
form={form}
|
||||
isCareLogEditorOpen={isCareLogEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isPlantEditorOpen={isPlantEditorOpen}
|
||||
isSaving={isSaving}
|
||||
plantFlagDefinitions={plantFlagDefinitions}
|
||||
plantFlagForm={plantFlagForm}
|
||||
plantTaxa={plantTaxa}
|
||||
plants={plants}
|
||||
selectedPlant={selectedPlant}
|
||||
visiblePlants={filteredPlants}
|
||||
onCancel={cancelEditing}
|
||||
onCancelCareLog={cancelCareLog}
|
||||
onClosePlantDetail={closePlantDetail}
|
||||
onDelete={(plant) => void removePlant(plant)}
|
||||
onEdit={startEditingPlant}
|
||||
onCompleteTask={completeTask}
|
||||
onLogCare={() => void saveCareLog()}
|
||||
onLogCareFieldChange={updateCareLogForm}
|
||||
onFieldChange={updateForm}
|
||||
onNew={startAddingPlant}
|
||||
onOpenDetail={openPlantDetail}
|
||||
onQuickTaxonFieldChange={updateQuickTaxonForm}
|
||||
onPlantFlagFieldChange={updatePlantFlagForm}
|
||||
onAssignFlag={() => void assignFlagToSelectedPlant()}
|
||||
onResolveFlag={(flag) => void resolveAssignedPlantFlag(flag)}
|
||||
onRemoveFlag={(flag) => void removeAssignedPlantFlag(flag)}
|
||||
onSave={() => void savePlant()}
|
||||
onSaveQuickTaxon={() => void createAndSelectPlantTaxon()}
|
||||
onSelectTaxon={selectPlantTaxon}
|
||||
onStartLogCare={startLoggingCare}
|
||||
quickTaxonForm={quickTaxonForm}
|
||||
isCreatingTaxon={isCreatingPlantTaxon}
|
||||
/>
|
||||
) : view === 'taxa' ? (
|
||||
<TaxaView
|
||||
activeTaxonName={activeTaxon?.name}
|
||||
error={error}
|
||||
form={taxonForm}
|
||||
isEditorOpen={isTaxonEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
taxa={plantTaxa}
|
||||
onCancel={cancelEditingTaxon}
|
||||
onDelete={(taxon) => void removeTaxon(taxon)}
|
||||
onEdit={startEditingTaxon}
|
||||
onFieldChange={updateTaxonForm}
|
||||
onNew={startAddingTaxon}
|
||||
onSave={() => void saveTaxon()}
|
||||
/>
|
||||
) : view === 'actions' ? (
|
||||
<ActionsView
|
||||
activeActionName={activeAction?.name}
|
||||
actions={careActions}
|
||||
error={error}
|
||||
form={actionForm}
|
||||
isEditorOpen={isActionEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
onCancel={cancelEditingAction}
|
||||
onDelete={(action) => void removeAction(action)}
|
||||
onEdit={startEditingAction}
|
||||
onFieldChange={updateActionForm}
|
||||
onNew={startAddingAction}
|
||||
onSave={() => void saveAction()}
|
||||
/>
|
||||
) : view === 'resources' ? (
|
||||
<ResourcesView
|
||||
activeResourceName={activeResource?.name}
|
||||
error={error}
|
||||
form={resourceForm}
|
||||
isEditorOpen={isResourceEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
onCancel={cancelEditingResource}
|
||||
onDelete={(resource) => void removeResource(resource)}
|
||||
onEdit={startEditingResource}
|
||||
onFieldChange={updateResourceForm}
|
||||
onNew={startAddingResource}
|
||||
onSave={() => void saveResource()}
|
||||
resources={actionResources}
|
||||
/>
|
||||
) : view === 'flags' ? (
|
||||
<FlagsView
|
||||
activeFlagName={activeFlagDefinition?.name}
|
||||
error={error}
|
||||
flags={plantFlagDefinitions}
|
||||
form={flagDefinitionForm}
|
||||
isEditorOpen={isFlagDefinitionEditorOpen}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
onCancel={cancelEditingFlagDefinition}
|
||||
onDelete={(flag) => void removeFlagDefinition(flag)}
|
||||
onEdit={startEditingFlagDefinition}
|
||||
onFieldChange={updateFlagDefinitionForm}
|
||||
onNew={startAddingFlagDefinition}
|
||||
onSave={() => void saveFlagDefinition()}
|
||||
/>
|
||||
) : (
|
||||
<HomeView
|
||||
careTasks={filteredCareTasks}
|
||||
dueCount={plantSearch.trim() ? visibleDueCount : dueCount}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isPlantSearchActive={Boolean(plantSearch.trim())}
|
||||
plants={filteredPlants}
|
||||
totalPlantCount={plants.length}
|
||||
onCompleteTask={completeTask}
|
||||
onNewPlant={startAddingPlant}
|
||||
onOpenPlant={openPlantDetail}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -617,6 +921,7 @@ function getViewEyebrow(view: View) {
|
||||
return 'Reference';
|
||||
case 'actions':
|
||||
case 'resources':
|
||||
case 'flags':
|
||||
return 'Care setup';
|
||||
default:
|
||||
return 'Today';
|
||||
@@ -633,7 +938,68 @@ function getViewTitle(view: View) {
|
||||
return 'Care Actions';
|
||||
case 'resources':
|
||||
return 'Resources';
|
||||
case 'flags':
|
||||
return 'Plant Flags';
|
||||
default:
|
||||
return 'Plant-Man';
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultPlantCareSchedules(careActions: CareAction[]): PlantCareScheduleFormState[] {
|
||||
const waterAction = careActions.find(
|
||||
(action) => action.isEnabled && action.name.toLowerCase() === 'water',
|
||||
);
|
||||
|
||||
return waterAction
|
||||
? [{
|
||||
careActionId: String(waterAction.id),
|
||||
everyDays: '7',
|
||||
isEnabled: true,
|
||||
}]
|
||||
: [];
|
||||
}
|
||||
|
||||
function filterPlants(plants: Plant[], search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return plants;
|
||||
}
|
||||
|
||||
return plants.filter((plant) => plantMatchesSearch(plant, normalizedSearch));
|
||||
}
|
||||
|
||||
function filterCareTasks(tasks: CareTask[], visiblePlantIds: Set<number>, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
return tasks.filter((task) => (
|
||||
visiblePlantIds.has(task.plantId)
|
||||
|| task.action.toLowerCase().includes(normalizedSearch)
|
||||
|| task.plantName.toLowerCase().includes(normalizedSearch)
|
||||
|| task.status.toLowerCase().includes(normalizedSearch)
|
||||
));
|
||||
}
|
||||
|
||||
function plantMatchesSearch(plant: Plant, search: string) {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
plant.nickname,
|
||||
plant.taxon,
|
||||
plant.location,
|
||||
plant.status,
|
||||
plant.nextCare,
|
||||
...plant.flags.map((flag) => flag.name),
|
||||
...plant.flags.map((flag) => flag.category),
|
||||
...plant.careSchedules.map((schedule) => schedule.action),
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
}
|
||||
|
||||
function getTodayInputDate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {
|
||||
CareLogPayload,
|
||||
CareTask,
|
||||
Plant,
|
||||
AssignPlantFlagPayload,
|
||||
PlantPayload,
|
||||
PlantFlag,
|
||||
PlantFlagDefinition,
|
||||
PlantFlagDefinitionPayload,
|
||||
PlantTaxon,
|
||||
PlantTaxonPayload,
|
||||
} from './domain';
|
||||
@@ -130,8 +134,51 @@ export async function deletePlant(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPlantFlags() {
|
||||
return request<PlantFlagDefinition[]>('/api/plant-flags');
|
||||
}
|
||||
|
||||
export async function createPlantFlag(payload: PlantFlagDefinitionPayload) {
|
||||
return request<PlantFlagDefinition>('/api/plant-flags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePlantFlag(id: number, payload: PlantFlagDefinitionPayload) {
|
||||
return request<PlantFlagDefinition>(`/api/plant-flags/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePlantFlag(id: number) {
|
||||
return request<void>(`/api/plant-flags/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignPlantFlag(plantId: number, payload: AssignPlantFlagPayload) {
|
||||
return request<PlantFlag>(`/api/plants/${plantId}/flags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolvePlantFlag(plantId: number, flagId: number) {
|
||||
return request<PlantFlag>(`/api/plants/${plantId}/flags/${flagId}/resolve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePlantFlagAssignment(plantId: number, flagId: number) {
|
||||
return request<void>(`/api/plants/${plantId}/flags/${flagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCareTasks() {
|
||||
return request<CareTask[]>('/api/care-tasks/today');
|
||||
return request<CareTask[]>('/api/care-tasks/upcoming');
|
||||
}
|
||||
|
||||
export async function getActionLogs() {
|
||||
@@ -144,3 +191,19 @@ export async function logCare(payload: CareLogPayload) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateActionLog(id: number, payload: CareLogPayload) {
|
||||
return request<ActionLog>(`/api/action-logs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
performedOn: payload.performedOn ?? new Date().toISOString().slice(0, 10),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteActionLog(id: number) {
|
||||
return request<void>(`/api/action-logs/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Edit3, ListChecks, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import type { CareAction } from '../domain';
|
||||
import type { ActionFormState } from '../form-state';
|
||||
|
||||
@@ -7,6 +7,7 @@ type ActionsViewProps = {
|
||||
actions: CareAction[];
|
||||
error: string | null;
|
||||
form: ActionFormState;
|
||||
isEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
@@ -22,6 +23,7 @@ export function ActionsView({
|
||||
actions,
|
||||
error,
|
||||
form,
|
||||
isEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
onCancel,
|
||||
@@ -49,6 +51,7 @@ export function ActionsView({
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="action-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -95,6 +98,7 @@ export function ActionsView({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="actions-list-heading">
|
||||
<div className="section-heading">
|
||||
@@ -108,9 +112,6 @@ export function ActionsView({
|
||||
|
||||
{actions.map((action) => (
|
||||
<article className="plant-row" key={action.id}>
|
||||
<div className="plant-mark">
|
||||
<ListChecks size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{action.name}</h3>
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import type { PlantFlagDefinition } from '../domain';
|
||||
import type { FlagDefinitionFormState } from '../form-state';
|
||||
|
||||
type FlagsViewProps = {
|
||||
activeFlagName?: string;
|
||||
error: string | null;
|
||||
flags: PlantFlagDefinition[];
|
||||
form: FlagDefinitionFormState;
|
||||
isEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onDelete: (flag: PlantFlagDefinition) => void;
|
||||
onEdit: (flag: PlantFlagDefinition) => void;
|
||||
onFieldChange: (field: keyof FlagDefinitionFormState, value: string | boolean) => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function FlagsView({
|
||||
activeFlagName,
|
||||
error,
|
||||
flags,
|
||||
form,
|
||||
isEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFieldChange,
|
||||
onNew,
|
||||
onSave,
|
||||
}: FlagsViewProps) {
|
||||
const enabledCount = flags.filter((flag) => flag.isEnabled).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="flags-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant flags</p>
|
||||
<h2 id="flags-summary-heading">
|
||||
{isLoading ? 'Loading flags' : `${enabledCount} flags enabled`}
|
||||
</h2>
|
||||
<p>{error ?? 'Configure reusable pest, condition, and workflow flags for plants.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New flag
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="flag-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeFlagName ? 'Editing' : 'New flag'}</p>
|
||||
<h2 id="flag-editor-heading">{activeFlagName ?? 'Flag details'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => onFieldChange('name', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(event) => onFieldChange('category', event.target.value)}
|
||||
>
|
||||
<option value="Pest">Pest</option>
|
||||
<option value="Disease">Disease</option>
|
||||
<option value="Condition">Condition</option>
|
||||
<option value="Workflow">Workflow</option>
|
||||
<option value="General">General</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Color
|
||||
<input
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(event) => onFieldChange('color', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="toggle-field">
|
||||
<input
|
||||
checked={form.isEnabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save flag'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="flags-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="flags-list-heading">All Flags</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && flags.length === 0 ? (
|
||||
<p className="empty-state">No flags yet.</p>
|
||||
) : null}
|
||||
|
||||
{flags.map((flag) => (
|
||||
<article className="plant-row" key={flag.id}>
|
||||
<div>
|
||||
<h3>{flag.name}</h3>
|
||||
<p>
|
||||
{flag.category}
|
||||
{' - '}
|
||||
{flag.isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${flag.name}`} onClick={() => onEdit(flag)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${flag.name}`} onClick={() => onDelete(flag)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,12 @@ type HomeViewProps = {
|
||||
dueCount: number;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
isPlantSearchActive: boolean;
|
||||
plants: Plant[];
|
||||
totalPlantCount: number;
|
||||
onCompleteTask: (task: CareTask) => void;
|
||||
onNewPlant: () => void;
|
||||
onOpenPlant: (plant: Plant) => void;
|
||||
};
|
||||
|
||||
export function HomeView({
|
||||
@@ -17,9 +20,12 @@ export function HomeView({
|
||||
dueCount,
|
||||
error,
|
||||
isLoading,
|
||||
isPlantSearchActive,
|
||||
plants,
|
||||
totalPlantCount,
|
||||
onCompleteTask,
|
||||
onNewPlant,
|
||||
onOpenPlant,
|
||||
}: HomeViewProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -45,7 +51,9 @@ export function HomeView({
|
||||
|
||||
<div className="task-list">
|
||||
{!isLoading && careTasks.length === 0 ? (
|
||||
<p className="empty-state">No care tasks yet.</p>
|
||||
<p className="empty-state">
|
||||
{isPlantSearchActive ? 'No care tasks match the search.' : 'No care tasks yet.'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{careTasks.map((task) => (
|
||||
@@ -61,7 +69,7 @@ export function HomeView({
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Done
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
@@ -77,12 +85,16 @@ export function HomeView({
|
||||
</div>
|
||||
|
||||
<div className="plant-grid">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
{!isLoading && totalPlantCount === 0 ? (
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && totalPlantCount > 0 && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants match the search.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
<PlantCard plant={plant} key={plant.id} />
|
||||
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,35 +1,61 @@
|
||||
import { Leaf } from 'lucide-react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import type { CareStatus, Plant } from '../domain';
|
||||
|
||||
const statusLabel: Record<CareStatus, string> = {
|
||||
due: 'Due',
|
||||
soon: 'Soon',
|
||||
ok: 'Ok',
|
||||
unscheduled: 'Unscheduled',
|
||||
};
|
||||
|
||||
export function PlantCard({ plant }: { plant: Plant }) {
|
||||
type PlantCardProps = {
|
||||
plant: Plant;
|
||||
onOpen?: (plant: Plant) => void;
|
||||
};
|
||||
|
||||
export function PlantCard({ plant, onOpen }: PlantCardProps) {
|
||||
return (
|
||||
<article className="plant-card">
|
||||
<div className="plant-card-top">
|
||||
<div className="plant-mark">
|
||||
<Leaf size={20} />
|
||||
</div>
|
||||
<span className={`status-pill ${plant.status}`}>
|
||||
{statusLabel[plant.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{plant.nickname}</h3>
|
||||
<p className="taxon">{plant.taxon}</p>
|
||||
<div className="taxon">
|
||||
<p>{plant.taxon}</p>
|
||||
{plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? (
|
||||
<div className="flag-list">
|
||||
{plant.flags
|
||||
.filter((flag) => flag.resolvedOn === null)
|
||||
.map((flag) => (
|
||||
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{plant.location}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last watered</dt>
|
||||
<dd>{plant.lastWatered}</dd>
|
||||
<dt>Next care</dt>
|
||||
<dd>{plant.nextCare}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="plant-card-actions">
|
||||
<span className={`status-pill ${plant.status}`}>
|
||||
{statusLabel[plant.status]}
|
||||
</span>
|
||||
{onOpen ? (
|
||||
<button
|
||||
className="icon-button compact"
|
||||
type="button"
|
||||
aria-label={`View ${plant.nickname}`}
|
||||
onClick={() => onOpen(plant)}
|
||||
>
|
||||
<Eye size={17} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +1,134 @@
|
||||
import { Check, Edit3, Leaf, Plus, Save, Trash2, X, ClipboardCheck } from 'lucide-react';
|
||||
import type { ActionLog, CareAction, Plant, PlantTaxon } from '../domain';
|
||||
import type { CareLogFormState, PlantFormState, TaxonFormState } from '../form-state';
|
||||
import {
|
||||
CalendarClock,
|
||||
ClipboardCheck,
|
||||
Edit3,
|
||||
Eye,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ActionResource,
|
||||
CareAction,
|
||||
CareStatus,
|
||||
CareTask,
|
||||
Plant,
|
||||
PlantFlag,
|
||||
PlantFlagDefinition,
|
||||
PlantTaxon,
|
||||
} from '../domain';
|
||||
import type {
|
||||
CareLogFormState,
|
||||
CareLogResourceFormState,
|
||||
PlantCareScheduleFormState,
|
||||
PlantFlagFormState,
|
||||
PlantFormState,
|
||||
TaxonFormState,
|
||||
} from '../form-state';
|
||||
import { formatTaxon } from '../form-state';
|
||||
|
||||
type PlantsViewProps = {
|
||||
activePlantName?: string;
|
||||
actionLogs: ActionLog[];
|
||||
actionResources: ActionResource[];
|
||||
careActions: CareAction[];
|
||||
careTasks: CareTask[];
|
||||
careLogForm: CareLogFormState;
|
||||
editingActionLogId: number | null;
|
||||
error: string | null;
|
||||
form: PlantFormState;
|
||||
isCareLogEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isPlantEditorOpen: boolean;
|
||||
isSaving: boolean;
|
||||
plantFlagDefinitions: PlantFlagDefinition[];
|
||||
plantFlagForm: PlantFlagFormState;
|
||||
plantTaxa: PlantTaxon[];
|
||||
plants: Plant[];
|
||||
selectedPlant?: Plant;
|
||||
visiblePlants: Plant[];
|
||||
onCancel: () => void;
|
||||
onCancelCareLog: () => void;
|
||||
onClosePlantDetail: () => void;
|
||||
onCompleteTask: (task: CareTask) => void;
|
||||
onDelete: (plant: Plant) => void;
|
||||
onEdit: (plant: Plant) => void;
|
||||
onFieldChange: (field: keyof PlantFormState, value: string) => void;
|
||||
onFieldChange: (field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) => void;
|
||||
onLogCare: () => void;
|
||||
onLogCareFieldChange: (field: keyof CareLogFormState, value: string) => void;
|
||||
onLogCareFieldChange: (
|
||||
field: keyof CareLogFormState,
|
||||
value: string | CareLogResourceFormState[],
|
||||
) => void;
|
||||
onNew: () => void;
|
||||
onOpenDetail: (plant: Plant) => void;
|
||||
onQuickTaxonFieldChange: (field: keyof TaxonFormState, value: string) => void;
|
||||
onPlantFlagFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
|
||||
onAssignFlag: () => void;
|
||||
onResolveFlag: (flag: PlantFlag) => void;
|
||||
onRemoveFlag: (flag: PlantFlag) => void;
|
||||
onSave: () => void;
|
||||
onSaveQuickTaxon: () => void;
|
||||
onSelectTaxon: (taxon: PlantTaxon) => void;
|
||||
onStartLogCare: (plant?: Plant) => void;
|
||||
onTaxonSearchChange: (value: string) => void;
|
||||
quickTaxonForm: TaxonFormState;
|
||||
taxonSearch: string;
|
||||
isCreatingTaxon: boolean;
|
||||
};
|
||||
|
||||
export function PlantsView({
|
||||
activePlantName,
|
||||
actionLogs,
|
||||
actionResources,
|
||||
careActions,
|
||||
careTasks,
|
||||
careLogForm,
|
||||
editingActionLogId,
|
||||
error,
|
||||
form,
|
||||
isCareLogEditorOpen,
|
||||
isLoading,
|
||||
isPlantEditorOpen,
|
||||
isSaving,
|
||||
plantFlagDefinitions,
|
||||
plantFlagForm,
|
||||
plantTaxa,
|
||||
plants,
|
||||
selectedPlant,
|
||||
visiblePlants,
|
||||
onCancel,
|
||||
onCancelCareLog,
|
||||
onClosePlantDetail,
|
||||
onCompleteTask,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFieldChange,
|
||||
onLogCare,
|
||||
onLogCareFieldChange,
|
||||
onNew,
|
||||
onOpenDetail,
|
||||
onQuickTaxonFieldChange,
|
||||
onPlantFlagFieldChange,
|
||||
onAssignFlag,
|
||||
onResolveFlag,
|
||||
onRemoveFlag,
|
||||
onSave,
|
||||
onSaveQuickTaxon,
|
||||
onSelectTaxon,
|
||||
onStartLogCare,
|
||||
onTaxonSearchChange,
|
||||
quickTaxonForm,
|
||||
taxonSearch,
|
||||
isCreatingTaxon,
|
||||
}: PlantsViewProps) {
|
||||
const enabledCareActions = careActions.filter((action) => action.isEnabled);
|
||||
const selectedTaxon = plantTaxa.find((taxon) => String(taxon.id) === form.taxonId);
|
||||
const normalizedSearch = taxonSearch.trim().toLowerCase();
|
||||
const filteredTaxa = plantTaxa.filter((taxon) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
taxon.name,
|
||||
taxon.genus,
|
||||
taxon.species,
|
||||
formatTaxon(taxon),
|
||||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
const enabledActionResources = actionResources.filter((resource) => resource.isEnabled);
|
||||
const selectedPlantTasks = selectedPlant
|
||||
? careTasks.filter((task) => task.plantId === selectedPlant.id)
|
||||
: [];
|
||||
const selectedPlantTaxon = selectedPlant
|
||||
? plantTaxa.find((taxon) => taxon.id === selectedPlant.taxonId)
|
||||
: undefined;
|
||||
const selectedPlantSchedules =
|
||||
selectedPlant?.careSchedules.filter((schedule) => schedule.isEnabled) ?? [];
|
||||
const enabledFlagDefinitions = plantFlagDefinitions.filter((flag) => flag.isEnabled);
|
||||
const selectedPlantActiveFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? [];
|
||||
const selectedPlantResolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,20 +140,261 @@ export function PlantsView({
|
||||
</h2>
|
||||
<p>{error ?? 'Add, update, or remove plants from your collection.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New plant
|
||||
</button>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" onClick={() => onStartLogCare()}>
|
||||
<ClipboardCheck size={16} />
|
||||
Log care
|
||||
</button>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New plant
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{selectedPlant ? (
|
||||
<section className="editor-panel plant-detail-panel" aria-labelledby="plant-detail-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant focus</p>
|
||||
<h2 id="plant-detail-heading">{selectedPlant.nickname}</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="icon-button compact"
|
||||
type="button"
|
||||
aria-label={`Log care for ${selectedPlant.nickname}`}
|
||||
onClick={() => onStartLogCare(selectedPlant)}
|
||||
>
|
||||
<ClipboardCheck size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button compact"
|
||||
type="button"
|
||||
aria-label={`Edit ${selectedPlant.nickname}`}
|
||||
onClick={() => onEdit(selectedPlant)}
|
||||
>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button compact"
|
||||
type="button"
|
||||
aria-label="Close plant detail"
|
||||
onClick={onClosePlantDetail}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plant-detail-meta">
|
||||
<div>
|
||||
<span>Taxon</span>
|
||||
<strong>{selectedPlantTaxon ? formatTaxon(selectedPlantTaxon) : selectedPlant.taxon}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Location</span>
|
||||
<strong>{selectedPlant.location || 'No location'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Next care</span>
|
||||
<strong>{selectedPlant.nextCare}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Active flags</span>
|
||||
{selectedPlantActiveFlags.length === 0 ? (
|
||||
<strong>None</strong>
|
||||
) : (
|
||||
<div className="flag-list compact">
|
||||
{selectedPlantActiveFlags.map((flag) => (
|
||||
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plant-detail-grid">
|
||||
<section className="detail-section" aria-labelledby="plant-detail-schedules">
|
||||
<div className="detail-section-heading">
|
||||
<CalendarClock size={17} />
|
||||
<h3 id="plant-detail-schedules">Schedules</h3>
|
||||
</div>
|
||||
{selectedPlantSchedules.length === 0 ? (
|
||||
<p className="empty-state">No schedules enabled.</p>
|
||||
) : (
|
||||
<div className="detail-list">
|
||||
{selectedPlantSchedules.map((schedule) => (
|
||||
<div className="detail-row" key={schedule.id}>
|
||||
<div>
|
||||
<h4>{schedule.action}</h4>
|
||||
<p>
|
||||
Every {schedule.everyDays} days - Last {schedule.lastPerformed}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`status-pill ${schedule.status}`}>
|
||||
{statusLabel[schedule.status]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section" aria-labelledby="plant-detail-tasks">
|
||||
<div className="detail-section-heading">
|
||||
<ClipboardCheck size={17} />
|
||||
<h3 id="plant-detail-tasks">Next Tasks</h3>
|
||||
</div>
|
||||
{selectedPlantTasks.length === 0 ? (
|
||||
<p className="empty-state">No upcoming tasks.</p>
|
||||
) : (
|
||||
<div className="detail-list">
|
||||
{selectedPlantTasks.map((task) => (
|
||||
<div className="detail-row" key={task.id}>
|
||||
<div>
|
||||
<h4>{task.action}</h4>
|
||||
<p>{task.due}</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<span className={`status-pill ${task.status}`}>
|
||||
{statusLabel[task.status]}
|
||||
</span>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<ClipboardCheck size={16} />
|
||||
Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section detail-section-wide" aria-labelledby="plant-detail-flags">
|
||||
<div className="detail-section-heading">
|
||||
<ClipboardCheck size={17} />
|
||||
<h3 id="plant-detail-flags">Plant Flags</h3>
|
||||
</div>
|
||||
|
||||
<div className="flag-assignment-form">
|
||||
<label>
|
||||
Flag
|
||||
<select
|
||||
value={plantFlagForm.plantFlagDefinitionId}
|
||||
onChange={(event) => onPlantFlagFieldChange('plantFlagDefinitionId', event.target.value)}
|
||||
>
|
||||
<option value="">Select a flag</option>
|
||||
{enabledFlagDefinitions.map((flag) => (
|
||||
<option key={flag.id} value={flag.id}>
|
||||
{flag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Severity
|
||||
<select
|
||||
value={plantFlagForm.severity}
|
||||
onChange={(event) => onPlantFlagFieldChange('severity', event.target.value)}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Started
|
||||
<input
|
||||
type="date"
|
||||
value={plantFlagForm.startedOn}
|
||||
onChange={(event) => onPlantFlagFieldChange('startedOn', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Notes
|
||||
<input
|
||||
value={plantFlagForm.notes}
|
||||
onChange={(event) => onPlantFlagFieldChange('notes', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="small-action"
|
||||
type="button"
|
||||
disabled={isSaving || enabledFlagDefinitions.length === 0}
|
||||
onClick={onAssignFlag}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add flag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedPlantActiveFlags.length === 0 ? (
|
||||
<p className="empty-state">No active flags.</p>
|
||||
) : (
|
||||
<div className="detail-list">
|
||||
{selectedPlantActiveFlags.map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>
|
||||
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
</h4>
|
||||
<p>
|
||||
{flag.category} - {flag.severity} - Started {formatDate(flag.startedOn)}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
|
||||
Resolve
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPlantResolvedFlags.length > 0 ? (
|
||||
<div className="detail-list resolved-flags">
|
||||
{selectedPlantResolvedFlags.slice(0, 4).map((flag) => (
|
||||
<div className="detail-row" key={flag.id}>
|
||||
<div>
|
||||
<h4>{flag.name}</h4>
|
||||
<p>
|
||||
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
|
||||
{flag.notes ? ` - ${flag.notes}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isCareLogEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="care-log-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Manual entry</p>
|
||||
<h2 id="care-log-heading">Log care</h2>
|
||||
<h2 id="care-log-heading">{editingActionLogId === null ? 'Log care' : 'Edit care log'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Start care log" onClick={() => onStartLogCare()}>
|
||||
<ClipboardCheck size={18} />
|
||||
<button className="icon-button compact" type="button" aria-label="Clear care log" onClick={onCancelCareLog}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -121,14 +416,15 @@ export function PlantsView({
|
||||
<label>
|
||||
Action
|
||||
<select
|
||||
value={careLogForm.action}
|
||||
onChange={(event) => onLogCareFieldChange('action', event.target.value)}
|
||||
value={careLogForm.careActionId}
|
||||
onChange={(event) => onLogCareFieldChange('careActionId', event.target.value)}
|
||||
>
|
||||
<option value="">Select an action</option>
|
||||
{enabledCareActions.length === 0 ? (
|
||||
<option value="">No enabled actions</option>
|
||||
) : null}
|
||||
{enabledCareActions.map((action) => (
|
||||
<option key={action.id} value={action.name}>
|
||||
<option key={action.id} value={action.id}>
|
||||
{action.name}
|
||||
</option>
|
||||
))}
|
||||
@@ -149,43 +445,87 @@ export function PlantsView({
|
||||
onChange={(event) => onLogCareFieldChange('notes', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="resource-picker">
|
||||
<legend>Resources</legend>
|
||||
{enabledActionResources.length === 0 ? (
|
||||
<p className="empty-state">No enabled resources.</p>
|
||||
) : null}
|
||||
{enabledActionResources.map((resource) => {
|
||||
const resourceId = String(resource.id);
|
||||
const selectedResource = careLogForm.resources.find(
|
||||
(item) => item.actionResourceId === resourceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="resource-entry" key={resource.id}>
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={Boolean(selectedResource)}
|
||||
type="checkbox"
|
||||
onChange={(event) => {
|
||||
const nextResources = event.target.checked
|
||||
? [
|
||||
...careLogForm.resources,
|
||||
{ actionResourceId: resourceId, quantity: '', unit: '' },
|
||||
]
|
||||
: careLogForm.resources.filter((item) => item.actionResourceId !== resourceId);
|
||||
onLogCareFieldChange('resources', nextResources);
|
||||
}}
|
||||
/>
|
||||
<span>{resource.name}</span>
|
||||
</label>
|
||||
{selectedResource ? (
|
||||
<div className="resource-amount">
|
||||
<label>
|
||||
Qty
|
||||
<input
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={selectedResource.quantity}
|
||||
onChange={(event) => onLogCareFieldChange(
|
||||
'resources',
|
||||
careLogForm.resources.map((item) => item.actionResourceId === resourceId
|
||||
? { ...item, quantity: event.target.value }
|
||||
: item),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Unit
|
||||
<input
|
||||
value={selectedResource.unit}
|
||||
onChange={(event) => onLogCareFieldChange(
|
||||
'resources',
|
||||
careLogForm.resources.map((item) => item.actionResourceId === resourceId
|
||||
? { ...item, unit: event.target.value }
|
||||
: item),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving || enabledCareActions.length === 0} onClick={onLogCare}>
|
||||
<ClipboardCheck size={18} />
|
||||
{isSaving ? 'Logging' : 'Log care'}
|
||||
{isSaving ? 'Saving' : editingActionLogId === null ? 'Log care' : 'Update log'}
|
||||
</button>
|
||||
{editingActionLogId === null ? null : (
|
||||
<button className="text-button" type="button" onClick={onCancelCareLog}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="care-history-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="care-history-heading">Care History</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && actionLogs.length === 0 ? (
|
||||
<p className="empty-state">No care logged yet.</p>
|
||||
) : null}
|
||||
|
||||
{actionLogs.slice(0, 12).map((log) => (
|
||||
<article className="plant-row" key={log.id}>
|
||||
<div className="plant-mark">
|
||||
<ClipboardCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{log.action}</h3>
|
||||
<p>
|
||||
{log.plantName} - {formatLogDate(log.performedOn)}
|
||||
{log.notes ? ` - ${log.notes}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isPlantEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="plant-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -208,34 +548,26 @@ export function PlantsView({
|
||||
<div className="taxon-picker">
|
||||
<label>
|
||||
Taxon
|
||||
<input
|
||||
value={taxonSearch}
|
||||
onChange={(event) => onTaxonSearchChange(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={form.taxonId}
|
||||
onChange={(event) => {
|
||||
const taxon = plantTaxa.find((item) => String(item.id) === event.target.value);
|
||||
if (taxon) {
|
||||
onSelectTaxon(taxon);
|
||||
return;
|
||||
}
|
||||
|
||||
onFieldChange('taxonId', '');
|
||||
}}
|
||||
>
|
||||
<option value="">Select a taxon</option>
|
||||
{plantTaxa.map((taxon) => (
|
||||
<option key={taxon.id} value={taxon.id}>
|
||||
{formatTaxon(taxon)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedTaxon ? (
|
||||
<p className="selected-taxon">
|
||||
<Check size={15} />
|
||||
{formatTaxon(selectedTaxon)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="taxon-results" aria-label="Taxon search results">
|
||||
{filteredTaxa.length === 0 ? (
|
||||
<p className="empty-state">No matching taxa.</p>
|
||||
) : null}
|
||||
{filteredTaxa.slice(0, 8).map((taxon) => (
|
||||
<button
|
||||
className="taxon-option"
|
||||
key={taxon.id}
|
||||
type="button"
|
||||
aria-pressed={String(taxon.id) === form.taxonId}
|
||||
onClick={() => onSelectTaxon(taxon)}
|
||||
>
|
||||
<span>{taxon.name}</span>
|
||||
<small>{taxon.genus} {taxon.species}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
Location
|
||||
@@ -244,24 +576,60 @@ export function PlantsView({
|
||||
onChange={(event) => onFieldChange('location', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Last watered
|
||||
<input
|
||||
type="date"
|
||||
value={form.lastWateredOn}
|
||||
onChange={(event) => onFieldChange('lastWateredOn', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Water every days
|
||||
<input
|
||||
min="1"
|
||||
max="365"
|
||||
type="number"
|
||||
value={form.waterEveryDays}
|
||||
onChange={(event) => onFieldChange('waterEveryDays', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="resource-picker schedule-picker">
|
||||
<legend>Care schedules</legend>
|
||||
{enabledCareActions.length === 0 ? (
|
||||
<p className="empty-state">No enabled actions.</p>
|
||||
) : null}
|
||||
{enabledCareActions.map((action) => {
|
||||
const actionId = String(action.id);
|
||||
const selectedSchedule = form.careSchedules.find(
|
||||
(schedule) => schedule.careActionId === actionId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="resource-entry" key={action.id}>
|
||||
<label className="check-option">
|
||||
<input
|
||||
checked={Boolean(selectedSchedule?.isEnabled)}
|
||||
type="checkbox"
|
||||
onChange={(event) => {
|
||||
const existingSchedules = form.careSchedules.filter(
|
||||
(schedule) => schedule.careActionId !== actionId,
|
||||
);
|
||||
const nextSchedule = {
|
||||
careActionId: actionId,
|
||||
everyDays: selectedSchedule?.everyDays || '7',
|
||||
isEnabled: event.target.checked,
|
||||
};
|
||||
onFieldChange('careSchedules', [...existingSchedules, nextSchedule]);
|
||||
}}
|
||||
/>
|
||||
<span>{action.name}</span>
|
||||
</label>
|
||||
{selectedSchedule?.isEnabled ? (
|
||||
<div className="resource-amount schedule-interval">
|
||||
<label>
|
||||
Every days
|
||||
<input
|
||||
min="1"
|
||||
max="365"
|
||||
type="number"
|
||||
value={selectedSchedule.everyDays}
|
||||
onChange={(event) => onFieldChange(
|
||||
'careSchedules',
|
||||
form.careSchedules.map((schedule) => schedule.careActionId === actionId
|
||||
? { ...schedule, everyDays: event.target.value }
|
||||
: schedule),
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="quick-taxon" aria-labelledby="quick-taxon-heading">
|
||||
@@ -308,6 +676,7 @@ export function PlantsView({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="plants-list-heading">
|
||||
<div className="section-heading">
|
||||
@@ -319,16 +688,31 @@ export function PlantsView({
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
{!isLoading && plants.length > 0 && visiblePlants.length === 0 ? (
|
||||
<p className="empty-state">No plants match the search.</p>
|
||||
) : null}
|
||||
|
||||
{visiblePlants.map((plant) => (
|
||||
<article className="plant-row" key={plant.id}>
|
||||
<div className="plant-mark">
|
||||
<Leaf size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{plant.nickname}</h3>
|
||||
<p>{plant.taxon} - {plant.location}</p>
|
||||
{plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? (
|
||||
<div className="flag-list">
|
||||
{plant.flags
|
||||
.filter((flag) => flag.resolvedOn === null)
|
||||
.map((flag) => (
|
||||
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`View ${plant.nickname}`} onClick={() => onOpenDetail(plant)}>
|
||||
<Eye size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
@@ -347,7 +731,14 @@ export function PlantsView({
|
||||
);
|
||||
}
|
||||
|
||||
function formatLogDate(date: string) {
|
||||
const statusLabel: Record<CareStatus, string> = {
|
||||
due: 'Due',
|
||||
soon: 'Soon',
|
||||
ok: 'Ok',
|
||||
unscheduled: 'Unscheduled',
|
||||
};
|
||||
|
||||
function formatDate(date: string) {
|
||||
const [year, month, day] = date.split('-');
|
||||
if (!year || !month || !day) {
|
||||
return date;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Edit3, Package, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import type { ActionResource } from '../domain';
|
||||
import type { ResourceFormState } from '../form-state';
|
||||
|
||||
@@ -6,6 +6,7 @@ type ResourcesViewProps = {
|
||||
activeResourceName?: string;
|
||||
error: string | null;
|
||||
form: ResourceFormState;
|
||||
isEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
@@ -21,6 +22,7 @@ export function ResourcesView({
|
||||
activeResourceName,
|
||||
error,
|
||||
form,
|
||||
isEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
onCancel,
|
||||
@@ -49,6 +51,7 @@ export function ResourcesView({
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="resource-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -102,6 +105,7 @@ export function ResourcesView({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="resources-list-heading">
|
||||
<div className="section-heading">
|
||||
@@ -115,9 +119,6 @@ export function ResourcesView({
|
||||
|
||||
{resources.map((resource) => (
|
||||
<article className="plant-row" key={resource.id}>
|
||||
<div className="plant-mark">
|
||||
<Package size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{resource.name}</h3>
|
||||
<p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookOpen, Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import type { PlantTaxon } from '../domain';
|
||||
import type { TaxonFormState } from '../form-state';
|
||||
import { formatTaxon } from '../form-state';
|
||||
@@ -7,6 +7,7 @@ type TaxaViewProps = {
|
||||
activeTaxonName?: string;
|
||||
error: string | null;
|
||||
form: TaxonFormState;
|
||||
isEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
taxa: PlantTaxon[];
|
||||
@@ -22,6 +23,7 @@ export function TaxaView({
|
||||
activeTaxonName,
|
||||
error,
|
||||
form,
|
||||
isEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
taxa,
|
||||
@@ -48,6 +50,7 @@ export function TaxaView({
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="taxon-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -114,6 +117,7 @@ export function TaxaView({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="taxa-list-heading">
|
||||
<div className="section-heading">
|
||||
@@ -127,9 +131,6 @@ export function TaxaView({
|
||||
|
||||
{taxa.map((taxon) => (
|
||||
<article className="plant-row" key={taxon.id}>
|
||||
<div className="plant-mark">
|
||||
<BookOpen size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>{taxon.name}</h3>
|
||||
<p>{formatTaxon(taxon)}</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CareStatus = 'due' | 'soon' | 'ok';
|
||||
export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled';
|
||||
|
||||
export type Plant = {
|
||||
id: number;
|
||||
@@ -6,11 +6,22 @@ export type Plant = {
|
||||
taxonId: number;
|
||||
taxon: string;
|
||||
location: string;
|
||||
lastWateredOn: string | null;
|
||||
lastWatered: string;
|
||||
nextCare: string;
|
||||
waterEveryDays: number;
|
||||
status: CareStatus;
|
||||
flags: PlantFlag[];
|
||||
careSchedules: PlantCareSchedule[];
|
||||
};
|
||||
|
||||
export type PlantCareSchedule = {
|
||||
id: number;
|
||||
careActionId: number;
|
||||
action: string;
|
||||
everyDays: number;
|
||||
lastPerformedOn: string | null;
|
||||
lastPerformed: string;
|
||||
nextCare: string;
|
||||
status: CareStatus;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type PlantTaxon = {
|
||||
@@ -38,12 +49,45 @@ export type ActionResource = {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type PlantFlagDefinition = {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
color: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
lastWateredOn: string | null;
|
||||
waterEveryDays: number;
|
||||
careSchedules: PlantCareSchedulePayload[];
|
||||
};
|
||||
|
||||
export type PlantCareSchedulePayload = {
|
||||
careActionId: number;
|
||||
everyDays: number;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export type PlantTaxonPayload = {
|
||||
@@ -68,27 +112,51 @@ export type ActionResourcePayload = {
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
action: string;
|
||||
careActionId: number;
|
||||
notes: string | null;
|
||||
performedOn: string | null;
|
||||
resources: CareLogResourcePayload[];
|
||||
};
|
||||
|
||||
export type CareLogResourcePayload = {
|
||||
actionResourceId: number;
|
||||
quantity: number | null;
|
||||
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;
|
||||
action: 'Water' | 'Fertilize' | 'Prune' | 'Inspect';
|
||||
careActionId: number;
|
||||
action: string;
|
||||
due: string;
|
||||
status: CareStatus;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
CareAction,
|
||||
CareActionPayload,
|
||||
Plant,
|
||||
AssignPlantFlagPayload,
|
||||
PlantPayload,
|
||||
PlantFlagDefinition,
|
||||
PlantFlagDefinitionPayload,
|
||||
PlantTaxon,
|
||||
PlantTaxonPayload,
|
||||
} from './domain';
|
||||
@@ -13,8 +16,13 @@ export const emptyPlantForm = {
|
||||
nickname: '',
|
||||
taxonId: '',
|
||||
location: '',
|
||||
lastWateredOn: '',
|
||||
waterEveryDays: '7',
|
||||
careSchedules: [] as PlantCareScheduleFormState[],
|
||||
};
|
||||
|
||||
export type PlantCareScheduleFormState = {
|
||||
careActionId: string;
|
||||
everyDays: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
export const emptyTaxonForm = {
|
||||
@@ -39,27 +47,53 @@ export const emptyResourceForm = {
|
||||
isEnabled: true,
|
||||
};
|
||||
|
||||
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: '',
|
||||
action: 'Water',
|
||||
careActionId: '',
|
||||
performedOn: '',
|
||||
notes: '',
|
||||
resources: [] as CareLogResourceFormState[],
|
||||
};
|
||||
|
||||
export type PlantFormState = typeof emptyPlantForm;
|
||||
export type TaxonFormState = typeof emptyTaxonForm;
|
||||
export type ActionFormState = typeof emptyActionForm;
|
||||
export type ResourceFormState = typeof emptyResourceForm;
|
||||
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
||||
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
||||
export type CareLogFormState = typeof emptyCareLogForm;
|
||||
export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources';
|
||||
export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources' | 'flags';
|
||||
|
||||
export function toPlantForm(plant: Plant): PlantFormState {
|
||||
return {
|
||||
nickname: plant.nickname,
|
||||
taxonId: String(plant.taxonId),
|
||||
location: plant.location,
|
||||
lastWateredOn: plant.lastWateredOn ?? '',
|
||||
waterEveryDays: String(plant.waterEveryDays),
|
||||
careSchedules: plant.careSchedules.map((schedule) => ({
|
||||
careActionId: String(schedule.careActionId),
|
||||
everyDays: String(schedule.everyDays),
|
||||
isEnabled: schedule.isEnabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,8 +102,11 @@ export function toPlantPayload(form: PlantFormState): PlantPayload {
|
||||
nickname: form.nickname.trim(),
|
||||
taxonId: Number(form.taxonId),
|
||||
location: form.location.trim(),
|
||||
lastWateredOn: form.lastWateredOn || null,
|
||||
waterEveryDays: Number(form.waterEveryDays),
|
||||
careSchedules: form.careSchedules.map((schedule) => ({
|
||||
careActionId: Number(schedule.careActionId),
|
||||
everyDays: Number(schedule.everyDays),
|
||||
isEnabled: schedule.isEnabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +166,33 @@ export function toResourcePayload(form: ResourceFormState): ActionResourcePayloa
|
||||
};
|
||||
}
|
||||
|
||||
export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState {
|
||||
return {
|
||||
name: flag.name,
|
||||
category: flag.category,
|
||||
color: flag.color,
|
||||
isEnabled: flag.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFlagDefinitionPayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
category: form.category.trim() || null,
|
||||
color: form.color.trim() || null,
|
||||
isEnabled: form.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
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 formatTaxon(taxon: PlantTaxon) {
|
||||
const botanical = `${taxon.genus} ${taxon.species}`.trim();
|
||||
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
|
||||
|
||||
+706
-346
File diff suppressed because it is too large
Load Diff
+185
-26
@@ -6,15 +6,18 @@ namespace plant_manager
|
||||
string Nickname,
|
||||
int TaxonId,
|
||||
string? Location,
|
||||
DateOnly? LastWateredOn,
|
||||
int? WaterEveryDays);
|
||||
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
|
||||
|
||||
public record UpdatePlantRequest(
|
||||
string Nickname,
|
||||
int TaxonId,
|
||||
string? Location,
|
||||
DateOnly? LastWateredOn,
|
||||
int? WaterEveryDays);
|
||||
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
|
||||
|
||||
public record SavePlantCareScheduleRequest(
|
||||
int CareActionId,
|
||||
int? EveryDays,
|
||||
bool IsEnabled);
|
||||
|
||||
public record SavePlantTaxonRequest(
|
||||
string Name,
|
||||
@@ -35,11 +38,42 @@ namespace plant_manager
|
||||
string? Notes,
|
||||
bool IsEnabled);
|
||||
|
||||
public record SavePlantFlagDefinitionRequest(
|
||||
string Name,
|
||||
string? Category,
|
||||
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,
|
||||
string Action,
|
||||
int CareActionId,
|
||||
string? Notes,
|
||||
DateOnly? PerformedOn);
|
||||
DateOnly? PerformedOn,
|
||||
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
||||
|
||||
public record UpdateActionLogRequest(
|
||||
int PlantId,
|
||||
int CareActionId,
|
||||
string? Notes,
|
||||
DateOnly PerformedOn,
|
||||
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
||||
|
||||
public record ActionLogResourceRequest(
|
||||
int ActionResourceId,
|
||||
decimal? Quantity,
|
||||
string? Unit);
|
||||
|
||||
public record PlantTaxonDto(
|
||||
int Id,
|
||||
@@ -88,16 +122,27 @@ namespace plant_manager
|
||||
int TaxonId,
|
||||
string Taxon,
|
||||
string Location,
|
||||
DateOnly? LastWateredOn,
|
||||
string LastWatered,
|
||||
string NextCare,
|
||||
int WaterEveryDays,
|
||||
string Status)
|
||||
string Status,
|
||||
IReadOnlyList<PlantFlagDto> Flags,
|
||||
IReadOnlyList<PlantCareScheduleDto> CareSchedules)
|
||||
{
|
||||
public static PlantDto FromPlant(Plant plant)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(plant);
|
||||
var schedules = plant.CareSchedules
|
||||
.OrderBy(schedule => schedule.CareAction.Name)
|
||||
.Select(schedule => PlantCareScheduleDto.FromSchedule(
|
||||
schedule,
|
||||
GetLatestPerformedOn(plant, schedule.CareActionId),
|
||||
today))
|
||||
.ToList();
|
||||
var nextCare = schedules
|
||||
.Where(schedule => schedule.IsEnabled)
|
||||
.Select(schedule => PlantCareFormatter.GetNextCareDate(schedule.LastPerformedOn, schedule.EveryDays))
|
||||
.Where(date => date is not null)
|
||||
.OrderBy(date => date)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new PlantDto(
|
||||
plant.Id,
|
||||
@@ -105,11 +150,52 @@ namespace plant_manager
|
||||
plant.TaxonId,
|
||||
$"{plant.Taxon.Genus} {plant.Taxon.Species}",
|
||||
plant.Location,
|
||||
plant.LastWateredOn,
|
||||
PlantCareFormatter.FormatRelativeDate(plant.LastWateredOn, today, "Never"),
|
||||
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
|
||||
plant.WaterEveryDays,
|
||||
PlantCareFormatter.GetStatus(nextCare, today));
|
||||
PlantCareFormatter.GetStatus(nextCare, today),
|
||||
plant.Flags
|
||||
.OrderBy(flag => flag.ResolvedOn is not null)
|
||||
.ThenByDescending(flag => flag.StartedOn)
|
||||
.ThenBy(flag => flag.Definition.Name)
|
||||
.Select(PlantFlagDto.FromPlantFlag)
|
||||
.ToList(),
|
||||
schedules);
|
||||
}
|
||||
|
||||
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActionId) =>
|
||||
plant.ActionLogs
|
||||
.Where(log => log.CareActionId == careActionId)
|
||||
.Select(log => (DateOnly?)log.PerformedOn)
|
||||
.Max();
|
||||
}
|
||||
|
||||
public record PlantCareScheduleDto(
|
||||
int Id,
|
||||
int CareActionId,
|
||||
string Action,
|
||||
int EveryDays,
|
||||
DateOnly? LastPerformedOn,
|
||||
string LastPerformed,
|
||||
string NextCare,
|
||||
string Status,
|
||||
bool IsEnabled)
|
||||
{
|
||||
public static PlantCareScheduleDto FromSchedule(
|
||||
PlantCareSchedule schedule,
|
||||
DateOnly? lastPerformedOn,
|
||||
DateOnly today)
|
||||
{
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays);
|
||||
|
||||
return new PlantCareScheduleDto(
|
||||
schedule.Id,
|
||||
schedule.CareActionId,
|
||||
schedule.CareAction.Name,
|
||||
schedule.EveryDays,
|
||||
lastPerformedOn,
|
||||
PlantCareFormatter.FormatRelativeDate(lastPerformedOn, today, "Never"),
|
||||
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
|
||||
PlantCareFormatter.GetStatus(nextCare, today),
|
||||
schedule.IsEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,46 +203,119 @@ namespace plant_manager
|
||||
int Id,
|
||||
int PlantId,
|
||||
string PlantName,
|
||||
int CareActionId,
|
||||
string Action,
|
||||
string Due,
|
||||
string Status)
|
||||
{
|
||||
public static CareTaskDto FromPlant(Plant plant, DateOnly today)
|
||||
public static CareTaskDto FromSchedule(
|
||||
PlantCareSchedule schedule,
|
||||
DateOnly? lastPerformedOn,
|
||||
DateOnly today)
|
||||
{
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(plant);
|
||||
var nextCare = PlantCareFormatter.GetNextCareDate(lastPerformedOn, schedule.EveryDays);
|
||||
|
||||
return new CareTaskDto(
|
||||
plant.Id,
|
||||
plant.Id,
|
||||
plant.Nickname,
|
||||
"Water",
|
||||
schedule.Id,
|
||||
schedule.PlantId,
|
||||
schedule.Plant.Nickname,
|
||||
schedule.CareActionId,
|
||||
schedule.CareAction.Name,
|
||||
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
|
||||
PlantCareFormatter.GetStatus(nextCare, today));
|
||||
}
|
||||
}
|
||||
|
||||
public record PlantFlagDefinitionDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string Category,
|
||||
string Color,
|
||||
bool IsEnabled)
|
||||
{
|
||||
public static PlantFlagDefinitionDto FromDefinition(PlantFlagDefinition definition) =>
|
||||
new(
|
||||
definition.Id,
|
||||
definition.Name,
|
||||
definition.Category,
|
||||
definition.Color,
|
||||
definition.IsEnabled);
|
||||
}
|
||||
|
||||
public record PlantFlagDto(
|
||||
int Id,
|
||||
int PlantFlagDefinitionId,
|
||||
string Name,
|
||||
string Category,
|
||||
string Color,
|
||||
string Severity,
|
||||
DateOnly StartedOn,
|
||||
DateOnly? ResolvedOn,
|
||||
string? Notes)
|
||||
{
|
||||
public static PlantFlagDto FromPlantFlag(PlantFlag flag) =>
|
||||
new(
|
||||
flag.Id,
|
||||
flag.PlantFlagDefinitionId,
|
||||
flag.Definition.Name,
|
||||
flag.Definition.Category,
|
||||
flag.Definition.Color,
|
||||
flag.Severity,
|
||||
flag.StartedOn,
|
||||
flag.ResolvedOn,
|
||||
flag.Notes);
|
||||
}
|
||||
|
||||
public record ActionLogDto(
|
||||
int Id,
|
||||
int PlantId,
|
||||
string PlantName,
|
||||
int CareActionId,
|
||||
string Action,
|
||||
string? Notes,
|
||||
DateOnly PerformedOn)
|
||||
DateOnly PerformedOn,
|
||||
IReadOnlyList<ActionLogResourceDto> Resources)
|
||||
{
|
||||
public static ActionLogDto FromActionLog(ActionLog log) =>
|
||||
new(log.Id, log.PlantId, log.Plant.Nickname, log.Action, log.Notes, log.PerformedOn);
|
||||
new(
|
||||
log.Id,
|
||||
log.PlantId,
|
||||
log.Plant.Nickname,
|
||||
log.CareActionId,
|
||||
log.ActionNameSnapshot,
|
||||
log.Notes,
|
||||
log.PerformedOn,
|
||||
log.Resources
|
||||
.Select(resource => ActionLogResourceDto.FromActionLogResource(resource))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
public record ActionLogResourceDto(
|
||||
int ActionResourceId,
|
||||
string Name,
|
||||
string? Category,
|
||||
decimal? Quantity,
|
||||
string? Unit)
|
||||
{
|
||||
public static ActionLogResourceDto FromActionLogResource(ActionLogResource resource) =>
|
||||
new(
|
||||
resource.ActionResourceId,
|
||||
resource.ActionResource.Name,
|
||||
resource.ActionResource.Category,
|
||||
resource.Quantity,
|
||||
resource.Unit);
|
||||
}
|
||||
|
||||
internal static class PlantCareFormatter
|
||||
{
|
||||
public static DateOnly? GetNextCareDate(Plant plant) =>
|
||||
plant.LastWateredOn?.AddDays(plant.WaterEveryDays);
|
||||
public static DateOnly? GetNextCareDate(DateOnly? lastPerformedOn, int everyDays) =>
|
||||
lastPerformedOn?.AddDays(everyDays);
|
||||
|
||||
public static string GetStatus(DateOnly? date, DateOnly today)
|
||||
{
|
||||
if (date is null)
|
||||
{
|
||||
return "due";
|
||||
return "unscheduled";
|
||||
}
|
||||
|
||||
if (date <= today)
|
||||
|
||||
@@ -10,6 +10,10 @@ namespace plant_manager.Data
|
||||
public DbSet<CareAction> CareActions { get; set; }
|
||||
public DbSet<ActionResource> ActionResources { get; set; }
|
||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||
public DbSet<ActionLogResource> ActionLogResources { get; set; }
|
||||
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
|
||||
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
|
||||
public DbSet<PlantFlag> PlantFlags { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -65,12 +69,79 @@ namespace plant_manager.Data
|
||||
modelBuilder.Entity<ActionLog>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Action).HasMaxLength(80).IsRequired();
|
||||
entity.Property(e => e.ActionNameSnapshot).HasMaxLength(80).IsRequired();
|
||||
entity.Property(e => e.Notes).HasMaxLength(1000);
|
||||
entity.HasOne(e => e.Plant)
|
||||
.WithMany(e => e.ActionLogs)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.CareAction)
|
||||
.WithMany(e => e.ActionLogs)
|
||||
.HasForeignKey(e => e.CareActionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ActionLogResource>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.ActionLogId, e.ActionResourceId });
|
||||
entity.Property(e => e.Quantity).HasPrecision(10, 2);
|
||||
entity.Property(e => e.Unit).HasMaxLength(40);
|
||||
entity.HasOne(e => e.ActionLog)
|
||||
.WithMany(e => e.Resources)
|
||||
.HasForeignKey(e => e.ActionLogId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.ActionResource)
|
||||
.WithMany(e => e.ActionLogResources)
|
||||
.HasForeignKey(e => e.ActionResourceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantCareSchedule>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.EveryDays).IsRequired();
|
||||
entity.Property(e => e.IsEnabled).IsRequired();
|
||||
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique();
|
||||
entity.HasOne(e => e.Plant)
|
||||
.WithMany(e => e.CareSchedules)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.CareAction)
|
||||
.WithMany(e => e.PlantCareSchedules)
|
||||
.HasForeignKey(e => e.CareActionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantFlagDefinition>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
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();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlantFlag>(entity =>
|
||||
{
|
||||
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)
|
||||
.WithMany(e => e.Flags)
|
||||
.HasForeignKey(e => e.PlantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
entity.HasOne(e => e.Definition)
|
||||
.WithMany(e => e.PlantFlags)
|
||||
.HasForeignKey(e => e.PlantFlagDefinitionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ namespace plant_manager.Data
|
||||
new() { Name = "Pruners", Category = "Equipment", Notes = "Cutting tool for pruning or cleanup." }
|
||||
];
|
||||
|
||||
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" }
|
||||
];
|
||||
|
||||
private static readonly StarterTaxon[] StarterTaxa =
|
||||
[
|
||||
new("Chinese Money Plant", "Pilea", "peperomioides"),
|
||||
@@ -50,47 +59,12 @@ namespace plant_manager.Data
|
||||
|
||||
public static void Seed(ApplicationDbContext db)
|
||||
{
|
||||
EnsureCareActionTable(db);
|
||||
EnsureActionResourceTable(db);
|
||||
SeedCareActions(db);
|
||||
SeedActionResources(db);
|
||||
SeedPlantFlags(db);
|
||||
SeedStarterTaxa(db);
|
||||
SeedStarterPlants(db);
|
||||
}
|
||||
|
||||
private static void EnsureCareActionTable(ApplicationDbContext db)
|
||||
{
|
||||
db.Database.ExecuteSqlRaw("""
|
||||
CREATE TABLE IF NOT EXISTS "CareActions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareActions" PRIMARY KEY AUTOINCREMENT,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Description" TEXT NULL,
|
||||
"IsEnabled" INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
db.Database.ExecuteSqlRaw("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareActions_Name"
|
||||
ON "CareActions" ("Name");
|
||||
""");
|
||||
}
|
||||
|
||||
private static void EnsureActionResourceTable(ApplicationDbContext db)
|
||||
{
|
||||
db.Database.ExecuteSqlRaw("""
|
||||
CREATE TABLE IF NOT EXISTS "ActionResources" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ActionResources" PRIMARY KEY AUTOINCREMENT,
|
||||
"Name" TEXT NOT NULL,
|
||||
"Category" TEXT NULL,
|
||||
"Notes" TEXT NULL,
|
||||
"IsEnabled" INTEGER NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
db.Database.ExecuteSqlRaw("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ActionResources_Name"
|
||||
ON "ActionResources" ("Name");
|
||||
""");
|
||||
SeedDefaultCareSchedules(db);
|
||||
}
|
||||
|
||||
private static void SeedCareActions(ApplicationDbContext db)
|
||||
@@ -119,6 +93,33 @@ 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
|
||||
@@ -146,6 +147,33 @@ namespace plant_manager.Data
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void SeedPlantFlags(ApplicationDbContext db)
|
||||
{
|
||||
var existingFlagNames = db.PlantFlagDefinitions
|
||||
.Select(flag => flag.Name)
|
||||
.ToList();
|
||||
|
||||
var missingFlags = StarterFlagDefinitions
|
||||
.Where(starterFlag => !existingFlagNames.Any(existingName =>
|
||||
string.Equals(existingName, starterFlag.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(starterFlag => new PlantFlagDefinition
|
||||
{
|
||||
Name = starterFlag.Name,
|
||||
Category = starterFlag.Category,
|
||||
Color = starterFlag.Color,
|
||||
IsEnabled = starterFlag.IsEnabled
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (missingFlags.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
db.PlantFlagDefinitions.AddRange(missingFlags);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
private static void SeedStarterTaxa(ApplicationDbContext db)
|
||||
{
|
||||
var existingTaxa = db.PlantTaxa.ToList();
|
||||
@@ -184,31 +212,69 @@ namespace plant_manager.Data
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
db.Plants.AddRange(
|
||||
new Plant
|
||||
var starterPlants = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Nickname = "Pothos",
|
||||
Taxon = Taxon("Pothos"),
|
||||
Location = "Living room",
|
||||
LastWateredOn = today.AddDays(-7),
|
||||
WaterEveryDays = 7
|
||||
Plant = new Plant
|
||||
{
|
||||
Nickname = "Pothos",
|
||||
Taxon = Taxon("Pothos"),
|
||||
Location = "Living room"
|
||||
},
|
||||
InitialWateredOn = today.AddDays(-7),
|
||||
WaterIntervalDays = 7
|
||||
},
|
||||
new Plant
|
||||
new
|
||||
{
|
||||
Nickname = "Money Tree",
|
||||
Taxon = Taxon("Money Tree"),
|
||||
Location = "Bedroom",
|
||||
LastWateredOn = today.AddDays(-13),
|
||||
WaterEveryDays = 14
|
||||
Plant = new Plant
|
||||
{
|
||||
Nickname = "Money Tree",
|
||||
Taxon = Taxon("Money Tree"),
|
||||
Location = "Bedroom"
|
||||
},
|
||||
InitialWateredOn = today.AddDays(-13),
|
||||
WaterIntervalDays = 14
|
||||
},
|
||||
new Plant
|
||||
new
|
||||
{
|
||||
Nickname = "Chinese Money Plant",
|
||||
Taxon = Taxon("Chinese Money Plant"),
|
||||
Location = "Kitchen",
|
||||
LastWateredOn = today.AddDays(-2),
|
||||
WaterEveryDays = 6
|
||||
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.SaveChanges();
|
||||
|
||||
var waterAction = db.CareActions.FirstOrDefault(action => action.Name == "Water");
|
||||
if (waterAction is null)
|
||||
{
|
||||
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.SaveChanges();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using plant_manager.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260518153034_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionNameSnapshot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("PerformedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("PlantId");
|
||||
|
||||
b.ToTable("ActionLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.Property<int>("ActionLogId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ActionLogId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TaxonId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaxonId");
|
||||
|
||||
b.ToTable("Plants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantFlagDefinitionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly?>("ResolvedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("StartedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlantFlagDefinitionId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn");
|
||||
|
||||
b.ToTable("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantFlagDefinitions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Cultivar")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Species")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Variety")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("ActionLogId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("ActionLogResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionLog");
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaxonId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Taxon");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition")
|
||||
.WithMany("PlantFlags")
|
||||
.HasForeignKey("PlantFlagDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("Flags")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Navigation("Resources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Navigation("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
|
||||
b.Navigation("Flags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ActionResources",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ActionResources", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CareActions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", maxLength: 400, nullable: true),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CareActions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantFlagDefinitions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
|
||||
Color = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantTaxa",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Genus = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Species = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Cultivar = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Variety = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantTaxa", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Plants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
TaxonId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Nickname = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Location = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Plants", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Plants_PlantTaxa_TaxonId",
|
||||
column: x => x.TaxonId,
|
||||
principalTable: "PlantTaxa",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ActionLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActionNameSnapshot = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
PerformedOn = table.Column<DateOnly>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ActionLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ActionLogs_CareActions_CareActionId",
|
||||
column: x => x.CareActionId,
|
||||
principalTable: "CareActions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ActionLogs_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantCareSchedules",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_CareActions_CareActionId",
|
||||
column: x => x.CareActionId,
|
||||
principalTable: "CareActions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantCareSchedules_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantFlags",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PlantFlagDefinitionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Severity = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||
StartedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
ResolvedOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantFlags", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantFlags_PlantFlagDefinitions_PlantFlagDefinitionId",
|
||||
column: x => x.PlantFlagDefinitionId,
|
||||
principalTable: "PlantFlagDefinitions",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantFlags_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ActionLogResources",
|
||||
columns: table => new
|
||||
{
|
||||
ActionLogId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActionResourceId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 10, scale: 2, nullable: true),
|
||||
Unit = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ActionLogResources", x => new { x.ActionLogId, x.ActionResourceId });
|
||||
table.ForeignKey(
|
||||
name: "FK_ActionLogResources_ActionLogs_ActionLogId",
|
||||
column: x => x.ActionLogId,
|
||||
principalTable: "ActionLogs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ActionLogResources_ActionResources_ActionResourceId",
|
||||
column: x => x.ActionResourceId,
|
||||
principalTable: "ActionResources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ActionLogResources_ActionResourceId",
|
||||
table: "ActionLogResources",
|
||||
column: "ActionResourceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ActionLogs_CareActionId",
|
||||
table: "ActionLogs",
|
||||
column: "CareActionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ActionLogs_PlantId",
|
||||
table: "ActionLogs",
|
||||
column: "PlantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ActionResources_Name",
|
||||
table: "ActionResources",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CareActions_Name",
|
||||
table: "CareActions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareSchedules_CareActionId",
|
||||
table: "PlantCareSchedules",
|
||||
column: "CareActionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantCareSchedules_PlantId_CareActionId",
|
||||
table: "PlantCareSchedules",
|
||||
columns: new[] { "PlantId", "CareActionId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantFlagDefinitions_Name",
|
||||
table: "PlantFlagDefinitions",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantFlags_PlantFlagDefinitionId",
|
||||
table: "PlantFlags",
|
||||
column: "PlantFlagDefinitionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantFlags_PlantId_PlantFlagDefinitionId_ResolvedOn",
|
||||
table: "PlantFlags",
|
||||
columns: new[] { "PlantId", "PlantFlagDefinitionId", "ResolvedOn" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Plants_TaxonId",
|
||||
table: "Plants",
|
||||
column: "TaxonId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionLogResources");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantCareSchedules");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantFlags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionResources");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantFlagDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CareActions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Plants");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantTaxa");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using plant_manager.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionNameSnapshot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("PerformedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("PlantId");
|
||||
|
||||
b.ToTable("ActionLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.Property<int>("ActionLogId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ActionLogId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TaxonId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaxonId");
|
||||
|
||||
b.ToTable("Plants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantFlagDefinitionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly?>("ResolvedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Severity")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("StartedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlantFlagDefinitionId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn");
|
||||
|
||||
b.ToTable("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantFlagDefinitions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Cultivar")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Species")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Variety")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("ActionLogId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("ActionLogResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionLog");
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaxonId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Taxon");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition")
|
||||
.WithMany("PlantFlags")
|
||||
.HasForeignKey("PlantFlagDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("Flags")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Navigation("Resources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Navigation("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
|
||||
b.Navigation("Flags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ namespace plant_manager.Data.Models
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PlantId { get; set; }
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public int CareActionId { 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 List<ActionLogResource> Resources { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class ActionLogResource
|
||||
{
|
||||
public int ActionLogId { get; set; }
|
||||
public int ActionResourceId { get; set; }
|
||||
public decimal? Quantity { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
|
||||
public ActionLog ActionLog { get; set; } = null!;
|
||||
public ActionResource ActionResource { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,7 @@ namespace plant_manager.Data.Models
|
||||
public string? Category { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public List<ActionLogResource> ActionLogResources { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@ namespace plant_manager.Data.Models
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ namespace plant_manager.Data.Models
|
||||
public int TaxonId { get; set; }
|
||||
public string Nickname { get; set; } = string.Empty;
|
||||
public string Location { get; set; } = string.Empty;
|
||||
public DateOnly? LastWateredOn { get; set; }
|
||||
public int WaterEveryDays { get; set; } = 7;
|
||||
|
||||
public PlantTaxon Taxon { get; set; } = null!;
|
||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||
public List<PlantCareSchedule> CareSchedules { get; set; } = [];
|
||||
public List<PlantFlag> Flags { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class PlantCareSchedule
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PlantId { get; set; }
|
||||
public int CareActionId { get; set; }
|
||||
public int EveryDays { get; set; } = 7;
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
public Plant Plant { get; set; } = null!;
|
||||
public CareAction CareAction { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class PlantFlag
|
||||
{
|
||||
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; }
|
||||
|
||||
public Plant Plant { get; set; } = null!;
|
||||
public PlantFlagDefinition Definition { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace plant_manager.Data.Models
|
||||
{
|
||||
public class PlantFlagDefinition
|
||||
{
|
||||
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;
|
||||
|
||||
public List<PlantFlag> PlantFlags { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,13 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
var logs = await db.ActionLogs
|
||||
.Include(log => log.Plant)
|
||||
.Include(log => log.Resources)
|
||||
.ThenInclude(resource => resource.ActionResource)
|
||||
.OrderByDescending(log => log.PerformedOn)
|
||||
.ThenByDescending(log => log.Id)
|
||||
.Select(log => new ActionLogDto(
|
||||
log.Id,
|
||||
log.PlantId,
|
||||
log.Plant.Nickname,
|
||||
log.Action,
|
||||
log.Notes,
|
||||
log.PerformedOn))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(logs);
|
||||
return Results.Ok(logs.Select(ActionLogDto.FromActionLog));
|
||||
});
|
||||
|
||||
app.MapPost("/api/action-logs", async (CreateActionLogRequest request, ApplicationDbContext db) =>
|
||||
@@ -34,25 +29,35 @@ namespace plant_manager.Endpoints
|
||||
return Results.BadRequest(new { error = "Plant was not found." });
|
||||
}
|
||||
|
||||
var action = string.IsNullOrWhiteSpace(request.Action)
|
||||
? "Water"
|
||||
: request.Action.Trim();
|
||||
var action = await db.CareActions.FindAsync(request.CareActionId);
|
||||
if (action is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care action was not found." });
|
||||
}
|
||||
|
||||
if (!action.IsEnabled)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Disabled actions cannot be logged." });
|
||||
}
|
||||
|
||||
var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var (resources, resourceError) = await BuildLogResources(request.Resources, db);
|
||||
if (resourceError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = resourceError });
|
||||
}
|
||||
|
||||
var log = new ActionLog
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
Action = action,
|
||||
CareActionId = action.Id,
|
||||
CareAction = action,
|
||||
ActionNameSnapshot = action.Name,
|
||||
Notes = request.Notes?.Trim(),
|
||||
PerformedOn = performedOn
|
||||
PerformedOn = performedOn,
|
||||
Resources = resources
|
||||
};
|
||||
|
||||
if (string.Equals(action, "Water", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
plant.LastWateredOn = performedOn;
|
||||
}
|
||||
|
||||
db.ActionLogs.Add(log);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -60,6 +65,116 @@ namespace plant_manager.Endpoints
|
||||
|
||||
return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log));
|
||||
});
|
||||
|
||||
app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
var log = await db.ActionLogs
|
||||
.Include(item => item.Plant)
|
||||
.Include(item => item.Resources)
|
||||
.FirstOrDefaultAsync(item => item.Id == id);
|
||||
if (log is null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Care action was not found." });
|
||||
}
|
||||
|
||||
if (!action.IsEnabled)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Disabled actions cannot be logged." });
|
||||
}
|
||||
|
||||
var (resources, resourceError) = await BuildLogResources(request.Resources, db);
|
||||
if (resourceError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = resourceError });
|
||||
}
|
||||
|
||||
db.ActionLogResources.RemoveRange(log.Resources);
|
||||
|
||||
log.PlantId = plant.Id;
|
||||
log.Plant = plant;
|
||||
log.CareActionId = action.Id;
|
||||
log.CareAction = action;
|
||||
log.ActionNameSnapshot = action.Name;
|
||||
log.Notes = request.Notes?.Trim();
|
||||
log.PerformedOn = request.PerformedOn;
|
||||
log.Resources = resources;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(ActionLogDto.FromActionLog(log));
|
||||
});
|
||||
|
||||
app.MapDelete("/api/action-logs/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||
{
|
||||
var log = await db.ActionLogs.FindAsync(id);
|
||||
if (log is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.ActionLogs.Remove(log);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<(List<ActionLogResource> Resources, string? Error)> BuildLogResources(
|
||||
IReadOnlyList<ActionLogResourceRequest>? requestResources,
|
||||
ApplicationDbContext db)
|
||||
{
|
||||
var requestedResources = requestResources?
|
||||
.GroupBy(resource => resource.ActionResourceId)
|
||||
.Select(group => group.First())
|
||||
.ToList() ?? [];
|
||||
|
||||
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 logged.");
|
||||
}
|
||||
|
||||
return (requestedResources
|
||||
.Select(resource => new ActionLogResource
|
||||
{
|
||||
ActionResourceId = resource.ActionResourceId,
|
||||
ActionResource = resourcesById[resource.ActionResourceId],
|
||||
Quantity = resource.Quantity,
|
||||
Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim()
|
||||
})
|
||||
.ToList(), null);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,14 @@ namespace plant_manager.Endpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var hasLogs = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id);
|
||||
if (hasLogs)
|
||||
{
|
||||
resource.IsEnabled = false;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Conflict(new { error = "Resource has care history, so it was disabled instead of deleted." });
|
||||
}
|
||||
|
||||
db.ActionResources.Remove(resource);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ namespace plant_manager.Endpoints
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var hasLogs = await db.ActionLogs.AnyAsync(log => log.Action.ToLower() == action.Name.ToLower());
|
||||
var hasLogs = await db.ActionLogs.AnyAsync(log => log.CareActionId == id);
|
||||
if (hasLogs)
|
||||
{
|
||||
action.IsEnabled = false;
|
||||
|
||||
@@ -7,21 +7,42 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
public static void MapCareTaskEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/care-tasks/today", async (ApplicationDbContext db) =>
|
||||
async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var plants = await db.Plants
|
||||
.Include(plant => plant.Taxon)
|
||||
.OrderBy(plant => plant.Nickname)
|
||||
var schedules = await db.PlantCareSchedules
|
||||
.Include(schedule => schedule.Plant)
|
||||
.Include(schedule => schedule.CareAction)
|
||||
.Where(schedule => schedule.IsEnabled && schedule.CareAction.IsEnabled)
|
||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
||||
.ThenBy(schedule => schedule.CareAction.Name)
|
||||
.ToListAsync();
|
||||
var latestLogs = await db.ActionLogs
|
||||
.GroupBy(log => new { log.PlantId, log.CareActionId })
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.PlantId,
|
||||
group.Key.CareActionId,
|
||||
LastPerformedOn = group.Max(log => log.PerformedOn)
|
||||
})
|
||||
.ToListAsync();
|
||||
var latestLogLookup = latestLogs.ToDictionary(
|
||||
log => (log.PlantId, log.CareActionId),
|
||||
log => (DateOnly?)log.LastPerformedOn);
|
||||
|
||||
var tasks = plants
|
||||
.Select(plant => CareTaskDto.FromPlant(plant, today))
|
||||
var tasks = schedules
|
||||
.Select(schedule => CareTaskDto.FromSchedule(
|
||||
schedule,
|
||||
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActionId)),
|
||||
today))
|
||||
.Where(task => task.Status is "due" or "soon")
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(tasks);
|
||||
});
|
||||
}
|
||||
|
||||
app.MapGet("/api/care-tasks/upcoming", GetUpcomingCareTasks);
|
||||
app.MapGet("/api/care-tasks/today", GetUpcomingCareTasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,17 +12,26 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
var plants = await db.Plants
|
||||
.Include(plant => plant.Taxon)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(plant => plant.ActionLogs)
|
||||
.Include(plant => plant.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.OrderBy(plant => plant.Nickname)
|
||||
.Select(plant => PlantDto.FromPlant(plant))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(plants);
|
||||
return Results.Ok(plants.Select(PlantDto.FromPlant));
|
||||
});
|
||||
|
||||
app.MapGet("/api/plants/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||
{
|
||||
var plant = await db.Plants
|
||||
.Include(item => item.Taxon)
|
||||
.Include(plant => plant.CareSchedules)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(plant => plant.ActionLogs)
|
||||
.Include(plant => plant.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.FirstOrDefaultAsync(item => item.Id == id);
|
||||
|
||||
return plant is null
|
||||
@@ -47,15 +56,20 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
Nickname = request.Nickname.Trim(),
|
||||
Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(),
|
||||
TaxonId = request.TaxonId,
|
||||
LastWateredOn = request.LastWateredOn,
|
||||
WaterEveryDays = Math.Clamp(request.WaterEveryDays ?? 7, 1, 365)
|
||||
TaxonId = request.TaxonId
|
||||
};
|
||||
|
||||
db.Plants.Add(plant);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
plant.Taxon = taxon;
|
||||
var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db);
|
||||
if (scheduleError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = scheduleError });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/plants/{plant.Id}", PlantDto.FromPlant(plant));
|
||||
});
|
||||
@@ -69,6 +83,11 @@ namespace plant_manager.Endpoints
|
||||
|
||||
var plant = await db.Plants
|
||||
.Include(item => item.Taxon)
|
||||
.Include(item => item.CareSchedules)
|
||||
.ThenInclude(schedule => schedule.CareAction)
|
||||
.Include(item => item.ActionLogs)
|
||||
.Include(item => item.Flags)
|
||||
.ThenInclude(flag => flag.Definition)
|
||||
.FirstOrDefaultAsync(item => item.Id == id);
|
||||
|
||||
if (plant is null)
|
||||
@@ -86,8 +105,11 @@ namespace plant_manager.Endpoints
|
||||
plant.Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim();
|
||||
plant.TaxonId = request.TaxonId;
|
||||
plant.Taxon = taxon;
|
||||
plant.LastWateredOn = request.LastWateredOn;
|
||||
plant.WaterEveryDays = Math.Clamp(request.WaterEveryDays ?? 7, 1, 365);
|
||||
var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db);
|
||||
if (scheduleError is not null)
|
||||
{
|
||||
return Results.BadRequest(new { error = scheduleError });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -108,5 +130,74 @@ namespace plant_manager.Endpoints
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<string?> ApplyCareSchedules(
|
||||
Plant plant,
|
||||
IReadOnlyList<SavePlantCareScheduleRequest>? requestedSchedules,
|
||||
ApplicationDbContext db)
|
||||
{
|
||||
var schedules = requestedSchedules?.ToList();
|
||||
if (schedules is null)
|
||||
{
|
||||
var waterAction = await db.CareActions
|
||||
.FirstOrDefaultAsync(action => action.Name.ToLower() == "water");
|
||||
if (waterAction is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
schedules =
|
||||
[
|
||||
new SavePlantCareScheduleRequest(
|
||||
waterAction.Id,
|
||||
7,
|
||||
true)
|
||||
];
|
||||
}
|
||||
|
||||
var normalizedSchedules = schedules
|
||||
.GroupBy(schedule => schedule.CareActionId)
|
||||
.Select(group => group.First())
|
||||
.Where(schedule => schedule.CareActionId > 0)
|
||||
.ToList();
|
||||
var actionIds = normalizedSchedules
|
||||
.Select(schedule => schedule.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.";
|
||||
}
|
||||
|
||||
var requestedActionIds = actionIds.ToHashSet();
|
||||
var schedulesToRemove = plant.CareSchedules
|
||||
.Where(schedule => !requestedActionIds.Contains(schedule.CareActionId))
|
||||
.ToList();
|
||||
db.PlantCareSchedules.RemoveRange(schedulesToRemove);
|
||||
|
||||
foreach (var requestedSchedule in normalizedSchedules)
|
||||
{
|
||||
var schedule = plant.CareSchedules
|
||||
.FirstOrDefault(item => item.CareActionId == requestedSchedule.CareActionId);
|
||||
if (schedule is null)
|
||||
{
|
||||
schedule = new PlantCareSchedule
|
||||
{
|
||||
PlantId = plant.Id,
|
||||
CareActionId = requestedSchedule.CareActionId,
|
||||
CareAction = actionsById[requestedSchedule.CareActionId]
|
||||
};
|
||||
plant.CareSchedules.Add(schedule);
|
||||
}
|
||||
|
||||
schedule.EveryDays = Math.Clamp(requestedSchedule.EveryDays ?? 7, 1, 365);
|
||||
schedule.IsEnabled = requestedSchedule.IsEnabled;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using plant_manager.Data;
|
||||
using plant_manager.Data.Models;
|
||||
|
||||
namespace plant_manager.Endpoints
|
||||
{
|
||||
public static class PlantFlagEndpoints
|
||||
{
|
||||
private static readonly HashSet<string> 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();
|
||||
|
||||
return Results.Ok(definitions);
|
||||
});
|
||||
|
||||
app.MapPost("/api/plant-flags", async (SavePlantFlagDefinitionRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Flag name is required." });
|
||||
}
|
||||
|
||||
var name = request.Name.Trim();
|
||||
var exists = await db.PlantFlagDefinitions.AnyAsync(definition =>
|
||||
definition.Name.ToLower() == name.ToLower());
|
||||
if (exists)
|
||||
{
|
||||
return Results.Conflict(new { error = "A flag with this name already exists." });
|
||||
}
|
||||
|
||||
var definition = new PlantFlagDefinition
|
||||
{
|
||||
Name = name,
|
||||
Category = NormalizeCategory(request.Category),
|
||||
Color = NormalizeColor(request.Color),
|
||||
IsEnabled = request.IsEnabled
|
||||
};
|
||||
|
||||
db.PlantFlagDefinitions.Add(definition);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/plant-flags/{definition.Id}", PlantFlagDefinitionDto.FromDefinition(definition));
|
||||
});
|
||||
|
||||
app.MapPut("/api/plant-flags/{id:int}", async (int id, SavePlantFlagDefinitionRequest request, ApplicationDbContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Flag name is required." });
|
||||
}
|
||||
|
||||
var name = request.Name.Trim();
|
||||
var definition = await db.PlantFlagDefinitions.FindAsync(id);
|
||||
if (definition is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var exists = await db.PlantFlagDefinitions.AnyAsync(item =>
|
||||
item.Id != id && item.Name.ToLower() == name.ToLower());
|
||||
if (exists)
|
||||
{
|
||||
return Results.Conflict(new { error = "A flag with this name already exists." });
|
||||
}
|
||||
|
||||
definition.Name = name;
|
||||
definition.Category = NormalizeCategory(request.Category);
|
||||
definition.Color = NormalizeColor(request.Color);
|
||||
definition.IsEnabled = request.IsEnabled;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(PlantFlagDefinitionDto.FromDefinition(definition));
|
||||
});
|
||||
|
||||
app.MapDelete("/api/plant-flags/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||
{
|
||||
var definition = await db.PlantFlagDefinitions.FindAsync(id);
|
||||
if (definition is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var isUsed = await db.PlantFlags.AnyAsync(flag => flag.PlantFlagDefinitionId == id);
|
||||
if (isUsed)
|
||||
{
|
||||
definition.IsEnabled = false;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Conflict(new { error = "Flag is assigned to plants, so it was disabled instead of deleted." });
|
||||
}
|
||||
|
||||
db.PlantFlagDefinitions.Remove(definition);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapPost("/api/plants/{plantId:int}/flags", async (
|
||||
int plantId,
|
||||
AssignPlantFlagRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var plant = await db.Plants.FindAsync(plantId);
|
||||
if (plant is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var definition = await db.PlantFlagDefinitions.FindAsync(request.PlantFlagDefinitionId);
|
||||
if (definition is null || !definition.IsEnabled)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Flag was not found or is disabled." });
|
||||
}
|
||||
|
||||
var hasActiveFlag = await db.PlantFlags.AnyAsync(flag =>
|
||||
flag.PlantId == plantId
|
||||
&& flag.PlantFlagDefinitionId == request.PlantFlagDefinitionId
|
||||
&& flag.ResolvedOn == null);
|
||||
if (hasActiveFlag)
|
||||
{
|
||||
return Results.Conflict(new { error = "This plant already has that active flag." });
|
||||
}
|
||||
|
||||
var flag = new PlantFlag
|
||||
{
|
||||
PlantId = plantId,
|
||||
PlantFlagDefinitionId = request.PlantFlagDefinitionId,
|
||||
Definition = definition,
|
||||
Severity = NormalizeSeverity(request.Severity),
|
||||
StartedOn = request.StartedOn ?? DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
Notes = NormalizeNotes(request.Notes)
|
||||
};
|
||||
|
||||
db.PlantFlags.Add(flag);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/plants/{plantId}/flags/{flag.Id}", PlantFlagDto.FromPlantFlag(flag));
|
||||
});
|
||||
|
||||
app.MapPut("/api/plants/{plantId:int}/flags/{flagId:int}", async (
|
||||
int plantId,
|
||||
int flagId,
|
||||
UpdatePlantFlagRequest request,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var flag = await db.PlantFlags
|
||||
.Include(item => item.Definition)
|
||||
.FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId);
|
||||
if (flag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
flag.Severity = NormalizeSeverity(request.Severity);
|
||||
flag.StartedOn = request.StartedOn ?? flag.StartedOn;
|
||||
flag.ResolvedOn = request.ResolvedOn;
|
||||
flag.Notes = NormalizeNotes(request.Notes);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(PlantFlagDto.FromPlantFlag(flag));
|
||||
});
|
||||
|
||||
app.MapPost("/api/plants/{plantId:int}/flags/{flagId:int}/resolve", async (
|
||||
int plantId,
|
||||
int flagId,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var flag = await db.PlantFlags
|
||||
.Include(item => item.Definition)
|
||||
.FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId);
|
||||
if (flag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
flag.ResolvedOn = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(PlantFlagDto.FromPlantFlag(flag));
|
||||
});
|
||||
|
||||
app.MapDelete("/api/plants/{plantId:int}/flags/{flagId:int}", async (
|
||||
int plantId,
|
||||
int flagId,
|
||||
ApplicationDbContext db) =>
|
||||
{
|
||||
var flag = await db.PlantFlags
|
||||
.FirstOrDefaultAsync(item => item.Id == flagId && item.PlantId == plantId);
|
||||
if (flag is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
db.PlantFlags.Remove(flag);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace plant_manager.Endpoints
|
||||
"/api/plant-taxa",
|
||||
"/api/care-actions",
|
||||
"/api/action-resources",
|
||||
"/api/care-tasks/today",
|
||||
"/api/care-tasks/upcoming",
|
||||
"/api/action-logs"
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -35,7 +35,7 @@ app.UseCors(frontendPolicy);
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
db.Database.Migrate();
|
||||
DatabaseSeeder.Seed(db);
|
||||
}
|
||||
|
||||
@@ -46,5 +46,6 @@ app.MapCareActionEndpoints();
|
||||
app.MapActionResourceEndpoints();
|
||||
app.MapCareTaskEndpoints();
|
||||
app.MapActionLogEndpoints();
|
||||
app.MapPlantFlagEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user