add recipes, timelines, group scheduling
This commit is contained in:
+286
-10
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+789
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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." });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user