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,
createCareAction,
createPlant,
createPlantGroup,
createPlantLocation,
createRecipe,
assignPlantFlag,
completeCareTasksBulk,
createPlantFlag,
@@ -13,17 +15,21 @@ import {
deleteCareActivity,
deleteCareAction,
deletePlant,
deletePlantGroup,
deletePlantFlag,
deletePlantLocation,
deletePlantTaxon,
deleteRecipe,
getActionResources,
getCareActivities,
getCareActions,
getCareTasks,
getPlants,
getPlantFlags,
getPlantGroups,
getPlantLocations,
getPlantTaxa,
getRecipes,
removePlantFlagAssignment,
removePlantCareSchedulesBulk,
resolvePlantFlag,
@@ -32,21 +38,25 @@ import {
updateCareActivity,
updateCareAction,
updatePlant,
updatePlantGroup,
updatePlantFlag,
updatePlantLocation,
updatePlantTaxon,
updateRecipe,
} from './api';
import { ActionsView } from './components/ActionsView';
import { ActivitiesView } from './components/ActivitiesView';
import { FlagsView } from './components/FlagsView';
import { GroupsView } from './components/GroupsView';
import { HomeView } from './components/HomeView';
import { LocationsView } from './components/LocationsView';
import { PlantManagementView } from './components/PlantManagementView';
import { PlantsView } from './components/PlantsView';
import { RecipesView } from './components/RecipesView';
import { ResourcesView } from './components/ResourcesView';
import { SchedulesView } from './components/SchedulesView';
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 {
emptyActionForm,
emptyActivityForm,
@@ -54,7 +64,9 @@ import {
emptyFlagDefinitionForm,
emptyLocationForm,
emptyPlantForm,
emptyPlantGroupForm,
emptyPlantFlagForm,
emptyRecipeForm,
emptyResourceForm,
emptyTaxonForm,
toActionForm,
@@ -66,9 +78,13 @@ import {
toFlagDefinitionPayload,
toLocationForm,
toLocationPayload,
toPlantGroupForm,
toPlantGroupPayload,
toPlantFlagPayload,
toPlantForm,
toPlantPayload,
toRecipeForm,
toRecipePayload,
toResourceForm,
toResourcePayload,
toTaxonForm,
@@ -78,8 +94,10 @@ import {
type BulkScheduleFormState,
type FlagDefinitionFormState,
type LocationFormState,
type PlantGroupFormState,
type PlantFlagFormState,
type PlantFormState,
type RecipeFormState,
type ResourceFormState,
type TaxonFormState,
type View,
@@ -90,8 +108,10 @@ export function App() {
const [plants, setPlants] = useState<Plant[]>([]);
const [plantTaxa, setPlantTaxa] = useState<PlantTaxon[]>([]);
const [plantLocations, setPlantLocations] = useState<PlantLocation[]>([]);
const [plantGroups, setPlantGroups] = useState<PlantGroup[]>([]);
const [careActions, setCareActions] = useState<CareAction[]>([]);
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
@@ -100,15 +120,19 @@ export function App() {
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
const [editingActionId, setEditingActionId] = useState<number | null>(null);
const [editingResourceId, setEditingResourceId] = useState<number | null>(null);
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
const [isResourceEditorOpen, setIsResourceEditorOpen] = useState(false);
const [isRecipeEditorOpen, setIsRecipeEditorOpen] = useState(false);
const [isActivityEditorOpen, setIsActivityEditorOpen] = useState(false);
const [isFlagDefinitionEditorOpen, setIsFlagDefinitionEditorOpen] = useState(false);
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
@@ -116,13 +140,16 @@ export function App() {
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [selectedActionId, setSelectedActionId] = 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 [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
const [resourceForm, setResourceForm] = useState<ResourceFormState>(emptyResourceForm);
const [recipeForm, setRecipeForm] = useState<RecipeFormState>(emptyRecipeForm);
const [activityForm, setActivityForm] = useState<ActivityFormState>(emptyActivityForm);
const [flagDefinitionForm, setFlagDefinitionForm] = useState<FlagDefinitionFormState>(emptyFlagDefinitionForm);
const [plantFlagForm, setPlantFlagForm] = useState<PlantFlagFormState>(emptyPlantFlagForm);
@@ -136,8 +163,10 @@ export function App() {
tasksResponse,
taxaResponse,
locationsResponse,
groupsResponse,
actionsResponse,
resourcesResponse,
recipesResponse,
activitiesResponse,
flagsResponse,
] = await Promise.all([
@@ -145,8 +174,10 @@ export function App() {
getCareTasks(),
getPlantTaxa(),
getPlantLocations(),
getPlantGroups(),
getCareActions(),
getActionResources(),
getRecipes(),
getCareActivities(),
getPlantFlags(),
]);
@@ -156,8 +187,10 @@ export function App() {
setCareTasks(tasksResponse);
setPlantTaxa(taxaResponse);
setPlantLocations(locationsResponse);
setPlantGroups(groupsResponse);
setCareActions(actionsResponse);
setActionResources(resourcesResponse);
setRecipes(recipesResponse);
setCareActivities(activitiesResponse);
setPlantFlagDefinitions(flagsResponse);
} catch {
@@ -174,6 +207,17 @@ export function App() {
setPlantLocations(locationsResponse);
}
async function loadGroupsAndPlants() {
const [groupsResponse, plantsResponse] = await Promise.all([
getPlantGroups(),
getPlants(),
]);
setError(null);
setPlantGroups(groupsResponse);
setPlants(plantsResponse);
}
async function loadPlants() {
const plantsResponse = await getPlants();
@@ -220,12 +264,14 @@ export function App() {
tasksResponse,
actionsResponse,
resourcesResponse,
recipesResponse,
activitiesResponse,
] = await Promise.all([
getPlants(),
getCareTasks(),
getCareActions(),
getActionResources(),
getRecipes(),
getCareActivities(),
]);
@@ -234,9 +280,21 @@ export function App() {
setCareTasks(tasksResponse);
setCareActions(actionsResponse);
setActionResources(resourcesResponse);
setRecipes(recipesResponse);
setCareActivities(activitiesResponse);
}
async function loadRecipesAndResources() {
const [recipesResponse, resourcesResponse] = await Promise.all([
getRecipes(),
getActionResources(),
]);
setError(null);
setRecipes(recipesResponse);
setActionResources(resourcesResponse);
}
useEffect(() => {
queueMicrotask(() => {
void loadDashboard();
@@ -254,10 +312,13 @@ export function App() {
const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
const activeLocation = plantLocations.find((location) => location.id === editingLocationId);
const selectedLocation = plantLocations.find((location) => location.id === selectedLocationId);
const activePlantGroup = plantGroups.find((group) => group.id === editingPlantGroupId);
const activeAction = careActions.find((action) => action.id === editingActionId);
const selectedAction = careActions.find((action) => action.id === selectedActionId);
const activeResource = actionResources.find((resource) => resource.id === editingResourceId);
const selectedResource = actionResources.find((resource) => resource.id === selectedResourceId);
const activeRecipe = recipes.find((recipe) => recipe.id === editingRecipeId);
const selectedRecipe = recipes.find((recipe) => recipe.id === selectedRecipeId);
const activeActivity = careActivities.find((activity) => activity.id === editingActivityId);
const selectedActivity = careActivities.find((activity) => activity.id === selectedActivityId);
const activeFlagDefinition = plantFlagDefinitions.find((flag) => flag.id === editingFlagDefinitionId);
@@ -275,6 +336,10 @@ export function App() {
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) {
setActionForm((current) => ({ ...current, [field]: value }));
}
@@ -287,6 +352,10 @@ export function App() {
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) {
setFlagDefinitionForm((current) => ({ ...current, [field]: value }));
}
@@ -304,6 +373,7 @@ export function App() {
function startAddingPlant() {
setEditingPlantId(null);
setSelectedPlantId(null);
setForm(emptyPlantForm);
setIsPlantEditorOpen(true);
setView('plants');
@@ -331,6 +401,14 @@ export function App() {
setIsPlantEditorOpen(false);
}
function openPlantReadOnlyDetail(plant: Plant) {
setSelectedPlantId(plant.id);
setEditingPlantId(null);
setForm(emptyPlantForm);
setIsPlantEditorOpen(false);
setView('plants');
}
function startAddingTaxon() {
setEditingTaxonId(null);
setSelectedTaxonId(null);
@@ -375,6 +453,26 @@ export function App() {
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() {
setEditingActionId(null);
setSelectedActionId(null);
@@ -419,6 +517,28 @@ export function App() {
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() {
setEditingActivityId(null);
setSelectedActivityId(null);
@@ -504,6 +624,7 @@ export function App() {
try {
await updatePlant(selectedPlant.id, {
nickname: selectedPlant.nickname,
birthday: selectedPlant.birthday,
taxonId: nextValues.taxonId === undefined ? selectedPlant.taxonId : nextValues.taxonId,
locationId: nextValues.locationId === undefined ? selectedPlant.locationId : nextValues.locationId,
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() {
if (!actionForm.name.trim()) {
setError('Action name is required.');
@@ -699,6 +863,7 @@ export function App() {
}
cancelEditingResource();
await loadCareModel();
await loadRecipesAndResources();
} catch {
setError('Could not save the resource.');
} finally {
@@ -719,6 +884,7 @@ export function App() {
cancelEditingResource();
}
await loadCareModel();
await loadRecipesAndResources();
} catch {
setError('Could not delete the resource.');
} 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() {
if (!activityForm.name.trim() || activityForm.actions.length === 0) {
setError('Activity name and at least one action are required.');
@@ -1010,13 +1222,6 @@ export function App() {
>
Care Activities
</button>
<button
type="button"
aria-current={view === 'actions' ? 'page' : undefined}
onClick={() => setView('actions')}
>
Care Actions
</button>
</div>
<div className="nav-group">
@@ -1035,6 +1240,13 @@ export function App() {
>
Locations
</button>
<button
type="button"
aria-current={view === 'groups' ? 'page' : undefined}
onClick={() => setView('groups')}
>
Groups
</button>
<button
type="button"
aria-current={view === 'resources' ? 'page' : undefined}
@@ -1042,6 +1254,20 @@ export function App() {
>
Resources
</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
type="button"
aria-current={view === 'flags' ? 'page' : undefined}
@@ -1056,9 +1282,11 @@ export function App() {
<h3>Catalog Status</h3>
<p>{dueCount} due</p>
<p>{plantLocations.length} locations</p>
<p>{plantGroups.length} groups</p>
<p>{careActivities.length} activities</p>
<p>{careActions.length} actions</p>
<p>{actionResources.length} resources</p>
<p>{recipes.length} recipes</p>
<p>{plantFlagDefinitions.length} flags</p>
</section>
</aside>
@@ -1079,11 +1307,14 @@ export function App() {
isPlantEditorOpen={isPlantEditorOpen}
isSaving={isSaving}
plants={plants}
selectedPlant={selectedPlant}
onCancel={cancelEditing}
onCloseDetail={() => setSelectedPlantId(null)}
onDelete={(plant) => void removePlant(plant)}
onEdit={startEditingPlant}
onFieldChange={updateForm}
onNew={startAddingPlant}
onOpenDetail={openPlantReadOnlyDetail}
onSave={() => void savePlant()}
/>
) : view === 'plant-management' ? (
@@ -1111,6 +1342,7 @@ export function App() {
activities={careActivities}
error={error}
form={bulkScheduleForm}
groups={plantGroups}
isLoading={isLoading}
isSaving={isSaving}
plants={plants}
@@ -1156,6 +1388,23 @@ export function App() {
onOpenDetail={(location) => setSelectedLocationId(location.id)}
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' ? (
<ActionsView
activeActionName={activeAction?.name}
@@ -1215,6 +1464,26 @@ export function App() {
onSave={() => void saveResource()}
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' ? (
<FlagsView
activeFlagName={activeFlagDefinition?.name}
@@ -1239,6 +1508,7 @@ export function App() {
careTasks={careTasks}
dueCount={dueCount}
error={error}
groups={plantGroups}
isLoading={isLoading}
plants={plants}
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)}
@@ -1265,10 +1535,12 @@ function getViewEyebrow(view: View) {
return 'Care';
case 'taxa':
case 'locations':
case 'groups':
case 'resources':
case 'recipes':
case 'actions':
case 'flags':
return 'Catalogs';
case 'actions':
case 'activities':
return 'Care';
default:
@@ -1288,12 +1560,16 @@ function getViewTitle(view: View) {
return 'Plant Taxa';
case 'locations':
return 'Locations';
case 'groups':
return 'Groups';
case 'actions':
return 'Care Actions';
return 'Actions';
case 'activities':
return 'Care Activities';
case 'resources':
return 'Resources';
case 'recipes':
return 'Recipes';
case 'flags':
return 'Plant Flags';
default:
+52
View File
@@ -8,12 +8,16 @@ import type {
BulkCompleteCareTasksPayload,
CareTask,
BulkPlantCareSchedulePayload,
Recipe,
RecipePayload,
Plant,
AssignPlantFlagPayload,
PlantPayload,
PlantFlag,
PlantFlagDefinition,
PlantFlagDefinitionPayload,
PlantGroup,
PlantGroupPayload,
PlantLocation,
PlantLocationPayload,
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) {
return request<PlantTaxon>('/api/plant-taxa', {
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() {
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 { CalendarCheck, Plus } from 'lucide-react';
import type { CareTask, Plant } from '../domain';
import type { CareTask, Plant, PlantGroup } from '../domain';
import { PlantCard } from './PlantCard';
type HomeViewProps = {
careTasks: CareTask[];
dueCount: number;
error: string | null;
groups: PlantGroup[];
isLoading: boolean;
plants: Plant[];
onCompleteBulkTasks: (tasks: CareTask[]) => void;
@@ -19,6 +20,7 @@ export function HomeView({
careTasks,
dueCount,
error,
groups,
isLoading,
plants,
onCompleteBulkTasks,
@@ -31,6 +33,7 @@ export function HomeView({
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date()));
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0];
const selectedTasks = getTasksForDate(careTasks, selectedDate);
const groupDueTaskGroups = getGroupDueTaskGroups(careTasks, groups);
return (
<>
@@ -136,6 +139,31 @@ export function HomeView({
</>
) : 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 ? (
<>
<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));
}
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[]) {
const names = tasks.map((task) => task.plantName);
if (names.length <= 3) {
@@ -30,6 +30,15 @@ export function PlantCard({ plant, onOpen }: PlantCardProps) {
))}
</div>
) : 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>
<dl>
<div>
@@ -93,6 +93,19 @@ export function PlantManagementView({
{selectedPlant ? (
<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">
<div className="detail-section-heading">
<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 { PlantFormState } from '../form-state';
@@ -10,11 +10,14 @@ type PlantsViewProps = {
isPlantEditorOpen: boolean;
isSaving: boolean;
plants: Plant[];
selectedPlant?: Plant;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (plant: Plant) => void;
onEdit: (plant: Plant) => void;
onFieldChange: (field: keyof PlantFormState, value: string) => void;
onNew: () => void;
onOpenDetail: (plant: Plant) => void;
onSave: () => void;
};
@@ -26,11 +29,14 @@ export function PlantsView({
isPlantEditorOpen,
isSaving,
plants,
selectedPlant,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: PlantsViewProps) {
return (
@@ -69,6 +75,14 @@ export function PlantsView({
onChange={(event) => onFieldChange('nickname', event.target.value)}
/>
</label>
<label>
Birthday
<input
type="date"
value={form.birthday}
onChange={(event) => onFieldChange('birthday', event.target.value)}
/>
</label>
</div>
<div className="form-actions">
@@ -83,6 +97,53 @@ export function PlantsView({
</section>
) : 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">
<div className="section-heading">
<h2 id="plants-list-heading">All Plants</h2>
@@ -97,8 +158,15 @@ export function PlantsView({
<article className="plant-row" key={plant.id}>
<div>
<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 className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${plant.nickname}`} onClick={() => onOpenDetail(plant)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
<Edit3 size={17} />
</button>
@@ -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>
<strong>{selectedResource.notes ?? 'No notes'}</strong>
</div>
{selectedResource.producedByRecipe ? (
<div>
<span>Produced by recipe</span>
<strong>{selectedResource.producedByRecipe.name}</strong>
</div>
) : null}
</div>
</section>
) : null}
@@ -136,7 +142,9 @@ export function ResourcesView({
<div>
<h3>{resource.name}</h3>
<p>
{resource.notes ?? 'No notes'}
{resource.producedByRecipe
? `Produced by ${resource.producedByRecipe.name}`
: resource.notes ?? 'No notes'}
</p>
</div>
<div className="row-actions">
@@ -1,12 +1,13 @@
import { CalendarClock, Save, Trash2 } from 'lucide-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';
type SchedulesViewProps = {
activities: CareActivity[];
error: string | null;
form: BulkScheduleFormState;
groups: PlantGroup[];
isLoading: boolean;
isSaving: boolean;
plants: Plant[];
@@ -38,6 +39,7 @@ export function SchedulesView({
activities,
error,
form,
groups,
isLoading,
isSaving,
plants,
@@ -46,12 +48,14 @@ export function SchedulesView({
onSave,
}: SchedulesViewProps) {
const [plantQuery, setPlantQuery] = useState('');
const [selectedGroupId, setSelectedGroupId] = useState('');
const visiblePlants = useMemo(
() => filterPlants(plants, plantQuery),
[plants, plantQuery],
);
const selectedPlantIds = new Set(form.plantIds);
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 preview = selectedActivity ? formatSchedulePreview(selectedActivity.name, form) : '';
const allVisibleSelected = visiblePlants.length > 0 && visiblePlants.every((plant) => selectedPlantIds.has(String(plant.id)));
@@ -79,7 +83,7 @@ export function SchedulesView({
</div>
<div className="schedule-count">
<CalendarClock size={16} />
<span>{selectedPlants.length} selected</span>
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
</div>
</div>
@@ -108,6 +112,27 @@ export function SchedulesView({
onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
/>
</label>
<label>
Target group
<select
disabled={isSaving}
value={selectedGroupId}
onChange={(event) => {
const groupId = event.target.value;
const group = groups.find((item) => String(item.id) === groupId);
setSelectedGroupId(groupId);
setPlantQuery('');
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
}}
>
<option value="">Individual plants</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.plants.length})
</option>
))}
</select>
</label>
</div>
<fieldset className="schedule-options">
@@ -247,12 +272,15 @@ export function SchedulesView({
className="text-button"
type="button"
disabled={isSaving}
onClick={() => onFieldChange(
'plantIds',
allVisibleSelected
? form.plantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
: [...new Set([...form.plantIds, ...visiblePlants.map((plant) => String(plant.id))])],
)}
onClick={() => {
setSelectedGroupId('');
onFieldChange(
'plantIds',
allVisibleSelected
? form.plantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
: [...new Set([...form.plantIds, ...visiblePlants.map((plant) => String(plant.id))])],
);
}}
>
{allVisibleSelected ? 'Clear visible' : 'Select visible'}
</button>
@@ -291,6 +319,7 @@ export function SchedulesView({
const nextPlantIds = event.target.checked
? [...form.plantIds, plantId]
: form.plantIds.filter((id) => id !== plantId);
setSelectedGroupId('');
onFieldChange('plantIds', nextPlantIds);
}}
/>
@@ -303,11 +332,27 @@ export function SchedulesView({
</div>
<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} />
{isSaving ? 'Saving' : `Apply to ${form.plantIds.length} plants`}
{isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`}
</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} />
Remove from {form.plantIds.length} plants
</button>
+95
View File
@@ -3,6 +3,7 @@ export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled';
export type Plant = {
id: number;
nickname: string;
birthday: string | null;
taxonId: number | null;
taxon: string;
locationId: number | null;
@@ -10,6 +11,8 @@ export type Plant = {
nextCare: string;
status: CareStatus;
flags: PlantFlag[];
groups: PlantGroupSummary[];
actionLogs: ActionLog[];
careSchedules: PlantCareSchedule[];
};
@@ -36,6 +39,7 @@ export type PlantCareSchedule = {
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
export type ScheduleEndsMode = 'on' | 'after';
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
export type PlantTaxon = {
id: number;
@@ -53,6 +57,23 @@ export type PlantLocation = {
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 = {
id: number;
name: string;
@@ -63,6 +84,38 @@ export type ActionResource = {
id: number;
name: string;
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 = {
@@ -106,8 +159,28 @@ export type PlantFlag = {
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 = {
nickname: string;
birthday: string | null;
taxonId: number | null;
locationId: number | null;
careSchedules: PlantCareSchedulePayload[] | null;
@@ -154,6 +227,12 @@ export type PlantLocationPayload = {
notes: string | null;
};
export type PlantGroupPayload = {
name: string;
plantIds: number[];
notes: string | null;
};
export type CareActionPayload = {
name: string;
description: string | null;
@@ -182,6 +261,22 @@ export type CareActivityActionResourcePayload = {
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 = {
name: string;
color: string | null;
+81 -1
View File
@@ -6,13 +6,18 @@ import type {
CareAction,
CareActionPayload,
BulkPlantCareSchedulePayload,
Recipe,
RecipePayload,
Plant,
AssignPlantFlagPayload,
PlantPayload,
PlantFlagDefinition,
PlantFlagDefinitionPayload,
PlantGroup,
PlantGroupPayload,
PlantLocation,
PlantLocationPayload,
RecipeMeasurementMode,
ScheduleEndsMode,
ScheduleRecurrenceMode,
ScheduleRepeatUnit,
@@ -22,6 +27,7 @@ import type {
export const emptyPlantForm = {
nickname: '',
birthday: '',
};
export const emptyTaxonForm = {
@@ -38,6 +44,12 @@ export const emptyLocationForm = {
notes: '',
};
export const emptyPlantGroupForm = {
name: '',
plantIds: [] as string[],
notes: '',
};
export const emptyActionForm = {
name: '',
description: '',
@@ -54,6 +66,15 @@ export const emptyActivityForm = {
notes: '',
};
export const emptyRecipeForm = {
name: '',
type: 'Soil mixture',
measurementMode: 'quantity' as RecipeMeasurementMode,
outputResourceName: '',
components: [] as RecipeComponentFormState[],
notes: '',
};
export type CareActivityActionResourceFormState = {
actionResourceId: string;
quantity: string;
@@ -66,6 +87,13 @@ export type CareActivityActionFormState = {
resources: CareActivityActionResourceFormState[];
};
export type RecipeComponentFormState = {
actionResourceId: string;
quantity: string;
unit: string;
notes: string;
};
export const emptyFlagDefinitionForm = {
name: '',
color: '#f2f2f2',
@@ -94,23 +122,27 @@ export const emptyBulkScheduleForm = {
export type PlantFormState = typeof emptyPlantForm;
export type TaxonFormState = typeof emptyTaxonForm;
export type LocationFormState = typeof emptyLocationForm;
export type PlantGroupFormState = typeof emptyPlantGroupForm;
export type ActionFormState = typeof emptyActionForm;
export type ResourceFormState = typeof emptyResourceForm;
export type ActivityFormState = typeof emptyActivityForm;
export type RecipeFormState = typeof emptyRecipeForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm;
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 {
return {
nickname: plant.nickname,
birthday: plant.birthday ?? '',
};
}
export function toPlantPayload(form: PlantFormState): PlantPayload {
return {
nickname: form.nickname.trim(),
birthday: form.birthday || null,
taxonId: null,
locationId: 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 {
return {
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 {
return {
name: flag.name,
+124
View File
@@ -754,6 +754,7 @@ dd {
.plant-form input,
.plant-form select,
.plant-form textarea,
.flag-assignment-form input,
.flag-assignment-form select {
width: 100%;
@@ -766,6 +767,21 @@ dd {
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'] {
padding: 2px;
}
@@ -901,6 +917,10 @@ dd {
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-row {
grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto;
@@ -1155,6 +1175,100 @@ dd {
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-assignment-form {
display: flex;
@@ -1322,6 +1436,16 @@ dd {
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 {
border-right: 0;
border-bottom: 1px solid var(--border);
+137 -2
View File
@@ -4,12 +4,14 @@ namespace plant_manager
{
public record CreatePlantRequest(
string Nickname,
DateOnly? Birthday,
int? TaxonId,
int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
public record UpdatePlantRequest(
string Nickname,
DateOnly? Birthday,
int? TaxonId,
int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
@@ -51,6 +53,11 @@ namespace plant_manager
string Name,
string? Notes);
public record SavePlantGroupRequest(
string Name,
IReadOnlyList<int>? PlantIds,
string? Notes);
public record SaveCareActionRequest(
string Name,
string? Description);
@@ -74,6 +81,20 @@ namespace plant_manager
string? Unit,
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(
int ActionResourceId,
string Name,
@@ -179,6 +200,39 @@ namespace plant_manager
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(
int Id,
string Name,
@@ -191,10 +245,35 @@ namespace plant_manager
public record ActionResourceDto(
int Id,
string Name,
string? Notes)
string? Notes,
RecipeSummaryDto? ProducedByRecipe)
{
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(
@@ -218,9 +297,53 @@ namespace plant_manager
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(
int Id,
string Nickname,
DateOnly? Birthday,
int? TaxonId,
string Taxon,
int? LocationId,
@@ -228,6 +351,8 @@ namespace plant_manager
string NextCare,
string Status,
IReadOnlyList<PlantFlagDto> Flags,
IReadOnlyList<PlantGroupSummaryDto> Groups,
IReadOnlyList<ActionLogDto> ActionLogs,
IReadOnlyList<PlantCareScheduleDto> CareSchedules)
{
public static PlantDto FromPlant(Plant plant)
@@ -256,6 +381,7 @@ namespace plant_manager
return new PlantDto(
plant.Id,
plant.Nickname,
plant.Birthday,
plant.TaxonId,
plant.Taxon is null ? "Unassigned" : $"{plant.Taxon.Genus} {plant.Taxon.Species}",
plant.LocationId,
@@ -268,6 +394,15 @@ namespace plant_manager
.ThenBy(flag => flag.Definition.Name)
.Select(PlantFlagDto.FromPlantFlag)
.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);
}
@@ -18,6 +18,10 @@ namespace plant_manager.Data
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { 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)
{
@@ -40,6 +44,7 @@ namespace plant_manager.Data
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Nickname).HasMaxLength(120).IsRequired();
entity.Property(e => e.Birthday);
entity.HasOne(e => e.Taxon)
.WithMany()
.HasForeignKey(e => e.TaxonId)
@@ -211,6 +216,64 @@ namespace plant_manager.Data
.HasForeignKey(e => e.PlantFlagDefinitionId)
.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")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -109,6 +110,7 @@ namespace plant_manager.Data.Migrations
b.Property<string>("Description")
.HasMaxLength(400)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
@@ -127,6 +129,7 @@ namespace plant_manager.Data.Migrations
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -201,6 +204,9 @@ namespace plant_manager.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateOnly?>("Birthday")
.HasColumnType("TEXT");
b.Property<int?>("LocationId")
.HasColumnType("INTEGER");
@@ -246,6 +252,7 @@ namespace plant_manager.Data.Migrations
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
b.Property<int>("PlantId")
.HasColumnType("INTEGER");
@@ -324,6 +331,7 @@ namespace plant_manager.Data.Migrations
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
@@ -337,11 +345,50 @@ namespace plant_manager.Data.Migrations
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)
@@ -397,6 +444,78 @@ namespace plant_manager.Data.Migrations
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")
@@ -544,6 +663,54 @@ namespace plant_manager.Data.Migrations
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");
@@ -554,6 +721,10 @@ namespace plant_manager.Data.Migrations
b.Navigation("ActionLogResources");
b.Navigation("CareActivityActionResources");
b.Navigation("ProducedByRecipe");
b.Navigation("RecipeComponents");
});
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
@@ -586,6 +757,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareSchedules");
b.Navigation("Flags");
b.Navigation("GroupMemberships");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
@@ -593,10 +766,20 @@ namespace plant_manager.Data.Migrations
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
}
}
@@ -9,5 +9,7 @@ namespace plant_manager.Data.Models
public List<CareActivityActionResource> CareActivityActionResources { 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? LocationId { get; set; }
public string Nickname { get; set; } = string.Empty;
public DateOnly? Birthday { get; set; }
public PlantTaxon? Taxon { get; set; }
public PlantLocation? Location { get; set; }
public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> CareSchedules { 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) =>
{
var resources = await db.ActionResources
.Include(resource => resource.ProducedByRecipe)
.OrderBy(resource => resource.Name)
.Select(resource => ActionResourceDto.FromActionResource(resource))
.ToListAsync();
@@ -82,7 +83,8 @@ namespace plant_manager.Endpoints
}
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)
{
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(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.OrderBy(plant => plant.Nickname)
.ToListAsync();
@@ -50,8 +54,12 @@ namespace plant_manager.Endpoints
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
.Include(plant => plant.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.FirstOrDefaultAsync(item => item.Id == id);
return plant is null
@@ -81,6 +89,7 @@ namespace plant_manager.Endpoints
var plant = new Plant
{
Nickname = request.Nickname.Trim(),
Birthday = request.Birthday,
TaxonId = request.TaxonId,
LocationId = request.LocationId
};
@@ -125,8 +134,12 @@ namespace plant_manager.Endpoints
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(item => item.ActionLogs)
.ThenInclude(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(item => item.Flags)
.ThenInclude(flag => flag.Definition)
.Include(item => item.GroupMemberships)
.ThenInclude(membership => membership.PlantGroup)
.FirstOrDefaultAsync(item => item.Id == id);
if (plant is null)
@@ -147,6 +160,7 @@ namespace plant_manager.Endpoints
}
plant.Nickname = request.Nickname.Trim();
plant.Birthday = request.Birthday;
plant.TaxonId = request.TaxonId;
plant.LocationId = request.LocationId;
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.MapPlantTaxonEndpoints();
app.MapPlantLocationEndpoints();
app.MapPlantGroupEndpoints();
app.MapCareActionEndpoints();
app.MapActionResourceEndpoints();
app.MapCareActivityEndpoints();
app.MapRecipeEndpoints();
app.MapPlantCareScheduleEndpoints();
app.MapCareTaskEndpoints();
app.MapActionLogEndpoints();