add recipes, timelines, group scheduling

This commit is contained in:
2026-05-30 14:45:49 +00:00
parent 5c9959584a
commit 7e64337cd9
29 changed files with 3414 additions and 28 deletions
+286 -10
View File
@@ -4,7 +4,9 @@ import {
createCareActivity, createCareActivity,
createCareAction, createCareAction,
createPlant, createPlant,
createPlantGroup,
createPlantLocation, createPlantLocation,
createRecipe,
assignPlantFlag, assignPlantFlag,
completeCareTasksBulk, completeCareTasksBulk,
createPlantFlag, createPlantFlag,
@@ -13,17 +15,21 @@ import {
deleteCareActivity, deleteCareActivity,
deleteCareAction, deleteCareAction,
deletePlant, deletePlant,
deletePlantGroup,
deletePlantFlag, deletePlantFlag,
deletePlantLocation, deletePlantLocation,
deletePlantTaxon, deletePlantTaxon,
deleteRecipe,
getActionResources, getActionResources,
getCareActivities, getCareActivities,
getCareActions, getCareActions,
getCareTasks, getCareTasks,
getPlants, getPlants,
getPlantFlags, getPlantFlags,
getPlantGroups,
getPlantLocations, getPlantLocations,
getPlantTaxa, getPlantTaxa,
getRecipes,
removePlantFlagAssignment, removePlantFlagAssignment,
removePlantCareSchedulesBulk, removePlantCareSchedulesBulk,
resolvePlantFlag, resolvePlantFlag,
@@ -32,21 +38,25 @@ import {
updateCareActivity, updateCareActivity,
updateCareAction, updateCareAction,
updatePlant, updatePlant,
updatePlantGroup,
updatePlantFlag, updatePlantFlag,
updatePlantLocation, updatePlantLocation,
updatePlantTaxon, updatePlantTaxon,
updateRecipe,
} from './api'; } from './api';
import { ActionsView } from './components/ActionsView'; import { ActionsView } from './components/ActionsView';
import { ActivitiesView } from './components/ActivitiesView'; import { ActivitiesView } from './components/ActivitiesView';
import { FlagsView } from './components/FlagsView'; import { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView'; import { HomeView } from './components/HomeView';
import { LocationsView } from './components/LocationsView'; import { LocationsView } from './components/LocationsView';
import { PlantManagementView } from './components/PlantManagementView'; import { PlantManagementView } from './components/PlantManagementView';
import { PlantsView } from './components/PlantsView'; import { PlantsView } from './components/PlantsView';
import { RecipesView } from './components/RecipesView';
import { ResourcesView } from './components/ResourcesView'; import { ResourcesView } from './components/ResourcesView';
import { SchedulesView } from './components/SchedulesView'; import { SchedulesView } from './components/SchedulesView';
import { TaxaView } from './components/TaxaView'; import { TaxaView } from './components/TaxaView';
import type { ActionResource, CareActivity, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantLocation, PlantTaxon } from './domain'; import type { ActionResource, CareActivity, CareAction, CareTask, Plant, PlantFlag, PlantFlagDefinition, PlantGroup, PlantLocation, PlantTaxon, Recipe } from './domain';
import { import {
emptyActionForm, emptyActionForm,
emptyActivityForm, emptyActivityForm,
@@ -54,7 +64,9 @@ import {
emptyFlagDefinitionForm, emptyFlagDefinitionForm,
emptyLocationForm, emptyLocationForm,
emptyPlantForm, emptyPlantForm,
emptyPlantGroupForm,
emptyPlantFlagForm, emptyPlantFlagForm,
emptyRecipeForm,
emptyResourceForm, emptyResourceForm,
emptyTaxonForm, emptyTaxonForm,
toActionForm, toActionForm,
@@ -66,9 +78,13 @@ import {
toFlagDefinitionPayload, toFlagDefinitionPayload,
toLocationForm, toLocationForm,
toLocationPayload, toLocationPayload,
toPlantGroupForm,
toPlantGroupPayload,
toPlantFlagPayload, toPlantFlagPayload,
toPlantForm, toPlantForm,
toPlantPayload, toPlantPayload,
toRecipeForm,
toRecipePayload,
toResourceForm, toResourceForm,
toResourcePayload, toResourcePayload,
toTaxonForm, toTaxonForm,
@@ -78,8 +94,10 @@ import {
type BulkScheduleFormState, type BulkScheduleFormState,
type FlagDefinitionFormState, type FlagDefinitionFormState,
type LocationFormState, type LocationFormState,
type PlantGroupFormState,
type PlantFlagFormState, type PlantFlagFormState,
type PlantFormState, type PlantFormState,
type RecipeFormState,
type ResourceFormState, type ResourceFormState,
type TaxonFormState, type TaxonFormState,
type View, type View,
@@ -90,8 +108,10 @@ export function App() {
const [plants, setPlants] = useState<Plant[]>([]); const [plants, setPlants] = useState<Plant[]>([]);
const [plantTaxa, setPlantTaxa] = useState<PlantTaxon[]>([]); const [plantTaxa, setPlantTaxa] = useState<PlantTaxon[]>([]);
const [plantLocations, setPlantLocations] = useState<PlantLocation[]>([]); const [plantLocations, setPlantLocations] = useState<PlantLocation[]>([]);
const [plantGroups, setPlantGroups] = useState<PlantGroup[]>([]);
const [careActions, setCareActions] = useState<CareAction[]>([]); const [careActions, setCareActions] = useState<CareAction[]>([]);
const [actionResources, setActionResources] = useState<ActionResource[]>([]); const [actionResources, setActionResources] = useState<ActionResource[]>([]);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [careActivities, setCareActivities] = useState<CareActivity[]>([]); const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]); const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]); const [careTasks, setCareTasks] = useState<CareTask[]>([]);
@@ -100,15 +120,19 @@ export function App() {
const [editingPlantId, setEditingPlantId] = useState<number | null>(null); const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null); const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
const [editingLocationId, setEditingLocationId] = useState<number | null>(null); const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
const [editingActionId, setEditingActionId] = useState<number | null>(null); const [editingActionId, setEditingActionId] = useState<number | null>(null);
const [editingResourceId, setEditingResourceId] = useState<number | null>(null); const [editingResourceId, setEditingResourceId] = useState<number | null>(null);
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
const [editingActivityId, setEditingActivityId] = useState<number | null>(null); const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null); const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false); const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false); const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false); const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false); const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false); const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false);
const [isRecipeEditorOpen, setIsRecipeEditorOpen] = useState(false);
const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false); const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false);
const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false); const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false);
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null); const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
@@ -116,13 +140,16 @@ export function App() {
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null); const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [selectedActionId, setSelectedActionId] = useState<number | null>(null); const [selectedActionId, setSelectedActionId] = useState<number | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<number | null>(null); const [selectedResourceId, setSelectedResourceId] = useState<number | null>(null);
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null); const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null); const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
const [form, setForm] = useState<PlantFormState>(emptyPlantForm); const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm); const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm); const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm); const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm); const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm);
const [recipeForm, setRecipeForm] = useState<RecipeFormState>(emptyRecipeForm);
const [activityForm, setActivityForm] = useState<ActivityFormState>(emptyActivityForm); const [activityForm, setActivityForm] = useState<ActivityFormState>(emptyActivityForm);
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm); const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm); const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
@@ -136,8 +163,10 @@ export function App() {
tasksResponse, tasksResponse,
taxaResponse, taxaResponse,
locationsResponse, locationsResponse,
groupsResponse,
actionsResponse, actionsResponse,
resourcesResponse, resourcesResponse,
recipesResponse,
activitiesResponse, activitiesResponse,
flagsResponse, flagsResponse,
] = await Promise.all([ ] = await Promise.all([
@@ -145,8 +174,10 @@ export function App() {
getCareTasks(), getCareTasks(),
getPlantTaxa(), getPlantTaxa(),
getPlantLocations(), getPlantLocations(),
getPlantGroups(),
getCareActions(), getCareActions(),
getActionResources(), getActionResources(),
getRecipes(),
getCareActivities(), getCareActivities(),
getPlantFlags(), getPlantFlags(),
]); ]);
@@ -156,8 +187,10 @@ export function App() {
setCareTasks(tasksResponse); setCareTasks(tasksResponse);
setPlantTaxa(taxaResponse); setPlantTaxa(taxaResponse);
setPlantLocations(locationsResponse); setPlantLocations(locationsResponse);
setPlantGroups(groupsResponse);
setCareActions(actionsResponse); setCareActions(actionsResponse);
setActionResources(resourcesResponse); setActionResources(resourcesResponse);
setRecipes(recipesResponse);
setCareActivities(activitiesResponse); setCareActivities(activitiesResponse);
setPlantFlagDefinitions(flagsResponse); setPlantFlagDefinitions(flagsResponse);
} catch { } catch {
@@ -174,6 +207,17 @@ export function App() {
setPlantLocations(locationsResponse); setPlantLocations(locationsResponse);
} }
async function loadGroupsAndPlants() {
const [groupsResponse, plantsResponse] = await Promise.all([
getPlantGroups(),
getPlants(),
]);
setError(null);
setPlantGroups(groupsResponse);
setPlants(plantsResponse);
}
async function loadPlants() { async function loadPlants() {
const plantsResponse = await getPlants(); const plantsResponse = await getPlants();
@@ -220,12 +264,14 @@ export function App() {
tasksResponse, tasksResponse,
actionsResponse, actionsResponse,
resourcesResponse, resourcesResponse,
recipesResponse,
activitiesResponse, activitiesResponse,
] = await Promise.all([ ] = await Promise.all([
getPlants(), getPlants(),
getCareTasks(), getCareTasks(),
getCareActions(), getCareActions(),
getActionResources(), getActionResources(),
getRecipes(),
getCareActivities(), getCareActivities(),
]); ]);
@@ -234,9 +280,21 @@ export function App() {
setCareTasks(tasksResponse); setCareTasks(tasksResponse);
setCareActions(actionsResponse); setCareActions(actionsResponse);
setActionResources(resourcesResponse); setActionResources(resourcesResponse);
setRecipes(recipesResponse);
setCareActivities(activitiesResponse); setCareActivities(activitiesResponse);
} }
async function loadRecipesAndResources() {
const [recipesResponse, resourcesResponse] = await Promise.all([
getRecipes(),
getActionResources(),
]);
setError(null);
setRecipes(recipesResponse);
setActionResources(resourcesResponse);
}
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
void loadDashboard(); void loadDashboard();
@@ -254,10 +312,13 @@ export function App() {
const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedTaxonId); const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
const activeLocation = plantLocations.find((location) => location.id === editingLocationId); const activeLocation = plantLocations.find((location) => location.id === editingLocationId);
const selectedLocation = plantLocations.find((location) => location.id === selectedLocationId); const selectedLocation = plantLocations.find((location) => location.id === selectedLocationId);
const activePlantGroup = plantGroups.find((group) => group.id === editingPlantGroupId);
const activeAction = careActions.find((action) => action.id === editingActionId); const activeAction = careActions.find((action) => action.id === editingActionId);
const selectedAction = careActions.find((action) => action.id === selectedActionId); const selectedAction = careActions.find((action) => action.id === selectedActionId);
const activeResource = actionResources.find((resource) => resource.id === editingResourceId); const activeResource = actionResources.find((resource) => resource.id === editingResourceId);
const selectedResource = actionResources.find((resource) => resource.id === selectedResourceId); const selectedResource = actionResources.find((resource) => resource.id === selectedResourceId);
const activeRecipe = recipes.find((recipe) => recipe.id === editingRecipeId);
const selectedRecipe = recipes.find((recipe) => recipe.id === selectedRecipeId);
const activeActivity = careActivities.find((activity) => activity.id === editingActivityId); const activeActivity = careActivities.find((activity) => activity.id === editingActivityId);
const selectedActivity = careActivities.find((activity) => activity.id === selectedActivityId); const selectedActivity = careActivities.find((activity) => activity.id === selectedActivityId);
const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId); const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId);
@@ -275,6 +336,10 @@ export function App() {
setLocationForm((current) => ({ ...current, [field]: value })); setLocationForm((current) => ({ ...current, [field]: value }));
} }
function updatePlantGroupForm(field: keyof PlantGroupFormState, value: string | string[]) {
setPlantGroupForm((current) => ({ ...current, [field]: value }));
}
function updateActionForm(field: keyof ActionFormState, value: string) { function updateActionForm(field: keyof ActionFormState, value: string) {
setActionForm((current) => ({ ...current, [field]: value })); setActionForm((current) => ({ ...current, [field]: value }));
} }
@@ -287,6 +352,10 @@ export function App() {
setActivityForm((current) => ({ ...current, [field]: value })); setActivityForm((current) => ({ ...current, [field]: value }));
} }
function updateRecipeForm(field: keyof RecipeFormState, value: RecipeFormState[keyof RecipeFormState]) {
setRecipeForm((current) => ({ ...current, [field]: value }));
}
function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string) { function updateFlagDefinitionForm(field: keyof FlagDefinitionFormState, value: string) {
setFlagDefinitionForm((current) => ({ ...current, [field]: value })); setFlagDefinitionForm((current) => ({ ...current, [field]: value }));
} }
@@ -304,6 +373,7 @@ export function App() {
function startAddingPlant() { function startAddingPlant() {
setEditingPlantId(null); setEditingPlantId(null);
setSelectedPlantId(null);
setForm(emptyPlantForm); setForm(emptyPlantForm);
setIsPlantEditorOpen(true); setIsPlantEditorOpen(true);
setView('plants'); setView('plants');
@@ -331,6 +401,14 @@ export function App() {
setIsPlantEditorOpen(false); setIsPlantEditorOpen(false);
} }
function openPlantReadOnlyDetail(plant: Plant) {
setSelectedPlantId(plant.id);
setEditingPlantId(null);
setForm(emptyPlantForm);
setIsPlantEditorOpen(false);
setView('plants');
}
function startAddingTaxon() { function startAddingTaxon() {
setEditingTaxonId(null); setEditingTaxonId(null);
setSelectedTaxonId(null); setSelectedTaxonId(null);
@@ -375,6 +453,26 @@ export function App() {
setIsLocationEditorOpen(false); setIsLocationEditorOpen(false);
} }
function startAddingPlantGroup() {
setEditingPlantGroupId(null);
setPlantGroupForm(emptyPlantGroupForm);
setIsPlantGroupEditorOpen(true);
setView('groups');
}
function startEditingPlantGroup(group: PlantGroup) {
setEditingPlantGroupId(group.id);
setPlantGroupForm(toPlantGroupForm(group));
setIsPlantGroupEditorOpen(true);
setView('groups');
}
function cancelEditingPlantGroup() {
setEditingPlantGroupId(null);
setPlantGroupForm(emptyPlantGroupForm);
setIsPlantGroupEditorOpen(false);
}
function startAddingAction() { function startAddingAction() {
setEditingActionId(null); setEditingActionId(null);
setSelectedActionId(null); setSelectedActionId(null);
@@ -419,6 +517,28 @@ export function App() {
setIsResourceEditorOpen(false); setIsResourceEditorOpen(false);
} }
function startAddingRecipe() {
setEditingRecipeId(null);
setSelectedRecipeId(null);
setRecipeForm(emptyRecipeForm);
setIsRecipeEditorOpen(true);
setView('recipes');
}
function startEditingRecipe(recipe: Recipe) {
setSelectedRecipeId(null);
setEditingRecipeId(recipe.id);
setRecipeForm(toRecipeForm(recipe));
setIsRecipeEditorOpen(true);
setView('recipes');
}
function cancelEditingRecipe() {
setEditingRecipeId(null);
setRecipeForm(emptyRecipeForm);
setIsRecipeEditorOpen(false);
}
function startAddingActivity() { function startAddingActivity() {
setEditingActivityId(null); setEditingActivityId(null);
setSelectedActivityId(null); setSelectedActivityId(null);
@@ -504,6 +624,7 @@ export function App() {
try { try {
await updatePlant(selectedPlant.id, { await updatePlant(selectedPlant.id, {
nickname: selectedPlant.nickname, nickname: selectedPlant.nickname,
birthday: selectedPlant.birthday,
taxonId: nextValues.taxonId === undefined ? selectedPlant.taxonId : nextValues.taxonId, taxonId: nextValues.taxonId === undefined ? selectedPlant.taxonId : nextValues.taxonId,
locationId: nextValues.locationId === undefined ? selectedPlant.locationId : nextValues.locationId, locationId: nextValues.locationId === undefined ? selectedPlant.locationId : nextValues.locationId,
careSchedules: null, careSchedules: null,
@@ -639,6 +760,49 @@ export function App() {
} }
} }
async function savePlantGroup() {
if (!plantGroupForm.name.trim()) {
setError('Group name is required.');
return;
}
setIsSaving(true);
try {
const payload = toPlantGroupPayload(plantGroupForm);
if (editingPlantGroupId === null) {
await createPlantGroup(payload);
} else {
await updatePlantGroup(editingPlantGroupId, payload);
}
cancelEditingPlantGroup();
await loadGroupsAndPlants();
} catch {
setError('Could not save the plant group.');
} finally {
setIsSaving(false);
}
}
async function removePlantGroup(group: PlantGroup) {
const confirmed = window.confirm(`Delete ${group.name}?`);
if (!confirmed) {
return;
}
setIsSaving(true);
try {
await deletePlantGroup(group.id);
if (editingPlantGroupId === group.id) {
cancelEditingPlantGroup();
}
await loadGroupsAndPlants();
} catch {
setError('Could not delete the plant group.');
} finally {
setIsSaving(false);
}
}
async function saveAction() { async function saveAction() {
if (!actionForm.name.trim()) { if (!actionForm.name.trim()) {
setError('Action name is required.'); setError('Action name is required.');
@@ -699,6 +863,7 @@ export function App() {
} }
cancelEditingResource(); cancelEditingResource();
await loadCareModel(); await loadCareModel();
await loadRecipesAndResources();
} catch { } catch {
setError('Could not save the resource.'); setError('Could not save the resource.');
} finally { } finally {
@@ -719,6 +884,7 @@ export function App() {
cancelEditingResource(); cancelEditingResource();
} }
await loadCareModel(); await loadCareModel();
await loadRecipesAndResources();
} catch { } catch {
setError('Could not delete the resource.'); setError('Could not delete the resource.');
} finally { } finally {
@@ -726,6 +892,52 @@ export function App() {
} }
} }
async function saveRecipe() {
if (!recipeForm.name.trim() || !recipeForm.type.trim() || !recipeForm.outputResourceName.trim() || recipeForm.components.length === 0) {
setError('Recipe name, type, produced resource, and at least one component are required.');
return;
}
setIsSaving(true);
try {
const payload = toRecipePayload(recipeForm);
if (editingRecipeId === null) {
await createRecipe(payload);
} else {
await updateRecipe(editingRecipeId, payload);
}
cancelEditingRecipe();
await loadRecipesAndResources();
} catch {
setError('Could not save the recipe.');
} finally {
setIsSaving(false);
}
}
async function removeRecipe(recipe: Recipe) {
const confirmed = window.confirm(`Delete ${recipe.name}?`);
if (!confirmed) {
return;
}
setIsSaving(true);
try {
await deleteRecipe(recipe.id);
if (selectedRecipeId === recipe.id) {
setSelectedRecipeId(null);
}
if (editingRecipeId === recipe.id) {
cancelEditingRecipe();
}
await loadRecipesAndResources();
} catch {
setError('Could not delete the recipe.');
} finally {
setIsSaving(false);
}
}
async function saveActivity() { async function saveActivity() {
if (!activityForm.name.trim() || activityForm.actions.length === 0) { if (!activityForm.name.trim() || activityForm.actions.length === 0) {
setError('Activity name and at least one action are required.'); setError('Activity name and at least one action are required.');
@@ -1010,13 +1222,6 @@ export function App() {
> >
Care Activities Care Activities
</button> </button>
<button
type="button"
aria-current={view === 'actions' ? 'page' : undefined}
onClick={() => setView('actions')}
>
Care Actions
</button>
</div> </div>
<div className="nav-group"> <div className="nav-group">
@@ -1035,6 +1240,13 @@ export function App() {
> >
Locations Locations
</button> </button>
<button
type="button"
aria-current={view === 'groups' ? 'page' : undefined}
onClick={() => setView('groups')}
>
Groups
</button>
<button <button
type="button" type="button"
aria-current={view === 'resources' ? 'page' : undefined} aria-current={view === 'resources' ? 'page' : undefined}
@@ -1042,6 +1254,20 @@ export function App() {
> >
Resources Resources
</button> </button>
<button
type="button"
aria-current={view === 'recipes' ? 'page' : undefined}
onClick={() => setView('recipes')}
>
Recipes
</button>
<button
type="button"
aria-current={view === 'actions' ? 'page' : undefined}
onClick={() => setView('actions')}
>
Actions
</button>
<button <button
type="button" type="button"
aria-current={view === 'flags' ? 'page' : undefined} aria-current={view === 'flags' ? 'page' : undefined}
@@ -1056,9 +1282,11 @@ export function App() {
<h3>Catalog Status</h3> <h3>Catalog Status</h3>
<p>{dueCount} due</p> <p>{dueCount} due</p>
<p>{plantLocations.length} locations</p> <p>{plantLocations.length} locations</p>
<p>{plantGroups.length} groups</p>
<p>{careActivities.length} activities</p> <p>{careActivities.length} activities</p>
<p>{careActions.length} actions</p> <p>{careActions.length} actions</p>
<p>{actionResources.length} resources</p> <p>{actionResources.length} resources</p>
<p>{recipes.length} recipes</p>
<p>{plantFlagDefinitions.length} flags</p> <p>{plantFlagDefinitions.length} flags</p>
</section> </section>
</aside> </aside>
@@ -1079,11 +1307,14 @@ export function App() {
isPlantEditorOpen={isPlantEditorOpen} isPlantEditorOpen={isPlantEditorOpen}
isSaving={isSaving} isSaving={isSaving}
plants={plants} plants={plants}
selectedPlant={selectedPlant}
onCancel={cancelEditing} onCancel={cancelEditing}
onCloseDetail={() => setSelectedPlantId(null)}
onDelete={(plant) => void removePlant(plant)} onDelete={(plant) => void removePlant(plant)}
onEdit={startEditingPlant} onEdit={startEditingPlant}
onFieldChange={updateForm} onFieldChange={updateForm}
onNew={startAddingPlant} onNew={startAddingPlant}
onOpenDetail={openPlantReadOnlyDetail}
onSave={() => void savePlant()} onSave={() => void savePlant()}
/> />
) : view === 'plant-management' ? ( ) : view === 'plant-management' ? (
@@ -1111,6 +1342,7 @@ export function App() {
activities={careActivities} activities={careActivities}
error={error} error={error}
form={bulkScheduleForm} form={bulkScheduleForm}
groups={plantGroups}
isLoading={isLoading} isLoading={isLoading}
isSaving={isSaving} isSaving={isSaving}
plants={plants} plants={plants}
@@ -1156,6 +1388,23 @@ export function App() {
onOpenDetail={(location) => setSelectedLocationId(location.id)} onOpenDetail={(location) => setSelectedLocationId(location.id)}
onSave={() => void saveLocation()} onSave={() => void saveLocation()}
/> />
) : view === 'groups' ? (
<GroupsView
activeGroupName={activePlantGroup?.name}
error={error}
form={plantGroupForm}
groups={plantGroups}
isEditorOpen={isPlantGroupEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
plants={plants}
onCancel={cancelEditingPlantGroup}
onDelete={(group) => void removePlantGroup(group)}
onEdit={startEditingPlantGroup}
onFieldChange={updatePlantGroupForm}
onNew={startAddingPlantGroup}
onSave={() => void savePlantGroup()}
/>
) : view === 'actions' ? ( ) : view === 'actions' ? (
<ActionsView <ActionsView
activeActionName={activeAction?.name} activeActionName={activeAction?.name}
@@ -1215,6 +1464,26 @@ export function App() {
onSave={() => void saveResource()} onSave={() => void saveResource()}
resources={actionResources} resources={actionResources}
/> />
) : view === 'recipes' ? (
<RecipesView
activeRecipeName={activeRecipe?.name}
error={error}
form={recipeForm}
isEditorOpen={isRecipeEditorOpen}
isLoading={isLoading}
isSaving={isSaving}
recipes={recipes}
resources={actionResources}
selectedRecipe={selectedRecipe}
onCancel={cancelEditingRecipe}
onCloseDetail={() => setSelectedRecipeId(null)}
onDelete={(recipe) => void removeRecipe(recipe)}
onEdit={startEditingRecipe}
onFieldChange={updateRecipeForm}
onNew={startAddingRecipe}
onOpenDetail={(recipe) => setSelectedRecipeId(recipe.id)}
onSave={() => void saveRecipe()}
/>
) : view === 'flags' ? ( ) : view === 'flags' ? (
<FlagsView <FlagsView
activeFlagName={activeFlagDefinition?.name} activeFlagName={activeFlagDefinition?.name}
@@ -1239,6 +1508,7 @@ export function App() {
careTasks={careTasks} careTasks={careTasks}
dueCount={dueCount} dueCount={dueCount}
error={error} error={error}
groups={plantGroups}
isLoading={isLoading} isLoading={isLoading}
plants={plants} plants={plants}
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)} onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)}
@@ -1265,10 +1535,12 @@ function getViewEyebrow(view: View) {
return 'Care'; return 'Care';
case 'taxa': case 'taxa':
case 'locations': case 'locations':
case 'groups':
case 'resources': case 'resources':
case 'recipes':
case 'actions':
case 'flags': case 'flags':
return 'Catalogs'; return 'Catalogs';
case 'actions':
case 'activities': case 'activities':
return 'Care'; return 'Care';
default: default:
@@ -1288,12 +1560,16 @@ function getViewTitle(view: View) {
return 'Plant Taxa'; return 'Plant Taxa';
case 'locations': case 'locations':
return 'Locations'; return 'Locations';
case 'groups':
return 'Groups';
case 'actions': case 'actions':
return 'Care Actions'; return 'Actions';
case 'activities': case 'activities':
return 'Care Activities'; return 'Care Activities';
case 'resources': case 'resources':
return 'Resources'; return 'Resources';
case 'recipes':
return 'Recipes';
case 'flags': case 'flags':
return 'Plant Flags'; return 'Plant Flags';
default: default:
+52
View File
@@ -8,12 +8,16 @@ import type {
BulkCompleteCareTasksPayload, BulkCompleteCareTasksPayload,
CareTask, CareTask,
BulkPlantCareSchedulePayload, BulkPlantCareSchedulePayload,
Recipe,
RecipePayload,
Plant, Plant,
AssignPlantFlagPayload, AssignPlantFlagPayload,
PlantPayload, PlantPayload,
PlantFlag, PlantFlag,
PlantFlagDefinition, PlantFlagDefinition,
PlantFlagDefinitionPayload, PlantFlagDefinitionPayload,
PlantGroup,
PlantGroupPayload,
PlantLocation, PlantLocation,
PlantLocationPayload, PlantLocationPayload,
PlantTaxon, PlantTaxon,
@@ -74,6 +78,30 @@ export async function deletePlantLocation(id: number) {
}); });
} }
export async function getPlantGroups() {
return request<PlantGroup[]>('/api/plant-groups');
}
export async function createPlantGroup(payload: PlantGroupPayload) {
return request<PlantGroup>('/api/plant-groups', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updatePlantGroup(id: number, payload: PlantGroupPayload) {
return request<PlantGroup>(`/api/plant-groups/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deletePlantGroup(id: number) {
return request<void>(`/api/plant-groups/${id}`, {
method: 'DELETE',
});
}
export async function createPlantTaxon(payload: PlantTaxonPayload) { export async function createPlantTaxon(payload: PlantTaxonPayload) {
return request<PlantTaxon>('/api/plant-taxa', { return request<PlantTaxon>('/api/plant-taxa', {
method: 'POST', method: 'POST',
@@ -142,6 +170,30 @@ export async function deleteActionResource(id: number) {
}); });
} }
export async function getRecipes() {
return request<Recipe[]>('/api/recipes');
}
export async function createRecipe(payload: RecipePayload) {
return request<Recipe>('/api/recipes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateRecipe(id: number, payload: RecipePayload) {
return request<Recipe>(`/api/recipes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteRecipe(id: number) {
return request<void>(`/api/recipes/${id}`, {
method: 'DELETE',
});
}
export async function getCareActivities() { export async function getCareActivities() {
return request<CareActivity[]>('/api/care-activities'); return request<CareActivity[]>('/api/care-activities');
} }
@@ -0,0 +1,159 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import type { Plant, PlantGroup } from '../domain';
import type { PlantGroupFormState } from '../form-state';
type GroupsViewProps = {
activeGroupName?: string;
error: string | null;
form: PlantGroupFormState;
groups: PlantGroup[];
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
plants: Plant[];
onCancel: () => void;
onDelete: (group: PlantGroup) => void;
onEdit: (group: PlantGroup) => void;
onFieldChange: (field: keyof PlantGroupFormState, value: string | string[]) => void;
onNew: () => void;
onSave: () => void;
};
export function GroupsView({
activeGroupName,
error,
form,
groups,
isEditorOpen,
isLoading,
isSaving,
plants,
onCancel,
onDelete,
onEdit,
onFieldChange,
onNew,
onSave,
}: GroupsViewProps) {
const selectedPlantIds = new Set(form.plantIds);
return (
<>
<section className="summary-panel" aria-labelledby="groups-summary-heading">
<div>
<p className="eyebrow">Plant groups</p>
<h2 id="groups-summary-heading">
{isLoading ? 'Loading groups' : `${groups.length} groups`}
</h2>
<p>{error ?? 'Organize plants for scheduling and care logging.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New group
</button>
</section>
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="group-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activeGroupName ? 'Editing' : 'New group'}</p>
<h2 id="group-editor-heading">{activeGroupName ?? 'Group 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
disabled={isSaving}
value={form.name}
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<label className="form-wide">
Notes
<textarea
disabled={isSaving}
value={form.notes}
onChange={(event) => onFieldChange('notes', event.target.value)}
/>
</label>
</div>
<fieldset className="resource-picker schedule-picker">
<legend>Plants</legend>
<div className="plant-list compact-plant-list">
{!isLoading && plants.length === 0 ? (
<p className="empty-state">No plants available.</p>
) : null}
{plants.map((plant) => (
<label className="plant-row check-row" key={plant.id}>
<span>
<input
checked={selectedPlantIds.has(String(plant.id))}
disabled={isSaving}
type="checkbox"
onChange={(event) => {
const plantId = String(plant.id);
const nextPlantIds = event.target.checked
? [...form.plantIds, plantId]
: form.plantIds.filter((id) => id !== plantId);
onFieldChange('plantIds', nextPlantIds);
}}
/>
<strong>{plant.nickname}</strong>
<small>{plant.taxon} - {plant.location}</small>
</span>
</label>
))}
</div>
</fieldset>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || !form.name.trim()} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save group'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="groups-list-heading">
<div className="section-heading">
<h2 id="groups-list-heading">All Groups</h2>
</div>
<div className="plant-list">
{!isLoading && groups.length === 0 ? (
<p className="empty-state">No groups yet.</p>
) : null}
{groups.map((group) => (
<article className="plant-row" key={group.id}>
<div>
<h3>{group.name}</h3>
<p>{group.plants.length} plants{group.notes ? ` - ${group.notes}` : ''}</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${group.name}`} onClick={() => onEdit(group)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${group.name}`} onClick={() => onDelete(group)}>
<Trash2 size={17} />
</button>
</div>
</article>
))}
</div>
</section>
</>
);
}
+43 -1
View File
@@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CalendarCheck, Plus } from 'lucide-react'; import { CalendarCheck, Plus } from 'lucide-react';
import type { CareTask, Plant } from '../domain'; import type { CareTask, Plant, PlantGroup } from '../domain';
import { PlantCard } from './PlantCard'; import { PlantCard } from './PlantCard';
type HomeViewProps = { type HomeViewProps = {
careTasks: CareTask[]; careTasks: CareTask[];
dueCount: number; dueCount: number;
error: string | null; error: string | null;
groups: PlantGroup[];
isLoading: boolean; isLoading: boolean;
plants: Plant[]; plants: Plant[];
onCompleteBulkTasks: (tasks: CareTask[]) => void; onCompleteBulkTasks: (tasks: CareTask[]) => void;
@@ -19,6 +20,7 @@ export function HomeView({
careTasks, careTasks,
dueCount, dueCount,
error, error,
groups,
isLoading, isLoading,
plants, plants,
onCompleteBulkTasks, onCompleteBulkTasks,
@@ -31,6 +33,7 @@ export function HomeView({
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date())); const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date()));
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0]; const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0];
const selectedTasks = getTasksForDate(careTasks, selectedDate); const selectedTasks = getTasksForDate(careTasks, selectedDate);
const groupDueTaskGroups = getGroupDueTaskGroups(careTasks, groups);
return ( return (
<> <>
@@ -136,6 +139,31 @@ export function HomeView({
</> </>
) : null} ) : null}
{groupDueTaskGroups.length > 0 ? (
<>
<p className="task-list-label">Group actions</p>
{groupDueTaskGroups.map((group) => (
<article className="task-row task-row-group" key={`${group.groupId}-${group.careActivityId}`}>
<span className="status-dot due" />
<div>
<h3>{group.groupName} / {group.action}</h3>
<p>
{group.tasks.length} due - {formatPlantNames(group.tasks)}
</p>
</div>
<button
className="small-action"
type="button"
onClick={() => onCompleteBulkTasks(group.tasks)}
>
<CalendarCheck size={16} />
Log group
</button>
</article>
))}
</>
) : null}
{careTasks.length > 0 ? ( {careTasks.length > 0 ? (
<> <>
<p className="task-list-label">Individual tasks</p> <p className="task-list-label">Individual tasks</p>
@@ -206,6 +234,20 @@ function groupDueTasks(tasks: CareTask[]) {
return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action)); return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action));
} }
function getGroupDueTaskGroups(tasks: CareTask[], groups: PlantGroup[]) {
const dueTasks = tasks.filter((task) => task.status === 'due');
return groups.flatMap((group) => {
const groupPlantIds = new Set(group.plants.map((plant) => plant.id));
return groupDueTasks(dueTasks.filter((task) => groupPlantIds.has(task.plantId)))
.map((taskGroup) => ({
...taskGroup,
groupId: group.id,
groupName: group.name,
}));
});
}
function formatPlantNames(tasks: CareTask[]) { function formatPlantNames(tasks: CareTask[]) {
const names = tasks.map((task) => task.plantName); const names = tasks.map((task) => task.plantName);
if (names.length <= 3) { if (names.length <= 3) {
@@ -30,6 +30,15 @@ export function PlantCard({ plant, onOpen }: PlantCardProps) {
))} ))}
</div> </div>
) : null} ) : null}
{plant.groups.length > 0 ? (
<div className="flag-list">
{plant.groups.map((group) => (
<span className="flag-chip group-chip" key={group.id}>
{group.name}
</span>
))}
</div>
) : null}
</div> </div>
<dl> <dl>
<div> <div>
@@ -93,6 +93,19 @@ export function PlantManagementView({
{selectedPlant ? ( {selectedPlant ? (
<div className="plant-detail-grid"> <div className="plant-detail-grid">
<section className="detail-section detail-section-wide" aria-labelledby="plant-management-life">
<div className="detail-section-heading">
<Check size={17} />
<h3 id="plant-management-life">Plant History</h3>
</div>
<div className="plant-detail-meta compact-meta">
<div>
<span>Birthday</span>
<strong>{selectedPlant.birthday ?? 'Not set'}</strong>
</div>
</div>
</section>
<section className="detail-section" aria-labelledby="plant-management-taxa"> <section className="detail-section" aria-labelledby="plant-management-taxa">
<div className="detail-section-heading"> <div className="detail-section-heading">
<Tags size={17} /> <Tags size={17} />
+117 -1
View File
@@ -1,4 +1,4 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react'; import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { Plant } from '../domain'; import type { Plant } from '../domain';
import type { PlantFormState } from '../form-state'; import type { PlantFormState } from '../form-state';
@@ -10,11 +10,14 @@ type PlantsViewProps = {
isPlantEditorOpen: boolean; isPlantEditorOpen: boolean;
isSaving: boolean; isSaving: boolean;
plants: Plant[]; plants: Plant[];
selectedPlant?: Plant;
onCancel: () => void; onCancel: () => void;
onCloseDetail: () => void;
onDelete: (plant: Plant) => void; onDelete: (plant: Plant) => void;
onEdit: (plant: Plant) => void; onEdit: (plant: Plant) => void;
onFieldChange: (field: keyof PlantFormState, value: string) => void; onFieldChange: (field: keyof PlantFormState, value: string) => void;
onNew: () => void; onNew: () => void;
onOpenDetail: (plant: Plant) => void;
onSave: () => void; onSave: () => void;
}; };
@@ -26,11 +29,14 @@ export function PlantsView({
isPlantEditorOpen, isPlantEditorOpen,
isSaving, isSaving,
plants, plants,
selectedPlant,
onCancel, onCancel,
onCloseDetail,
onDelete, onDelete,
onEdit, onEdit,
onFieldChange, onFieldChange,
onNew, onNew,
onOpenDetail,
onSave, onSave,
}: PlantsViewProps) { }: PlantsViewProps) {
return ( return (
@@ -69,6 +75,14 @@ export function PlantsView({
onChange={(event) => onFieldChange('nickname', event.target.value)} onChange={(event) => onFieldChange('nickname', event.target.value)}
/> />
</label> </label>
<label>
Birthday
<input
type="date"
value={form.birthday}
onChange={(event) => onFieldChange('birthday', event.target.value)}
/>
</label>
</div> </div>
<div className="form-actions"> <div className="form-actions">
@@ -83,6 +97,53 @@ export function PlantsView({
</section> </section>
) : null} ) : null}
{selectedPlant && !isPlantEditorOpen ? (
<section className="editor-panel" aria-labelledby="plant-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Plant detail</p>
<h2 id="plant-detail-heading">{selectedPlant.nickname}</h2>
</div>
<div className="row-actions">
<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={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta compact-meta">
<div>
<span>Birthday</span>
<strong>{selectedPlant.birthday ?? 'Not set'}</strong>
</div>
<div>
<span>Location</span>
<strong>{selectedPlant.location}</strong>
</div>
<div>
<span>Taxon</span>
<strong>{selectedPlant.taxon}</strong>
</div>
<div>
<span>Next care</span>
<strong>{selectedPlant.nextCare}</strong>
</div>
<div className="meta-wide">
<span>Groups</span>
<strong>{selectedPlant.groups.length > 0 ? selectedPlant.groups.map((group) => group.name).join(', ') : 'None'}</strong>
</div>
</div>
<div className="detail-section-heading">
<h3>Timeline</h3>
</div>
<PlantTimeline plant={selectedPlant} />
</section>
) : null}
<section className="section" aria-labelledby="plants-list-heading"> <section className="section" aria-labelledby="plants-list-heading">
<div className="section-heading"> <div className="section-heading">
<h2 id="plants-list-heading">All Plants</h2> <h2 id="plants-list-heading">All Plants</h2>
@@ -97,8 +158,15 @@ export function PlantsView({
<article className="plant-row" key={plant.id}> <article className="plant-row" key={plant.id}>
<div> <div>
<h3>{plant.nickname}</h3> <h3>{plant.nickname}</h3>
<p>
{plant.birthday ? `Birthday ${plant.birthday}` : 'No birthday'}
{plant.groups.length > 0 ? ` - ${plant.groups.map((group) => group.name).join(', ')}` : ''}
</p>
</div> </div>
<div className="row-actions"> <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)}> <button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
<Edit3 size={17} /> <Edit3 size={17} />
</button> </button>
@@ -113,3 +181,51 @@ export function PlantsView({
</> </>
); );
} }
function PlantTimeline({ plant }: { plant: Plant }) {
const timelineItems = [
...(plant.birthday ? [{
date: plant.birthday,
title: 'Birthday',
detail: `${plant.nickname} joined the collection.`,
}] : []),
...plant.actionLogs.map((log) => ({
date: log.performedOn,
title: log.action,
detail: formatLogDetail(log),
})),
].sort((left, right) => right.date.localeCompare(left.date));
if (timelineItems.length === 0) {
return <p className="empty-state">No timeline entries yet.</p>;
}
return (
<div className="timeline-list">
{timelineItems.map((item, index) => (
<article className="timeline-item" key={`${item.date}-${item.title}-${index}`}>
<time>{item.date}</time>
<div>
<h4>{item.title}</h4>
<p>{item.detail}</p>
</div>
</article>
))}
</div>
);
}
function formatLogDetail(log: Plant['actionLogs'][number]) {
const resources = log.resources.map((resource) => {
const amount = resource.quantity === null
? ''
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
return `${resource.name}${amount}`;
});
const parts = [
log.notes,
resources.length > 0 ? resources.join(', ') : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(' - ') : 'Care logged.';
}
@@ -0,0 +1,560 @@
import { useState } from 'react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { ActionResource, Recipe } from '../domain';
import type { RecipeFormState } from '../form-state';
type RecipesViewProps = {
activeRecipeName?: string;
error: string | null;
form: RecipeFormState;
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
recipes: Recipe[];
resources: ActionResource[];
selectedRecipe?: Recipe;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (recipe: Recipe) => void;
onEdit: (recipe: Recipe) => void;
onFieldChange: <Field extends keyof RecipeFormState>(
field: Field,
value: RecipeFormState[Field],
) => void;
onNew: () => void;
onOpenDetail: (recipe: Recipe) => void;
onSave: () => void;
};
export function RecipesView({
activeRecipeName,
error,
form,
isEditorOpen,
isLoading,
isSaving,
recipes,
resources,
selectedRecipe,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: RecipesViewProps) {
const [calculatorAmounts, setCalculatorAmounts] = useState<Record<number, string>>({});
function updateComponent(index: number, nextComponent: RecipeFormState['components'][number]) {
onFieldChange(
'components',
form.components.map((component, componentIndex) => componentIndex === index ? nextComponent : component),
);
}
function addComponent() {
onFieldChange('components', [
...form.components,
{ actionResourceId: '', quantity: '', unit: '', notes: '' },
]);
}
function hasIncompleteComponents() {
if (form.components.some((component) => !component.actionResourceId)) {
return true;
}
if (form.measurementMode === 'quantity') {
return false;
}
if (form.components.some((component) => !component.quantity.trim())) {
return true;
}
if (form.measurementMode === 'total_percent') {
return getPercentTotal(form) !== 100;
}
return !form.components.some((component) => Number(component.quantity) === 100);
}
const isPercentRecipe = form.measurementMode !== 'quantity';
const amountHeading = getAmountHeading(form.measurementMode);
const selectedRecipeCalculatorAmount = selectedRecipe
? calculatorAmounts[selectedRecipe.id] ?? ''
: '';
return (
<>
<section className="summary-panel" aria-labelledby="recipes-summary-heading">
<div>
<p className="eyebrow">Recipe catalog</p>
<h2 id="recipes-summary-heading">
{isLoading ? 'Loading recipes' : `${recipes.length} recipes`}
</h2>
<p>{error ?? 'Build reusable mixes and solutions, then create the resource they produce.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New recipe
</button>
</section>
{selectedRecipe && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="recipe-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Recipe detail</p>
<h2 id="recipe-detail-heading">{selectedRecipe.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedRecipe.name}`} onClick={() => onEdit(selectedRecipe)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close recipe detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta compact-meta">
<div>
<span>Type</span>
<strong>{selectedRecipe.type}</strong>
</div>
<div>
<span>Mode</span>
<strong>{formatMeasurementMode(selectedRecipe.measurementMode)}</strong>
</div>
<div>
<span>Output resource</span>
<strong>{selectedRecipe.outputResource?.name ?? 'None'}</strong>
</div>
<div>
<span>Components</span>
<strong>{selectedRecipe.components.length}</strong>
</div>
<div className="meta-wide">
<span>Procedure</span>
<strong className="preserve-lines">{selectedRecipe.notes ?? 'No procedure'}</strong>
</div>
</div>
<RecipeDetailTable
amount={selectedRecipeCalculatorAmount}
recipe={selectedRecipe}
onAmountChange={(nextAmount) => setCalculatorAmounts((current) => ({
...current,
[selectedRecipe.id]: nextAmount,
}))}
/>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="recipe-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activeRecipeName ? 'Editing' : 'New recipe'}</p>
<h2 id="recipe-editor-heading">{activeRecipeName ?? 'Recipe 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>
Type
<select
value={form.type}
onChange={(event) => onFieldChange('type', event.target.value)}
>
<option value="Soil mixture">Soil mixture</option>
<option value="Fertilizer solution">Fertilizer solution</option>
<option value="Treatment">Treatment</option>
<option value="Other">Other</option>
</select>
</label>
<label>
Mode
<select
value={form.measurementMode}
onChange={(event) => onFieldChange('measurementMode', event.target.value as RecipeFormState['measurementMode'])}
>
<option value="quantity">Quantity</option>
<option value="total_percent">Percent of total</option>
<option value="bakers_percent">Percent of base</option>
</select>
</label>
<label>
Produced resource
<input
value={form.outputResourceName}
onChange={(event) => onFieldChange('outputResourceName', event.target.value)}
/>
</label>
<fieldset className="resource-picker schedule-picker">
<legend>Components</legend>
{form.components.length === 0 ? (
<p className="empty-state">No components configured.</p>
) : (
<div className={`activity-resource-header recipe-resource-header ${isPercentRecipe ? 'recipe-resource-row-percent' : ''}`} aria-hidden="true">
<span>Resource</span>
<span>{amountHeading}</span>
{isPercentRecipe ? null : <span>Unit</span>}
<span>Notes</span>
<span />
</div>
)}
{form.components.map((component, componentIndex) => {
const selectedResourceIds = new Set(
form.components
.filter((_, index) => index !== componentIndex)
.map((item) => item.actionResourceId),
);
return (
<div className={`activity-resource-row recipe-resource-row ${isPercentRecipe ? 'recipe-resource-row-percent' : ''}`} key={`${component.actionResourceId}-${componentIndex}`}>
<select
aria-label="Component resource"
value={component.actionResourceId}
onChange={(event) => updateComponent(
componentIndex,
{ ...component, actionResourceId: event.target.value },
)}
>
<option value="">Select a resource</option>
{resources
.filter((resource) => !selectedResourceIds.has(String(resource.id)))
.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
<input
aria-label={amountHeading}
min="0"
step="0.01"
type="number"
value={component.quantity}
onChange={(event) => updateComponent(
componentIndex,
{ ...component, quantity: event.target.value },
)}
/>
{isPercentRecipe ? null : (
<input
aria-label="Unit"
value={component.unit}
onChange={(event) => updateComponent(
componentIndex,
{ ...component, unit: event.target.value },
)}
/>
)}
<input
aria-label="Component notes"
value={component.notes}
onChange={(event) => updateComponent(
componentIndex,
{ ...component, notes: event.target.value },
)}
/>
<button
className="icon-button compact danger"
type="button"
aria-label="Remove component"
onClick={() => onFieldChange(
'components',
form.components.filter((_, index) => index !== componentIndex),
)}
>
<Trash2 size={17} />
</button>
</div>
);
})}
<button
className="small-action"
type="button"
disabled={resources.length === 0}
onClick={addComponent}
>
<Plus size={16} />
Add component
</button>
{form.measurementMode === 'total_percent' ? (
<p className="schedule-hint">Total: {getPercentTotal(form)}%</p>
) : null}
</fieldset>
<label className="form-wide">
Procedure
<textarea
value={form.notes}
onChange={(event) => onFieldChange('notes', event.target.value)}
/>
</label>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || !form.outputResourceName.trim() || form.components.length === 0 || hasIncompleteComponents()} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save recipe'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="recipes-list-heading">
<div className="section-heading">
<h2 id="recipes-list-heading">All Recipes</h2>
</div>
<div className="plant-list">
{!isLoading && recipes.length === 0 ? (
<p className="empty-state">No recipes yet.</p>
) : null}
{recipes.map((recipe) => (
<article className="plant-row" key={recipe.id}>
<div>
<h3>{recipe.name}</h3>
<p>{recipe.type} - {formatMeasurementMode(recipe.measurementMode)} - {formatRecipeSummary(recipe)}</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${recipe.name}`} onClick={() => onOpenDetail(recipe)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${recipe.name}`} onClick={() => onEdit(recipe)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${recipe.name}`} onClick={() => onDelete(recipe)}>
<Trash2 size={17} />
</button>
</div>
</article>
))}
</div>
</section>
</>
);
}
function formatRecipeSummary(recipe: Recipe) {
if (recipe.components.length === 0) {
return 'No components';
}
return recipe.components
.map((component) => `${component.name}${formatComponentAmount(component.quantity, component.unit, recipe.measurementMode, true)}`)
.join(', ');
}
function RecipeDetailTable({
amount,
recipe,
onAmountChange,
}: {
amount: string;
recipe: Recipe;
onAmountChange: (amount: string) => void;
}) {
if (recipe.components.length === 0) {
return (
<div className="detail-list">
<p className="empty-state">No components configured.</p>
</div>
);
}
const sortedComponents = [...recipe.components].sort((left, right) => {
if (recipe.measurementMode === 'bakers_percent') {
if (left.quantity === 100 && right.quantity !== 100) {
return -1;
}
if (right.quantity === 100 && left.quantity !== 100) {
return 1;
}
}
return left.sortOrder - right.sortOrder;
});
const baseComponent = sortedComponents.find((component) => component.quantity === 100);
const numericAmount = Number(amount);
const canCalculate = amount.trim() !== '' && Number.isFinite(numericAmount);
if (recipe.measurementMode === 'quantity') {
return (
<div className="recipe-table" role="table" aria-label="Recipe components">
<div className="recipe-table-row recipe-table-heading" role="row">
<span role="columnheader">Ingredient / Resource</span>
<span role="columnheader">Quantity</span>
<span role="columnheader">Notes</span>
</div>
{sortedComponents.map((component) => (
<div className="recipe-table-row" role="row" key={component.actionResourceId}>
<span role="cell">{component.name}</span>
<span role="cell">{formatComponentAmount(component.quantity, component.unit, recipe.measurementMode)}</span>
<span role="cell">{component.notes ?? 'No notes'}</span>
</div>
))}
</div>
);
}
if (recipe.measurementMode === 'total_percent') {
return (
<div className="recipe-calculator">
<label className="recipe-calculator-input">
Total amount
<input
min="0"
step="0.01"
type="number"
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
/>
</label>
<PercentRecipeTable
getCalculatedAmount={(component) => canCalculate && component.quantity !== null
? numericAmount * (component.quantity / 100)
: null}
recipe={recipe}
components={sortedComponents}
/>
</div>
);
}
return (
<div className="recipe-calculator">
<div className="recipe-table" role="table" aria-label="Recipe calculator">
<div className="recipe-table-row recipe-table-heading" role="row">
<span role="columnheader">Ingredient / Resource</span>
<span role="columnheader">Percent</span>
<span role="columnheader">Amount</span>
</div>
{sortedComponents.map((component) => {
const isBase = baseComponent?.actionResourceId === component.actionResourceId;
const calculatedAmount = canCalculate && component.quantity !== null
? numericAmount * (component.quantity / 100)
: null;
return (
<div className="recipe-table-row" role="row" key={component.actionResourceId}>
<span role="cell">{component.name}</span>
<span role="cell">{formatComponentAmount(component.quantity, component.unit, recipe.measurementMode)}</span>
<span role="cell">
{isBase ? (
<input
aria-label={`${component.name} base amount`}
min="0"
step="0.01"
type="number"
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
/>
) : formatCalculatedAmount(calculatedAmount)}
</span>
</div>
);
})}
</div>
</div>
);
}
function PercentRecipeTable({
components,
getCalculatedAmount,
recipe,
}: {
components: Recipe['components'];
getCalculatedAmount: (component: Recipe['components'][number]) => number | null;
recipe: Recipe;
}) {
return (
<div className="recipe-table" role="table" aria-label="Recipe calculator">
<div className="recipe-table-row recipe-table-heading" role="row">
<span role="columnheader">Ingredient / Resource</span>
<span role="columnheader">Percent</span>
<span role="columnheader">Amount</span>
</div>
{components.map((component) => (
<div className="recipe-table-row" role="row" key={component.actionResourceId}>
<span role="cell">{component.name}</span>
<span role="cell">{formatComponentAmount(component.quantity, component.unit, recipe.measurementMode)}</span>
<span role="cell">{formatCalculatedAmount(getCalculatedAmount(component))}</span>
</div>
))}
</div>
);
}
function formatComponentAmount(
quantity: number | null,
unit: string | null,
measurementMode: Recipe['measurementMode'],
compact = false,
) {
if (quantity === null) {
return compact ? '' : 'No value';
}
const suffix = measurementMode === 'quantity'
? unit ? ` ${unit}` : ''
: '%';
return `${compact ? ' (' : ''}${quantity}${suffix}${compact ? ')' : ''}`;
}
function formatMeasurementMode(measurementMode: Recipe['measurementMode']) {
switch (measurementMode) {
case 'total_percent':
return 'Percent of total';
case 'bakers_percent':
return 'Percent of base';
default:
return 'Quantity';
}
}
function getAmountHeading(measurementMode: RecipeFormState['measurementMode']) {
switch (measurementMode) {
case 'total_percent':
return '% of total';
case 'bakers_percent':
return '% of base';
default:
return 'Qty';
}
}
function getPercentTotal(form: RecipeFormState) {
return form.components.reduce((total, component) => total + Number(component.quantity || 0), 0);
}
function formatCalculatedAmount(amount: number | null) {
if (amount === null) {
return '';
}
return Number.isInteger(amount) ? String(amount) : amount.toFixed(2);
}
@@ -76,6 +76,12 @@ export function ResourcesView({
<span>Notes</span> <span>Notes</span>
<strong>{selectedResource.notes ?? 'No notes'}</strong> <strong>{selectedResource.notes ?? 'No notes'}</strong>
</div> </div>
{selectedResource.producedByRecipe ? (
<div>
<span>Produced by recipe</span>
<strong>{selectedResource.producedByRecipe.name}</strong>
</div>
) : null}
</div> </div>
</section> </section>
) : null} ) : null}
@@ -136,7 +142,9 @@ export function ResourcesView({
<div> <div>
<h3>{resource.name}</h3> <h3>{resource.name}</h3>
<p> <p>
{resource.notes ?? 'No notes'} {resource.producedByRecipe
? `Produced by ${resource.producedByRecipe.name}`
: resource.notes ?? 'No notes'}
</p> </p>
</div> </div>
<div className="row-actions"> <div className="row-actions">
@@ -1,12 +1,13 @@
import { CalendarClock, Save, Trash2 } from 'lucide-react'; import { CalendarClock, Save, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { CareActivity, Plant } from '../domain'; import type { CareActivity, Plant, PlantGroup } from '../domain';
import type { BulkScheduleFormState } from '../form-state'; import type { BulkScheduleFormState } from '../form-state';
type SchedulesViewProps = { type SchedulesViewProps = {
activities: CareActivity[]; activities: CareActivity[];
error: string | null; error: string | null;
form: BulkScheduleFormState; form: BulkScheduleFormState;
groups: PlantGroup[];
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
plants: Plant[]; plants: Plant[];
@@ -38,6 +39,7 @@ export function SchedulesView({
activities, activities,
error, error,
form, form,
groups,
isLoading, isLoading,
isSaving, isSaving,
plants, plants,
@@ -46,12 +48,14 @@ export function SchedulesView({
onSave, onSave,
}: SchedulesViewProps) { }: SchedulesViewProps) {
const [plantQuery, setPlantQuery] = useState(''); const [plantQuery, setPlantQuery] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState('');
const visiblePlants = useMemo( const visiblePlants = useMemo(
() => filterPlants(plants, plantQuery), () => filterPlants(plants, plantQuery),
[plants, plantQuery], [plants, plantQuery],
); );
const selectedPlantIds = new Set(form.plantIds); const selectedPlantIds = new Set(form.plantIds);
const selectedPlants = plants.filter((plant) => selectedPlantIds.has(String(plant.id))); const selectedPlants = plants.filter((plant) => selectedPlantIds.has(String(plant.id)));
const selectedGroup = groups.find((group) => String(group.id) === selectedGroupId);
const selectedActivity = activities.find((activity) => String(activity.id) === form.careActivityId); const selectedActivity = activities.find((activity) => String(activity.id) === form.careActivityId);
const preview = selectedActivity ? formatSchedulePreview(selectedActivity.name, form) : ''; const preview = selectedActivity ? formatSchedulePreview(selectedActivity.name, form) : '';
const allVisibleSelected = visiblePlants.length > 0 && visiblePlants.every((plant) => selectedPlantIds.has(String(plant.id))); const allVisibleSelected = visiblePlants.length > 0 && visiblePlants.every((plant) => selectedPlantIds.has(String(plant.id)));
@@ -79,7 +83,7 @@ export function SchedulesView({
</div> </div>
<div className="schedule-count"> <div className="schedule-count">
<CalendarClock size={16} /> <CalendarClock size={16} />
<span>{selectedPlants.length} selected</span> <span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
</div> </div>
</div> </div>
@@ -108,6 +112,27 @@ export function SchedulesView({
onChange={(event) => onFieldChange('scheduledFor', event.target.value)} onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
/> />
</label> </label>
<label>
Target group
<select
disabled={isSaving}
value={selectedGroupId}
onChange={(event) => {
const groupId = event.target.value;
const group = groups.find((item) => String(item.id) === groupId);
setSelectedGroupId(groupId);
setPlantQuery('');
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
}}
>
<option value="">Individual plants</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.plants.length})
</option>
))}
</select>
</label>
</div> </div>
<fieldset className="schedule-options"> <fieldset className="schedule-options">
@@ -247,12 +272,15 @@ export function SchedulesView({
className="text-button" className="text-button"
type="button" type="button"
disabled={isSaving} disabled={isSaving}
onClick={() => onFieldChange( onClick={() => {
setSelectedGroupId('');
onFieldChange(
'plantIds', 'plantIds',
allVisibleSelected allVisibleSelected
? form.plantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id)) ? form.plantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
: [...new Set([...form.plantIds, ...visiblePlants.map((plant) => String(plant.id))])], : [...new Set([...form.plantIds, ...visiblePlants.map((plant) => String(plant.id))])],
)} );
}}
> >
{allVisibleSelected ? 'Clear visible' : 'Select visible'} {allVisibleSelected ? 'Clear visible' : 'Select visible'}
</button> </button>
@@ -291,6 +319,7 @@ export function SchedulesView({
const nextPlantIds = event.target.checked const nextPlantIds = event.target.checked
? [...form.plantIds, plantId] ? [...form.plantIds, plantId]
: form.plantIds.filter((id) => id !== plantId); : form.plantIds.filter((id) => id !== plantId);
setSelectedGroupId('');
onFieldChange('plantIds', nextPlantIds); onFieldChange('plantIds', nextPlantIds);
}} }}
/> />
@@ -303,11 +332,27 @@ export function SchedulesView({
</div> </div>
<div className="form-actions"> <div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId} onClick={onSave}> <button
className="primary-action"
type="button"
disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId}
onClick={() => {
onSave();
setSelectedGroupId('');
}}
>
<Save size={18} /> <Save size={18} />
{isSaving ? 'Saving' : `Apply to ${form.plantIds.length} plants`} {isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`}
</button> </button>
<button className="text-button danger" type="button" disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId} onClick={onRemove}> <button
className="text-button danger"
type="button"
disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId}
onClick={() => {
onRemove();
setSelectedGroupId('');
}}
>
<Trash2 size={16} /> <Trash2 size={16} />
Remove from {form.plantIds.length} plants Remove from {form.plantIds.length} plants
</button> </button>
+95
View File
@@ -3,6 +3,7 @@ export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled';
export type Plant = { export type Plant = {
id: number; id: number;
nickname: string; nickname: string;
birthday: string | null;
taxonId: number | null; taxonId: number | null;
taxon: string; taxon: string;
locationId: number | null; locationId: number | null;
@@ -10,6 +11,8 @@ export type Plant = {
nextCare: string; nextCare: string;
status: CareStatus; status: CareStatus;
flags: PlantFlag[]; flags: PlantFlag[];
groups: PlantGroupSummary[];
actionLogs: ActionLog[];
careSchedules: PlantCareSchedule[]; careSchedules: PlantCareSchedule[];
}; };
@@ -36,6 +39,7 @@ export type PlantCareSchedule = {
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year'; export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
export type ScheduleEndsMode = 'on' | 'after'; export type ScheduleEndsMode = 'on' | 'after';
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
export type PlantTaxon = { export type PlantTaxon = {
id: number; id: number;
@@ -53,6 +57,23 @@ export type PlantLocation = {
notes: string | null; notes: string | null;
}; };
export type PlantGroupSummary = {
id: number;
name: string;
};
export type PlantGroupMember = {
id: number;
nickname: string;
};
export type PlantGroup = {
id: number;
name: string;
plants: PlantGroupMember[];
notes: string | null;
};
export type CareAction = { export type CareAction = {
id: number; id: number;
name: string; name: string;
@@ -63,6 +84,38 @@ export type ActionResource = {
id: number; id: number;
name: string; name: string;
notes: string | null; notes: string | null;
producedByRecipe: RecipeSummary | null;
};
export type ActionResourceSummary = {
id: number;
name: string;
};
export type RecipeSummary = {
id: number;
name: string;
type: string;
measurementMode: RecipeMeasurementMode;
};
export type Recipe = {
id: number;
name: string;
type: string;
measurementMode: RecipeMeasurementMode;
outputResource: ActionResourceSummary | null;
components: RecipeComponent[];
notes: string | null;
};
export type RecipeComponent = {
actionResourceId: number;
name: string;
quantity: number | null;
unit: string | null;
notes: string | null;
sortOrder: number;
}; };
export type CareActivity = { export type CareActivity = {
@@ -106,8 +159,28 @@ export type PlantFlag = {
notes: string | null; notes: string | null;
}; };
export type ActionLog = {
id: number;
plantId: number;
plantName: string;
careActivityId: number;
careActionId: number;
action: string;
notes: string | null;
performedOn: string;
resources: ActionLogResource[];
};
export type ActionLogResource = {
actionResourceId: number;
name: string;
quantity: number | null;
unit: string | null;
};
export type PlantPayload = { export type PlantPayload = {
nickname: string; nickname: string;
birthday: string | null;
taxonId: number | null; taxonId: number | null;
locationId: number | null; locationId: number | null;
careSchedules: PlantCareSchedulePayload[] | null; careSchedules: PlantCareSchedulePayload[] | null;
@@ -154,6 +227,12 @@ export type PlantLocationPayload = {
notes: string | null; notes: string | null;
}; };
export type PlantGroupPayload = {
name: string;
plantIds: number[];
notes: string | null;
};
export type CareActionPayload = { export type CareActionPayload = {
name: string; name: string;
description: string | null; description: string | null;
@@ -182,6 +261,22 @@ export type CareActivityActionResourcePayload = {
notes: string | null; notes: string | null;
}; };
export type RecipePayload = {
name: string;
type: string;
measurementMode: RecipeMeasurementMode;
outputResourceName: string | null;
components: RecipeComponentPayload[];
notes: string | null;
};
export type RecipeComponentPayload = {
actionResourceId: number;
quantity: number | null;
unit: string | null;
notes: string | null;
};
export type PlantFlagDefinitionPayload = { export type PlantFlagDefinitionPayload = {
name: string; name: string;
color: string | null; color: string | null;
+81 -1
View File
@@ -6,13 +6,18 @@ import type {
CareAction, CareAction,
CareActionPayload, CareActionPayload,
BulkPlantCareSchedulePayload, BulkPlantCareSchedulePayload,
Recipe,
RecipePayload,
Plant, Plant,
AssignPlantFlagPayload, AssignPlantFlagPayload,
PlantPayload, PlantPayload,
PlantFlagDefinition, PlantFlagDefinition,
PlantFlagDefinitionPayload, PlantFlagDefinitionPayload,
PlantGroup,
PlantGroupPayload,
PlantLocation, PlantLocation,
PlantLocationPayload, PlantLocationPayload,
RecipeMeasurementMode,
ScheduleEndsMode, ScheduleEndsMode,
ScheduleRecurrenceMode, ScheduleRecurrenceMode,
ScheduleRepeatUnit, ScheduleRepeatUnit,
@@ -22,6 +27,7 @@ import type {
export const emptyPlantForm = { export const emptyPlantForm = {
nickname: '', nickname: '',
birthday: '',
}; };
export const emptyTaxonForm = { export const emptyTaxonForm = {
@@ -38,6 +44,12 @@ export const emptyLocationForm = {
notes: '', notes: '',
}; };
export const emptyPlantGroupForm = {
name: '',
plantIds: [] as string[],
notes: '',
};
export const emptyActionForm = { export const emptyActionForm = {
name: '', name: '',
description: '', description: '',
@@ -54,6 +66,15 @@ export const emptyActivityForm = {
notes: '', notes: '',
}; };
export const emptyRecipeForm = {
name: '',
type: 'Soil mixture',
measurementMode: 'quantity' as RecipeMeasurementMode,
outputResourceName: '',
components: [] as RecipeComponentFormState[],
notes: '',
};
export type CareActivityActionResourceFormState = { export type CareActivityActionResourceFormState = {
actionResourceId: string; actionResourceId: string;
quantity: string; quantity: string;
@@ -66,6 +87,13 @@ export type CareActivityActionFormState = {
resources: CareActivityActionResourceFormState[]; resources: CareActivityActionResourceFormState[];
}; };
export type RecipeComponentFormState = {
actionResourceId: string;
quantity: string;
unit: string;
notes: string;
};
export const emptyFlagDefinitionForm = { export const emptyFlagDefinitionForm = {
name: '', name: '',
color: '#f2f2f2', color: '#f2f2f2',
@@ -94,23 +122,27 @@ export const emptyBulkScheduleForm = {
export type PlantFormState = typeof emptyPlantForm; export type PlantFormState = typeof emptyPlantForm;
export type TaxonFormState = typeof emptyTaxonForm; export type TaxonFormState = typeof emptyTaxonForm;
export type LocationFormState = typeof emptyLocationForm; export type LocationFormState = typeof emptyLocationForm;
export type PlantGroupFormState = typeof emptyPlantGroupForm;
export type ActionFormState = typeof emptyActionForm; export type ActionFormState = typeof emptyActionForm;
export type ResourceFormState = typeof emptyResourceForm; export type ResourceFormState = typeof emptyResourceForm;
export type ActivityFormState = typeof emptyActivityForm; export type ActivityFormState = typeof emptyActivityForm;
export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm; export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm; export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type BulkScheduleFormState = typeof emptyBulkScheduleForm; export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'actions' | 'resources' | 'activities' | 'flags'; export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags';
export function toPlantForm(plant: Plant): PlantFormState { export function toPlantForm(plant: Plant): PlantFormState {
return { return {
nickname: plant.nickname, nickname: plant.nickname,
birthday: plant.birthday ?? '',
}; };
} }
export function toPlantPayload(form: PlantFormState): PlantPayload { export function toPlantPayload(form: PlantFormState): PlantPayload {
return { return {
nickname: form.nickname.trim(), nickname: form.nickname.trim(),
birthday: form.birthday || null,
taxonId: null, taxonId: null,
locationId: null, locationId: null,
careSchedules: null, careSchedules: null,
@@ -131,6 +163,22 @@ export function toLocationPayload(form: LocationFormState): PlantLocationPayload
}; };
} }
export function toPlantGroupForm(group: PlantGroup): PlantGroupFormState {
return {
name: group.name,
plantIds: group.plants.map((plant) => String(plant.id)),
notes: group.notes ?? '',
};
}
export function toPlantGroupPayload(form: PlantGroupFormState): PlantGroupPayload {
return {
name: form.name.trim(),
plantIds: form.plantIds.map((id) => Number(id)),
notes: form.notes.trim() || null,
};
}
export function toTaxonForm(taxon: PlantTaxon): TaxonFormState { export function toTaxonForm(taxon: PlantTaxon): TaxonFormState {
return { return {
name: taxon.name, name: taxon.name,
@@ -213,6 +261,38 @@ export function toActivityPayload(form: ActivityFormState): CareActivityPayload
}; };
} }
export function toRecipeForm(recipe: Recipe): RecipeFormState {
return {
name: recipe.name,
type: recipe.type,
measurementMode: recipe.measurementMode,
outputResourceName: recipe.outputResource?.name ?? '',
components: recipe.components.map((component) => ({
actionResourceId: String(component.actionResourceId),
quantity: component.quantity === null ? '' : String(component.quantity),
unit: component.unit ?? '',
notes: component.notes ?? '',
})),
notes: recipe.notes ?? '',
};
}
export function toRecipePayload(form: RecipeFormState): RecipePayload {
return {
name: form.name.trim(),
type: form.type.trim(),
measurementMode: form.measurementMode,
outputResourceName: form.outputResourceName.trim() || null,
components: form.components.map((component) => ({
actionResourceId: Number(component.actionResourceId),
quantity: component.quantity.trim() ? Number(component.quantity) : null,
unit: form.measurementMode === 'quantity' ? component.unit.trim() || null : '%',
notes: component.notes.trim() || null,
})),
notes: form.notes.trim() || null,
};
}
export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState { export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState {
return { return {
name: flag.name, name: flag.name,
+124
View File
@@ -754,6 +754,7 @@ dd {
.plant-form input, .plant-form input,
.plant-form select, .plant-form select,
.plant-form textarea,
.flag-assignment-form input, .flag-assignment-form input,
.flag-assignment-form select { .flag-assignment-form select {
width: 100%; width: 100%;
@@ -766,6 +767,21 @@ dd {
box-shadow: none; box-shadow: none;
} }
.plant-form textarea {
min-height: 112px;
padding: 8px;
line-height: 1.35;
resize: vertical;
}
.plant-form .form-wide {
grid-column: 1 / -1;
}
.preserve-lines {
white-space: pre-wrap;
}
.plant-form input[type='color'] { .plant-form input[type='color'] {
padding: 2px; padding: 2px;
} }
@@ -901,6 +917,10 @@ dd {
padding: 0; padding: 0;
} }
.recipe-resource-row-percent {
grid-template-columns: minmax(160px, 1.2fr) minmax(70px, 90px) minmax(140px, 1fr) auto;
}
.log-resource-header, .log-resource-header,
.log-resource-row { .log-resource-row {
grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto; grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto;
@@ -1155,6 +1175,100 @@ dd {
padding: 7px 8px; padding: 7px 8px;
} }
.recipe-calculator {
display: grid;
gap: 8px;
}
.recipe-calculator-input {
display: grid;
max-width: 180px;
gap: 4px;
color: #4f5f54;
font-size: 0.78rem;
font-weight: 500;
}
.recipe-calculator-input input,
.recipe-table input {
width: 100%;
min-height: 30px;
padding: 0 8px;
border: 1px solid #cfc4b4;
border-radius: 6px;
background: var(--field);
color: #24342c;
}
.recipe-table {
display: grid;
border-top: 1px solid var(--border);
}
.recipe-table-row {
display: grid;
grid-template-columns: minmax(180px, 1.4fr) minmax(90px, 120px) minmax(110px, 150px);
align-items: center;
gap: 10px;
min-height: 40px;
padding: 7px 8px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.recipe-table-row:nth-child(even) {
background: var(--row);
}
.recipe-table-heading {
min-height: 32px;
background: #f3efe3;
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
text-transform: uppercase;
}
.recipe-table-row span {
min-width: 0;
overflow-wrap: anywhere;
}
.timeline-list {
display: grid;
border-top: 1px solid var(--border);
}
.timeline-item {
display: grid;
grid-template-columns: minmax(92px, 130px) minmax(0, 1fr);
gap: 12px;
min-height: 44px;
padding: 8px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.timeline-item:nth-child(even) {
background: var(--row);
}
.timeline-item time {
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
}
.timeline-item h4 {
margin: 0 0 2px;
}
.timeline-item p {
color: #59675d;
font-size: 0.86rem;
line-height: 1.25;
}
.flag-list, .flag-list,
.flag-assignment-form { .flag-assignment-form {
display: flex; display: flex;
@@ -1322,6 +1436,16 @@ dd {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.recipe-table-row {
grid-template-columns: 1fr;
align-items: start;
}
.timeline-item {
grid-template-columns: 1fr;
gap: 3px;
}
.plant-detail-meta div { .plant-detail-meta div {
border-right: 0; border-right: 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
+137 -2
View File
@@ -4,12 +4,14 @@ namespace plant_manager
{ {
public record CreatePlantRequest( public record CreatePlantRequest(
string Nickname, string Nickname,
DateOnly? Birthday,
int? TaxonId, int? TaxonId,
int? LocationId, int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules); IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
public record UpdatePlantRequest( public record UpdatePlantRequest(
string Nickname, string Nickname,
DateOnly? Birthday,
int? TaxonId, int? TaxonId,
int? LocationId, int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules); IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
@@ -51,6 +53,11 @@ namespace plant_manager
string Name, string Name,
string? Notes); string? Notes);
public record SavePlantGroupRequest(
string Name,
IReadOnlyList<int>? PlantIds,
string? Notes);
public record SaveCareActionRequest( public record SaveCareActionRequest(
string Name, string Name,
string? Description); string? Description);
@@ -74,6 +81,20 @@ namespace plant_manager
string? Unit, string? Unit,
string? Notes); string? Notes);
public record SaveRecipeRequest(
string Name,
string Type,
string? MeasurementMode,
string? OutputResourceName,
IReadOnlyList<SaveRecipeComponentRequest>? Components,
string? Notes);
public record SaveRecipeComponentRequest(
int ActionResourceId,
decimal? Quantity,
string? Unit,
string? Notes);
public record CareActivityActionResourceDto( public record CareActivityActionResourceDto(
int ActionResourceId, int ActionResourceId,
string Name, string Name,
@@ -179,6 +200,39 @@ namespace plant_manager
new(location.Id, location.Name, location.Notes); new(location.Id, location.Name, location.Notes);
} }
public record PlantGroupSummaryDto(
int Id,
string Name)
{
public static PlantGroupSummaryDto FromMembership(PlantGroupMembership membership) =>
new(membership.PlantGroupId, membership.PlantGroup.Name);
}
public record PlantGroupMemberDto(
int Id,
string Nickname)
{
public static PlantGroupMemberDto FromMembership(PlantGroupMembership membership) =>
new(membership.PlantId, membership.Plant.Nickname);
}
public record PlantGroupDto(
int Id,
string Name,
IReadOnlyList<PlantGroupMemberDto> Plants,
string? Notes)
{
public static PlantGroupDto FromGroup(PlantGroup group) =>
new(
group.Id,
group.Name,
group.Memberships
.OrderBy(membership => membership.Plant.Nickname)
.Select(PlantGroupMemberDto.FromMembership)
.ToList(),
group.Notes);
}
public record CareActionDto( public record CareActionDto(
int Id, int Id,
string Name, string Name,
@@ -191,10 +245,35 @@ namespace plant_manager
public record ActionResourceDto( public record ActionResourceDto(
int Id, int Id,
string Name, string Name,
string? Notes) string? Notes,
RecipeSummaryDto? ProducedByRecipe)
{ {
public static ActionResourceDto FromActionResource(ActionResource resource) => public static ActionResourceDto FromActionResource(ActionResource resource) =>
new(resource.Id, resource.Name, resource.Notes); new(
resource.Id,
resource.Name,
resource.Notes,
resource.ProducedByRecipe is null
? null
: RecipeSummaryDto.FromRecipe(resource.ProducedByRecipe));
}
public record ActionResourceSummaryDto(
int Id,
string Name)
{
public static ActionResourceSummaryDto FromActionResource(ActionResource resource) =>
new(resource.Id, resource.Name);
}
public record RecipeSummaryDto(
int Id,
string Name,
string Type,
string MeasurementMode)
{
public static RecipeSummaryDto FromRecipe(Recipe recipe) =>
new(recipe.Id, recipe.Name, recipe.Type, recipe.MeasurementMode);
} }
public record CareActivityDto( public record CareActivityDto(
@@ -218,9 +297,53 @@ namespace plant_manager
activity.Notes); activity.Notes);
} }
public record RecipeComponentDto(
int ActionResourceId,
string Name,
decimal? Quantity,
string? Unit,
string? Notes,
int SortOrder)
{
public static RecipeComponentDto FromRecipeComponent(RecipeComponent component) =>
new(
component.ActionResourceId,
component.ActionResource.Name,
component.Quantity,
component.Unit,
component.Notes,
component.SortOrder);
}
public record RecipeDto(
int Id,
string Name,
string Type,
string MeasurementMode,
ActionResourceSummaryDto? OutputResource,
IReadOnlyList<RecipeComponentDto> Components,
string? Notes)
{
public static RecipeDto FromRecipe(Recipe recipe) =>
new(
recipe.Id,
recipe.Name,
recipe.Type,
recipe.MeasurementMode,
recipe.OutputResource is null
? null
: ActionResourceSummaryDto.FromActionResource(recipe.OutputResource),
recipe.Components
.OrderBy(component => component.SortOrder)
.Select(RecipeComponentDto.FromRecipeComponent)
.ToList(),
recipe.Notes);
}
public record PlantDto( public record PlantDto(
int Id, int Id,
string Nickname, string Nickname,
DateOnly? Birthday,
int? TaxonId, int? TaxonId,
string Taxon, string Taxon,
int? LocationId, int? LocationId,
@@ -228,6 +351,8 @@ namespace plant_manager
string NextCare, string NextCare,
string Status, string Status,
IReadOnlyList<PlantFlagDto> Flags, IReadOnlyList<PlantFlagDto> Flags,
IReadOnlyList<PlantGroupSummaryDto> Groups,
IReadOnlyList<ActionLogDto> ActionLogs,
IReadOnlyList<PlantCareScheduleDto> CareSchedules) IReadOnlyList<PlantCareScheduleDto> CareSchedules)
{ {
public static PlantDto FromPlant(Plant plant) public static PlantDto FromPlant(Plant plant)
@@ -256,6 +381,7 @@ namespace plant_manager
return new PlantDto( return new PlantDto(
plant.Id, plant.Id,
plant.Nickname, plant.Nickname,
plant.Birthday,
plant.TaxonId, plant.TaxonId,
plant.Taxon is null ? "Unassigned" : $"{plant.Taxon.Genus} {plant.Taxon.Species}", plant.Taxon is null ? "Unassigned" : $"{plant.Taxon.Genus} {plant.Taxon.Species}",
plant.LocationId, plant.LocationId,
@@ -268,6 +394,15 @@ namespace plant_manager
.ThenBy(flag => flag.Definition.Name) .ThenBy(flag => flag.Definition.Name)
.Select(PlantFlagDto.FromPlantFlag) .Select(PlantFlagDto.FromPlantFlag)
.ToList(), .ToList(),
plant.GroupMemberships
.OrderBy(membership => membership.PlantGroup.Name)
.Select(PlantGroupSummaryDto.FromMembership)
.ToList(),
plant.ActionLogs
.OrderByDescending(log => log.PerformedOn)
.ThenByDescending(log => log.Id)
.Select(ActionLogDto.FromActionLog)
.ToList(),
schedules); schedules);
} }
@@ -18,6 +18,10 @@ namespace plant_manager.Data
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; } public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; } public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
public DbSet<PlantFlag> PlantFlags { get; set; } public DbSet<PlantFlag> PlantFlags { get; set; }
public DbSet<Recipe> Recipes { get; set; }
public DbSet<RecipeComponent> RecipeComponents { get; set; }
public DbSet<PlantGroup> PlantGroups { get; set; }
public DbSet<PlantGroupMembership> PlantGroupMemberships { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -40,6 +44,7 @@ namespace plant_manager.Data
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.Property(e => e.Nickname).HasMaxLength(120).IsRequired(); entity.Property(e => e.Nickname).HasMaxLength(120).IsRequired();
entity.Property(e => e.Birthday);
entity.HasOne(e => e.Taxon) entity.HasOne(e => e.Taxon)
.WithMany() .WithMany()
.HasForeignKey(e => e.TaxonId) .HasForeignKey(e => e.TaxonId)
@@ -211,6 +216,64 @@ namespace plant_manager.Data
.HasForeignKey(e => e.PlantFlagDefinitionId) .HasForeignKey(e => e.PlantFlagDefinitionId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
modelBuilder.Entity<PlantGroup>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasIndex(e => e.Name).IsUnique();
});
modelBuilder.Entity<PlantGroupMembership>(entity =>
{
entity.HasKey(e => new { e.PlantId, e.PlantGroupId });
entity.HasOne(e => e.Plant)
.WithMany(e => e.GroupMemberships)
.HasForeignKey(e => e.PlantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.PlantGroup)
.WithMany(e => e.Memberships)
.HasForeignKey(e => e.PlantGroupId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Recipe>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
entity.Property(e => e.Type).HasMaxLength(80).IsRequired();
entity.Property(e => e.MeasurementMode).HasMaxLength(40).IsRequired();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasIndex(e => e.Name).IsUnique();
entity.HasIndex(e => e.OutputResourceId).IsUnique();
entity.HasOne(e => e.OutputResource)
.WithOne(e => e.ProducedByRecipe)
.HasForeignKey<Recipe>(e => e.OutputResourceId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<RecipeComponent>(entity =>
{
entity.HasKey(e => new { e.RecipeId, e.ActionResourceId });
entity.Property(e => e.Quantity).HasPrecision(10, 2);
entity.Property(e => e.Unit).HasMaxLength(40);
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.Property(e => e.SortOrder).IsRequired();
entity.HasIndex(e => new { e.RecipeId, e.SortOrder }).IsUnique();
entity.HasOne(e => e.Recipe)
.WithMany(e => e.Components)
.HasForeignKey(e => e.RecipeId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ActionResource)
.WithMany(e => e.RecipeComponents)
.HasForeignKey(e => e.ActionResourceId)
.OnDelete(DeleteBehavior.Restrict);
});
} }
} }
} }
@@ -0,0 +1,789 @@
// <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("20260530144239_AddRecipesPlantTimelineAndGroups")]
partial class AddRecipesPlantTimelineAndGroups
{
/// <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<int>("CareActivityId")
.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("CareActivityId");
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>("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<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("CareActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.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("CareActivities");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("CareActivityId", "CareActionId");
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId", "SortOrder")
.IsUnique();
b.ToTable("CareActivityActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("Unit")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.HasKey("CareActivityId", "CareActionId", "ActionResourceId");
b.HasIndex("ActionResourceId");
b.ToTable("CareActivityActionResources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly?>("Birthday")
.HasColumnType("TEXT");
b.Property<int?>("LocationId")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int?>("TaxonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LocationId");
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>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int?>("EndsAfterOccurrences")
.HasColumnType("INTEGER");
b.Property<string>("EndsMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly?>("EndsOn")
.HasColumnType("TEXT");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<string>("RecurrenceMode")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<int>("RepeatEvery")
.HasColumnType("INTEGER");
b.Property<string>("RepeatOnDays")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("RepeatUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly?>("ScheduledFor")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActionId");
b.HasIndex("PlantId", "CareActivityId")
.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<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>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
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.PlantGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.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("PlantGroups");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b =>
{
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<int>("PlantGroupId")
.HasColumnType("INTEGER");
b.HasKey("PlantId", "PlantGroupId");
b.HasIndex("PlantGroupId");
b.ToTable("PlantGroupMemberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.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("PlantLocations");
});
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.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("MeasurementMode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("OutputResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OutputResourceId")
.IsUnique();
b.ToTable("Recipes");
});
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
{
b.Property<int>("RecipeId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Unit")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.HasKey("RecipeId", "ActionResourceId");
b.HasIndex("ActionResourceId");
b.HasIndex("RecipeId", "SortOrder")
.IsUnique();
b.ToTable("RecipeComponents");
});
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.CareActivity", "CareActivity")
.WithMany("ActionLogs")
.HasForeignKey("CareActivityId")
.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("CareActivity");
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.CareActivityAction", b =>
{
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
.WithMany("CareActivityActions")
.HasForeignKey("CareActionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("Actions")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
.WithMany("CareActivityActionResources")
.HasForeignKey("ActionResourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction")
.WithMany("Resources")
.HasForeignKey("CareActivityId", "CareActionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActionResource");
b.Navigation("CareActivityAction");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
.WithMany("Plants")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
.WithMany()
.HasForeignKey("TaxonId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Location");
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.CareActivity", "CareActivity")
.WithMany("PlantCareSchedules")
.HasForeignKey("CareActivityId")
.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("CareActivity");
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.PlantGroupMembership", b =>
{
b.HasOne("plant_manager.Data.Models.PlantGroup", "PlantGroup")
.WithMany("Memberships")
.HasForeignKey("PlantGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("GroupMemberships")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Plant");
b.Navigation("PlantGroup");
});
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "OutputResource")
.WithOne("ProducedByRecipe")
.HasForeignKey("plant_manager.Data.Models.Recipe", "OutputResourceId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("OutputResource");
});
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
.WithMany("RecipeComponents")
.HasForeignKey("ActionResourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Recipe", "Recipe")
.WithMany("Components")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActionResource");
b.Navigation("Recipe");
});
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
{
b.Navigation("Resources");
});
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
{
b.Navigation("ActionLogResources");
b.Navigation("CareActivityActionResources");
b.Navigation("ProducedByRecipe");
b.Navigation("RecipeComponents");
});
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
{
b.Navigation("ActionLogs");
b.Navigation("CareActivityActions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Navigation("ActionLogs");
b.Navigation("Actions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Navigation("Resources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Navigation("ActionLogs");
b.Navigation("CareSchedules");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
{
b.Navigation("PlantFlags");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Navigation("Plants");
});
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
{
b.Navigation("Components");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,164 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace plant_manager.Data.Migrations
{
/// <inheritdoc />
public partial class AddRecipesPlantTimelineAndGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "Birthday",
table: "Plants",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "PlantGroups",
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),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantGroups", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Recipes",
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),
Type = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
MeasurementMode = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
OutputResourceId = table.Column<int>(type: "INTEGER", nullable: true),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Recipes", x => x.Id);
table.ForeignKey(
name: "FK_Recipes_ActionResources_OutputResourceId",
column: x => x.OutputResourceId,
principalTable: "ActionResources",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "PlantGroupMemberships",
columns: table => new
{
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
PlantGroupId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantGroupMemberships", x => new { x.PlantId, x.PlantGroupId });
table.ForeignKey(
name: "FK_PlantGroupMemberships_PlantGroups_PlantGroupId",
column: x => x.PlantGroupId,
principalTable: "PlantGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlantGroupMemberships_Plants_PlantId",
column: x => x.PlantId,
principalTable: "Plants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RecipeComponents",
columns: table => new
{
RecipeId = 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),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RecipeComponents", x => new { x.RecipeId, x.ActionResourceId });
table.ForeignKey(
name: "FK_RecipeComponents_ActionResources_ActionResourceId",
column: x => x.ActionResourceId,
principalTable: "ActionResources",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_RecipeComponents_Recipes_RecipeId",
column: x => x.RecipeId,
principalTable: "Recipes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PlantGroupMemberships_PlantGroupId",
table: "PlantGroupMemberships",
column: "PlantGroupId");
migrationBuilder.CreateIndex(
name: "IX_PlantGroups_Name",
table: "PlantGroups",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecipeComponents_ActionResourceId",
table: "RecipeComponents",
column: "ActionResourceId");
migrationBuilder.CreateIndex(
name: "IX_RecipeComponents_RecipeId_SortOrder",
table: "RecipeComponents",
columns: new[] { "RecipeId", "SortOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Recipes_Name",
table: "Recipes",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Recipes_OutputResourceId",
table: "Recipes",
column: "OutputResourceId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlantGroupMemberships");
migrationBuilder.DropTable(
name: "RecipeComponents");
migrationBuilder.DropTable(
name: "PlantGroups");
migrationBuilder.DropTable(
name: "Recipes");
migrationBuilder.DropColumn(
name: "Birthday",
table: "Plants");
}
}
}
@@ -83,6 +83,7 @@ namespace plant_manager.Data.Migrations
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -109,6 +110,7 @@ namespace plant_manager.Data.Migrations
b.Property<string>("Description") b.Property<string>("Description")
.HasMaxLength(400) .HasMaxLength(400)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(80) .HasMaxLength(80)
@@ -127,6 +129,7 @@ namespace plant_manager.Data.Migrations
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -201,6 +204,9 @@ namespace plant_manager.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<DateOnly?>("Birthday")
.HasColumnType("TEXT");
b.Property<int?>("LocationId") b.Property<int?>("LocationId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -246,6 +252,7 @@ namespace plant_manager.Data.Migrations
b.Property<int>("EveryDays") b.Property<int>("EveryDays")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("PlantId") b.Property<int>("PlantId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -324,6 +331,7 @@ namespace plant_manager.Data.Migrations
.IsRequired() .IsRequired()
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -337,11 +345,50 @@ namespace plant_manager.Data.Migrations
b.ToTable("PlantFlagDefinitions"); b.ToTable("PlantFlagDefinitions");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.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("PlantGroups");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b =>
{
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
b.Property<int>("PlantGroupId")
.HasColumnType("INTEGER");
b.HasKey("PlantId", "PlantGroupId");
b.HasIndex("PlantGroupId");
b.ToTable("PlantGroupMemberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -397,6 +444,78 @@ namespace plant_manager.Data.Migrations
b.ToTable("PlantTaxa"); b.ToTable("PlantTaxa");
}); });
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("MeasurementMode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<int?>("OutputResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("OutputResourceId")
.IsUnique();
b.ToTable("Recipes");
});
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
{
b.Property<int>("RecipeId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Unit")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.HasKey("RecipeId", "ActionResourceId");
b.HasIndex("ActionResourceId");
b.HasIndex("RecipeId", "SortOrder")
.IsUnique();
b.ToTable("RecipeComponents");
});
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
{ {
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction") b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
@@ -544,6 +663,54 @@ namespace plant_manager.Data.Migrations
b.Navigation("Plant"); b.Navigation("Plant");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b =>
{
b.HasOne("plant_manager.Data.Models.PlantGroup", "PlantGroup")
.WithMany("Memberships")
.HasForeignKey("PlantGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("GroupMemberships")
.HasForeignKey("PlantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Plant");
b.Navigation("PlantGroup");
});
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "OutputResource")
.WithOne("ProducedByRecipe")
.HasForeignKey("plant_manager.Data.Models.Recipe", "OutputResourceId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("OutputResource");
});
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
.WithMany("RecipeComponents")
.HasForeignKey("ActionResourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Recipe", "Recipe")
.WithMany("Components")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActionResource");
b.Navigation("Recipe");
});
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b => modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
{ {
b.Navigation("Resources"); b.Navigation("Resources");
@@ -554,6 +721,10 @@ namespace plant_manager.Data.Migrations
b.Navigation("ActionLogResources"); b.Navigation("ActionLogResources");
b.Navigation("CareActivityActionResources"); b.Navigation("CareActivityActionResources");
b.Navigation("ProducedByRecipe");
b.Navigation("RecipeComponents");
}); });
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b => modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
@@ -586,6 +757,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareSchedules"); b.Navigation("CareSchedules");
b.Navigation("Flags"); b.Navigation("Flags");
b.Navigation("GroupMemberships");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b => modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
@@ -593,10 +766,20 @@ namespace plant_manager.Data.Migrations
b.Navigation("PlantFlags"); b.Navigation("PlantFlags");
}); });
modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b =>
{
b.Navigation("Memberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b => modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{ {
b.Navigation("Plants"); b.Navigation("Plants");
}); });
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
{
b.Navigation("Components");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -9,5 +9,7 @@ namespace plant_manager.Data.Models
public List<CareActivityActionResource> CareActivityActionResources { get; set; } = []; public List<CareActivityActionResource> CareActivityActionResources { get; set; } = [];
public List<ActionLogResource> ActionLogResources { get; set; } = []; public List<ActionLogResource> ActionLogResources { get; set; } = [];
public List<RecipeComponent> RecipeComponents { get; set; } = [];
public Recipe? ProducedByRecipe { get; set; }
} }
} }
+2
View File
@@ -6,11 +6,13 @@ namespace plant_manager.Data.Models
public int? TaxonId { get; set; } public int? TaxonId { get; set; }
public int? LocationId { get; set; } public int? LocationId { get; set; }
public string Nickname { get; set; } = string.Empty; public string Nickname { get; set; } = string.Empty;
public DateOnly? Birthday { get; set; }
public PlantTaxon? Taxon { get; set; } public PlantTaxon? Taxon { get; set; }
public PlantLocation? Location { get; set; } public PlantLocation? Location { get; set; }
public List<ActionLog> ActionLogs { get; set; } = []; public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> CareSchedules { get; set; } = []; public List<PlantCareSchedule> CareSchedules { get; set; } = [];
public List<PlantFlag> Flags { get; set; } = []; public List<PlantFlag> Flags { get; set; } = [];
public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
} }
} }
+12
View File
@@ -0,0 +1,12 @@
namespace plant_manager.Data.Models
{
public class PlantGroup
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Notes { get; set; }
public List<PlantGroupMembership> Memberships { get; set; } = [];
}
}
@@ -0,0 +1,11 @@
namespace plant_manager.Data.Models
{
public class PlantGroupMembership
{
public int PlantId { get; set; }
public int PlantGroupId { get; set; }
public Plant Plant { get; set; } = null!;
public PlantGroup PlantGroup { get; set; } = null!;
}
}
+16
View File
@@ -0,0 +1,16 @@
namespace plant_manager.Data.Models
{
public class Recipe
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string MeasurementMode { get; set; } = "quantity";
public int? OutputResourceId { get; set; }
public string? Notes { get; set; }
public ActionResource? OutputResource { get; set; }
public List<RecipeComponent> Components { get; set; } = [];
}
}
@@ -0,0 +1,15 @@
namespace plant_manager.Data.Models
{
public class RecipeComponent
{
public int RecipeId { get; set; }
public int ActionResourceId { get; set; }
public decimal? Quantity { get; set; }
public string? Unit { get; set; }
public string? Notes { get; set; }
public int SortOrder { get; set; }
public Recipe Recipe { get; set; } = null!;
public ActionResource ActionResource { get; set; } = null!;
}
}
@@ -11,6 +11,7 @@ namespace plant_manager.Endpoints
app.MapGet("/api/action-resources", async (ApplicationDbContext db) => app.MapGet("/api/action-resources", async (ApplicationDbContext db) =>
{ {
var resources = await db.ActionResources var resources = await db.ActionResources
.Include(resource => resource.ProducedByRecipe)
.OrderBy(resource => resource.Name) .OrderBy(resource => resource.Name)
.Select(resource => ActionResourceDto.FromActionResource(resource)) .Select(resource => ActionResourceDto.FromActionResource(resource))
.ToListAsync(); .ToListAsync();
@@ -82,7 +83,8 @@ namespace plant_manager.Endpoints
} }
var isInUse = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id) var isInUse = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id)
|| await db.CareActivityActionResources.AnyAsync(activityResource => activityResource.ActionResourceId == id); || await db.CareActivityActionResources.AnyAsync(activityResource => activityResource.ActionResourceId == id)
|| await db.RecipeComponents.AnyAsync(component => component.ActionResourceId == id);
if (isInUse) if (isInUse)
{ {
return Results.Conflict(new { error = "Resource is in use." }); return Results.Conflict(new { error = "Resource is in use." });
+14
View File
@@ -25,8 +25,12 @@ namespace plant_manager.Endpoints
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs) .Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.Flags) .Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.OrderBy(plant => plant.Nickname) .OrderBy(plant => plant.Nickname)
.ToListAsync(); .ToListAsync();
@@ -50,8 +54,12 @@ namespace plant_manager.Endpoints
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs) .Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.Flags) .Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.FirstOrDefaultAsync(item => item.Id == id); .FirstOrDefaultAsync(item => item.Id == id);
return plant is null return plant is null
@@ -81,6 +89,7 @@ namespace plant_manager.Endpoints
var plant = new Plant var plant = new Plant
{ {
Nickname = request.Nickname.Trim(), Nickname = request.Nickname.Trim(),
Birthday = request.Birthday,
TaxonId = request.TaxonId, TaxonId = request.TaxonId,
LocationId = request.LocationId LocationId = request.LocationId
}; };
@@ -125,8 +134,12 @@ namespace plant_manager.Endpoints
.ThenInclude(action => action.Resources) .ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource) .ThenInclude(resource => resource.ActionResource)
.Include(item => item.ActionLogs) .Include(item => item.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(item => item.Flags) .Include(item => item.Flags)
.ThenInclude(flag => flag.Definition) .ThenInclude(flag => flag.Definition)
.Include(item => item.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.FirstOrDefaultAsync(item => item.Id == id); .FirstOrDefaultAsync(item => item.Id == id);
if (plant is null) if (plant is null)
@@ -147,6 +160,7 @@ namespace plant_manager.Endpoints
} }
plant.Nickname = request.Nickname.Trim(); plant.Nickname = request.Nickname.Trim();
plant.Birthday = request.Birthday;
plant.TaxonId = request.TaxonId; plant.TaxonId = request.TaxonId;
plant.LocationId = request.LocationId; plant.LocationId = request.LocationId;
plant.Taxon = taxon; plant.Taxon = taxon;
@@ -0,0 +1,147 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class PlantGroupEndpoints
{
public static void MapPlantGroupEndpoints(this WebApplication app)
{
app.MapGet("/api/plant-groups", async (ApplicationDbContext db) =>
{
var groups = await db.PlantGroups
.Include(group => group.Memberships)
.ThenInclude(membership => membership.Plant)
.OrderBy(group => group.Name)
.Select(group => PlantGroupDto.FromGroup(group))
.ToListAsync();
return Results.Ok(groups);
});
app.MapPost("/api/plant-groups", async (SavePlantGroupRequest request, ApplicationDbContext db) =>
{
var validation = await ValidateRequest(request, db);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.PlantGroups.AnyAsync(group => group.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A plant group with this name already exists." });
}
var group = new PlantGroup
{
Name = name,
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
Memberships = validation.PlantIds
.Select(plantId => new PlantGroupMembership { PlantId = plantId })
.ToList()
};
db.PlantGroups.Add(group);
await db.SaveChangesAsync();
await db.Entry(group)
.Collection(item => item.Memberships)
.Query()
.Include(membership => membership.Plant)
.LoadAsync();
return Results.Created($"/api/plant-groups/{group.Id}", PlantGroupDto.FromGroup(group));
});
app.MapPut("/api/plant-groups/{id:int}", async (int id, SavePlantGroupRequest request, ApplicationDbContext db) =>
{
var group = await db.PlantGroups
.Include(item => item.Memberships)
.ThenInclude(membership => membership.Plant)
.FirstOrDefaultAsync(item => item.Id == id);
if (group is null)
{
return Results.NotFound();
}
var validation = await ValidateRequest(request, db);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.PlantGroups.AnyAsync(item =>
item.Id != id && item.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A plant group with this name already exists." });
}
group.Name = name;
group.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
db.PlantGroupMemberships.RemoveRange(group.Memberships);
group.Memberships = validation.PlantIds
.Select(plantId => new PlantGroupMembership
{
PlantGroupId = group.Id,
PlantId = plantId
})
.ToList();
await db.SaveChangesAsync();
await db.Entry(group)
.Collection(item => item.Memberships)
.Query()
.Include(membership => membership.Plant)
.LoadAsync();
return Results.Ok(PlantGroupDto.FromGroup(group));
});
app.MapDelete("/api/plant-groups/{id:int}", async (int id, ApplicationDbContext db) =>
{
var group = await db.PlantGroups.FindAsync(id);
if (group is null)
{
return Results.NotFound();
}
db.PlantGroups.Remove(group);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
private static async Task<(List<int> PlantIds, string? Error)> ValidateRequest(
SavePlantGroupRequest request,
ApplicationDbContext db)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return ([], "Group name is required.");
}
var plantIds = request.PlantIds?
.Where(id => id > 0)
.Distinct()
.ToList() ?? [];
if (plantIds.Count > 0)
{
var existingPlantCount = await db.Plants.CountAsync(plant => plantIds.Contains(plant.Id));
if (existingPlantCount != plantIds.Count)
{
return ([], "One or more plants were not found.");
}
}
return (plantIds, null);
}
}
}
+250
View File
@@ -0,0 +1,250 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class RecipeEndpoints
{
public static void MapRecipeEndpoints(this WebApplication app)
{
app.MapGet("/api/recipes", async (ApplicationDbContext db) =>
{
var recipes = await db.Recipes
.Include(recipe => recipe.OutputResource)
.Include(recipe => recipe.Components)
.ThenInclude(component => component.ActionResource)
.OrderBy(recipe => recipe.Name)
.Select(recipe => RecipeDto.FromRecipe(recipe))
.ToListAsync();
return Results.Ok(recipes);
});
app.MapPost("/api/recipes", async (SaveRecipeRequest request, ApplicationDbContext db) =>
{
var validation = await ValidateRequest(request, db);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.Recipes.AnyAsync(recipe => recipe.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A recipe with this name already exists." });
}
var recipe = new Recipe
{
Name = name,
Type = request.Type.Trim(),
MeasurementMode = validation.MeasurementMode,
OutputResource = validation.OutputResourceName is null
? null
: new ActionResource
{
Name = validation.OutputResourceName
},
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
Components = validation.Components
};
db.Recipes.Add(recipe);
await db.SaveChangesAsync();
return Results.Created($"/api/recipes/{recipe.Id}", RecipeDto.FromRecipe(recipe));
});
app.MapPut("/api/recipes/{id:int}", async (int id, SaveRecipeRequest request, ApplicationDbContext db) =>
{
var recipe = await db.Recipes
.Include(item => item.OutputResource)
.Include(item => item.Components)
.ThenInclude(component => component.ActionResource)
.FirstOrDefaultAsync(item => item.Id == id);
if (recipe is null)
{
return Results.NotFound();
}
var validation = await ValidateRequest(request, db, recipe.OutputResourceId);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.Recipes.AnyAsync(item =>
item.Id != id && item.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A recipe with this name already exists." });
}
recipe.Name = name;
recipe.Type = request.Type.Trim();
recipe.MeasurementMode = validation.MeasurementMode;
if (validation.OutputResourceName is null)
{
recipe.OutputResourceId = null;
recipe.OutputResource = null;
}
else if (recipe.OutputResource is null)
{
recipe.OutputResource = new ActionResource
{
Name = validation.OutputResourceName
};
}
else
{
recipe.OutputResource.Name = validation.OutputResourceName;
}
recipe.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
db.RecipeComponents.RemoveRange(recipe.Components);
recipe.Components = validation.Components;
await db.SaveChangesAsync();
return Results.Ok(RecipeDto.FromRecipe(recipe));
});
app.MapDelete("/api/recipes/{id:int}", async (int id, ApplicationDbContext db) =>
{
var recipe = await db.Recipes.FindAsync(id);
if (recipe is null)
{
return Results.NotFound();
}
db.Recipes.Remove(recipe);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
private static async Task<(List<RecipeComponent> Components, string MeasurementMode, string? OutputResourceName, string? Error)> ValidateRequest(
SaveRecipeRequest request,
ApplicationDbContext db,
int? currentOutputResourceId = null)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return ([], "quantity", null, "Recipe name is required.");
}
if (string.IsNullOrWhiteSpace(request.Type))
{
return ([], "quantity", null, "Recipe type is required.");
}
var measurementMode = NormalizeMeasurementMode(request.MeasurementMode);
if (measurementMode is null)
{
return ([], "quantity", null, "Recipe measurement mode is invalid.");
}
var outputResourceName = string.IsNullOrWhiteSpace(request.OutputResourceName)
? null
: request.OutputResourceName.Trim();
if (outputResourceName is null)
{
return ([], measurementMode, null, "Produced resource name is required.");
}
if (outputResourceName is not null)
{
var outputNameExists = await db.ActionResources.AnyAsync(resource =>
resource.Name.ToLower() == outputResourceName.ToLower()
&& (currentOutputResourceId == null || resource.Id != currentOutputResourceId));
if (outputNameExists)
{
return ([], measurementMode, null, "A resource with this output name already exists.");
}
}
var requestedComponents = request.Components?
.GroupBy(component => component.ActionResourceId)
.Select(group => group.First())
.ToList();
if (requestedComponents is null || requestedComponents.Count == 0)
{
return ([], measurementMode, outputResourceName, "Select at least one recipe component.");
}
if (requestedComponents.Any(component => component.ActionResourceId <= 0))
{
return ([], measurementMode, outputResourceName, "Select a resource for every recipe component.");
}
if (requestedComponents.Any(component => component.Quantity < 0))
{
return ([], measurementMode, outputResourceName, "Component quantities cannot be negative.");
}
if (measurementMode is "total_percent" or "bakers_percent"
&& requestedComponents.Any(component => component.Quantity is null))
{
return ([], measurementMode, outputResourceName, "Percent recipes require a value for every component.");
}
if (measurementMode == "total_percent"
&& requestedComponents.Sum(component => component.Quantity ?? 0) != 100)
{
return ([], measurementMode, outputResourceName, "Total percent recipes must add up to 100%.");
}
if (measurementMode == "bakers_percent"
&& !requestedComponents.Any(component => component.Quantity == 100))
{
return ([], measurementMode, outputResourceName, "Baker's percent recipes need one base component at 100%.");
}
var resourceIds = requestedComponents
.Select(component => component.ActionResourceId)
.ToList();
var resourcesById = await db.ActionResources
.Where(resource => resourceIds.Contains(resource.Id))
.ToDictionaryAsync(resource => resource.Id);
if (resourcesById.Count != resourceIds.Count)
{
return ([], measurementMode, outputResourceName, "One or more resources were not found.");
}
var components = requestedComponents
.Select((componentRequest, index) => new RecipeComponent
{
ActionResourceId = componentRequest.ActionResourceId,
ActionResource = resourcesById[componentRequest.ActionResourceId],
Quantity = componentRequest.Quantity,
Unit = measurementMode == "quantity"
? string.IsNullOrWhiteSpace(componentRequest.Unit) ? null : componentRequest.Unit.Trim()
: "%",
Notes = string.IsNullOrWhiteSpace(componentRequest.Notes) ? null : componentRequest.Notes.Trim(),
SortOrder = index
})
.ToList();
return (components, measurementMode, outputResourceName, null);
}
private static string? NormalizeMeasurementMode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "quantity";
}
return value.Trim().ToLowerInvariant() switch
{
"quantity" => "quantity",
"total_percent" => "total_percent",
"bakers_percent" => "bakers_percent",
_ => null
};
}
}
}
+2
View File
@@ -43,9 +43,11 @@ app.MapRootEndpoints();
app.MapPlantEndpoints(); app.MapPlantEndpoints();
app.MapPlantTaxonEndpoints(); app.MapPlantTaxonEndpoints();
app.MapPlantLocationEndpoints(); app.MapPlantLocationEndpoints();
app.MapPlantGroupEndpoints();
app.MapCareActionEndpoints(); app.MapCareActionEndpoints();
app.MapActionResourceEndpoints(); app.MapActionResourceEndpoints();
app.MapCareActivityEndpoints(); app.MapCareActivityEndpoints();
app.MapRecipeEndpoints();
app.MapPlantCareScheduleEndpoints(); app.MapPlantCareScheduleEndpoints();
app.MapCareTaskEndpoints(); app.MapCareTaskEndpoints();
app.MapActionLogEndpoints(); app.MapActionLogEndpoints();