add care workflow and schedule management
This commit is contained in:
@@ -7,6 +7,9 @@
|
|||||||
.env
|
.env
|
||||||
.codex/
|
.codex/
|
||||||
data/
|
data/
|
||||||
|
plant-manager/App_Data/
|
||||||
|
core.*
|
||||||
|
**/core.*
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ActionsView } from './components/ActionsView';
|
import { ActionsView } from './components/ActionsView';
|
||||||
import { ActivitiesView } from './components/ActivitiesView';
|
import { ActivitiesView } from './components/ActivitiesView';
|
||||||
|
import { CareView } from './components/CareView';
|
||||||
import { FlagsView } from './components/FlagsView';
|
import { FlagsView } from './components/FlagsView';
|
||||||
import { GroupsView } from './components/GroupsView';
|
import { GroupsView } from './components/GroupsView';
|
||||||
import { HomeView } from './components/HomeView';
|
import { HomeView } from './components/HomeView';
|
||||||
@@ -35,6 +36,7 @@ export function App() {
|
|||||||
loadPlantsAndCareTasks,
|
loadPlantsAndCareTasks,
|
||||||
loadRecipesAndResources,
|
loadRecipesAndResources,
|
||||||
loadTaxaAndPlants,
|
loadTaxaAndPlants,
|
||||||
|
plantCareSchedules,
|
||||||
plantFlagDefinitions,
|
plantFlagDefinitions,
|
||||||
plantGroups,
|
plantGroups,
|
||||||
plantLocations,
|
plantLocations,
|
||||||
@@ -64,7 +66,6 @@ export function App() {
|
|||||||
activePlantGroup,
|
activePlantGroup,
|
||||||
activeRecipe,
|
activeRecipe,
|
||||||
activeResource,
|
activeResource,
|
||||||
activeTaxon,
|
|
||||||
activityForm,
|
activityForm,
|
||||||
bulkScheduleForm,
|
bulkScheduleForm,
|
||||||
cancelEditing,
|
cancelEditing,
|
||||||
@@ -75,7 +76,8 @@ export function App() {
|
|||||||
cancelEditingPlantGroup,
|
cancelEditingPlantGroup,
|
||||||
cancelEditingRecipe,
|
cancelEditingRecipe,
|
||||||
cancelEditingResource,
|
cancelEditingResource,
|
||||||
cancelEditingTaxon,
|
cancelEditingSchedule,
|
||||||
|
editingScheduleId,
|
||||||
flagDefinitionForm,
|
flagDefinitionForm,
|
||||||
form,
|
form,
|
||||||
isActionEditorOpen,
|
isActionEditorOpen,
|
||||||
@@ -86,7 +88,6 @@ export function App() {
|
|||||||
isPlantGroupEditorOpen,
|
isPlantGroupEditorOpen,
|
||||||
isRecipeEditorOpen,
|
isRecipeEditorOpen,
|
||||||
isResourceEditorOpen,
|
isResourceEditorOpen,
|
||||||
isTaxonEditorOpen,
|
|
||||||
locationForm,
|
locationForm,
|
||||||
openPlantDetail,
|
openPlantDetail,
|
||||||
plantFlagForm,
|
plantFlagForm,
|
||||||
@@ -118,8 +119,6 @@ export function App() {
|
|||||||
startAddingPlantGroup,
|
startAddingPlantGroup,
|
||||||
startAddingRecipe,
|
startAddingRecipe,
|
||||||
startAddingResource,
|
startAddingResource,
|
||||||
startAddingTaxon,
|
|
||||||
startAddingTaxonFromPlantInfo,
|
|
||||||
startEditingAction,
|
startEditingAction,
|
||||||
startEditingActivity,
|
startEditingActivity,
|
||||||
startEditingFlagDefinition,
|
startEditingFlagDefinition,
|
||||||
@@ -127,8 +126,8 @@ export function App() {
|
|||||||
startEditingPlantGroup,
|
startEditingPlantGroup,
|
||||||
startEditingRecipe,
|
startEditingRecipe,
|
||||||
startEditingResource,
|
startEditingResource,
|
||||||
startEditingTaxon,
|
startEditingSchedule,
|
||||||
taxonForm,
|
startNewSchedule,
|
||||||
updateActionForm,
|
updateActionForm,
|
||||||
updateActivityForm,
|
updateActivityForm,
|
||||||
updateBulkScheduleForm,
|
updateBulkScheduleForm,
|
||||||
@@ -139,7 +138,6 @@ export function App() {
|
|||||||
updatePlantGroupForm,
|
updatePlantGroupForm,
|
||||||
updateRecipeForm,
|
updateRecipeForm,
|
||||||
updateResourceForm,
|
updateResourceForm,
|
||||||
updateTaxonForm,
|
|
||||||
} = editors;
|
} = editors;
|
||||||
const {
|
const {
|
||||||
assignFlagToSelectedPlant,
|
assignFlagToSelectedPlant,
|
||||||
@@ -147,11 +145,13 @@ export function App() {
|
|||||||
catalogImportResult,
|
catalogImportResult,
|
||||||
completeBulkTasks,
|
completeBulkTasks,
|
||||||
completeTask,
|
completeTask,
|
||||||
|
dismissCare,
|
||||||
exportSpreadsheet,
|
exportSpreadsheet,
|
||||||
isExporting,
|
isExporting,
|
||||||
isImportingCatalog,
|
isImportingCatalog,
|
||||||
isSearchingPlantInfo,
|
isSearchingPlantInfo,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
logCare,
|
||||||
importTaxonFromPlantInfo,
|
importTaxonFromPlantInfo,
|
||||||
hasSearchedPlantInfo,
|
hasSearchedPlantInfo,
|
||||||
plantInfoQuery,
|
plantInfoQuery,
|
||||||
@@ -159,7 +159,7 @@ export function App() {
|
|||||||
removeAction,
|
removeAction,
|
||||||
removeActivity,
|
removeActivity,
|
||||||
removeAssignedPlantFlag,
|
removeAssignedPlantFlag,
|
||||||
removeBulkSchedule,
|
removeSchedule,
|
||||||
removeFlagDefinition,
|
removeFlagDefinition,
|
||||||
removeLocation,
|
removeLocation,
|
||||||
removePlant,
|
removePlant,
|
||||||
@@ -177,7 +177,6 @@ export function App() {
|
|||||||
savePlantGroup,
|
savePlantGroup,
|
||||||
saveRecipe,
|
saveRecipe,
|
||||||
saveResource,
|
saveResource,
|
||||||
saveTaxon,
|
|
||||||
searchTaxonInfo,
|
searchTaxonInfo,
|
||||||
previewCatalogImportFile,
|
previewCatalogImportFile,
|
||||||
setPlantInfoQuery,
|
setPlantInfoQuery,
|
||||||
@@ -238,6 +237,13 @@ export function App() {
|
|||||||
|
|
||||||
<div className="nav-group">
|
<div className="nav-group">
|
||||||
<h3>Care</h3>
|
<h3>Care</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={view === 'care' ? 'page' : undefined}
|
||||||
|
onClick={() => setView('care')}
|
||||||
|
>
|
||||||
|
Care
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={view === 'schedules' ? 'page' : undefined}
|
aria-current={view === 'schedules' ? 'page' : undefined}
|
||||||
@@ -261,7 +267,7 @@ export function App() {
|
|||||||
aria-current={view === 'taxa' ? 'page' : undefined}
|
aria-current={view === 'taxa' ? 'page' : undefined}
|
||||||
onClick={() => setView('taxa')}
|
onClick={() => setView('taxa')}
|
||||||
>
|
>
|
||||||
Plant Taxa
|
Taxa
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -303,7 +309,7 @@ export function App() {
|
|||||||
aria-current={view === 'flags' ? 'page' : undefined}
|
aria-current={view === 'flags' ? 'page' : undefined}
|
||||||
onClick={() => setView('flags')}
|
onClick={() => setView('flags')}
|
||||||
>
|
>
|
||||||
Plant Flags
|
Flags
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -365,34 +371,41 @@ export function App() {
|
|||||||
error={error}
|
error={error}
|
||||||
form={bulkScheduleForm}
|
form={bulkScheduleForm}
|
||||||
groups={plantGroups}
|
groups={plantGroups}
|
||||||
|
editingScheduleId={editingScheduleId}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
plants={plants}
|
plants={plants}
|
||||||
|
schedules={plantCareSchedules}
|
||||||
|
onCancel={cancelEditingSchedule}
|
||||||
|
onDelete={removeSchedule}
|
||||||
|
onEdit={startEditingSchedule}
|
||||||
onFieldChange={updateBulkScheduleForm}
|
onFieldChange={updateBulkScheduleForm}
|
||||||
onRemove={() => void removeBulkSchedule()}
|
onNew={startNewSchedule}
|
||||||
onSave={() => void saveBulkSchedule()}
|
onSave={() => void saveBulkSchedule()}
|
||||||
/>
|
/>
|
||||||
|
) : view === 'care' ? (
|
||||||
|
<CareView
|
||||||
|
activities={careActivities}
|
||||||
|
careTasks={careTasks}
|
||||||
|
error={error}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
plants={plants}
|
||||||
|
onDismissCare={(payload) => void dismissCare(payload)}
|
||||||
|
onLogCare={(payload, requireDueSchedule) => void logCare(payload, requireDueSchedule)}
|
||||||
|
/>
|
||||||
) : view === 'taxa' ? (
|
) : view === 'taxa' ? (
|
||||||
<TaxaView
|
<TaxaView
|
||||||
activeTaxonName={activeTaxon?.name}
|
|
||||||
error={error}
|
error={error}
|
||||||
form={taxonForm}
|
|
||||||
hasSearched={hasSearchedPlantInfo}
|
hasSearched={hasSearchedPlantInfo}
|
||||||
isEditorOpen={isTaxonEditorOpen}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
selectedTaxon={selectedTaxon}
|
selectedTaxon={selectedTaxon}
|
||||||
taxa={plantTaxa}
|
taxa={plantTaxa}
|
||||||
onCancel={cancelEditingTaxon}
|
|
||||||
onCloseDetail={() => setSelectedTaxonId(null)}
|
onCloseDetail={() => setSelectedTaxonId(null)}
|
||||||
onDelete={(taxon) => void removeTaxon(taxon)}
|
onDelete={(taxon) => void removeTaxon(taxon)}
|
||||||
onEdit={startEditingTaxon}
|
|
||||||
onFieldChange={updateTaxonForm}
|
|
||||||
onImportResult={(result) => void importTaxonFromPlantInfo(result)}
|
onImportResult={(result) => void importTaxonFromPlantInfo(result)}
|
||||||
onNew={startAddingTaxon}
|
|
||||||
onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)}
|
onOpenDetail={(taxon) => setSelectedTaxonId(taxon.id)}
|
||||||
onPrefillResult={startAddingTaxonFromPlantInfo}
|
|
||||||
onSave={() => void saveTaxon()}
|
|
||||||
onSearch={(query) => void searchTaxonInfo(query)}
|
onSearch={(query) => void searchTaxonInfo(query)}
|
||||||
onSearchQueryChange={setPlantInfoQuery}
|
onSearchQueryChange={setPlantInfoQuery}
|
||||||
plantInfoResults={plantInfoResults}
|
plantInfoResults={plantInfoResults}
|
||||||
@@ -537,12 +550,10 @@ export function App() {
|
|||||||
<HomeView
|
<HomeView
|
||||||
careTasks={careTasks}
|
careTasks={careTasks}
|
||||||
error={error}
|
error={error}
|
||||||
groups={plantGroups}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
plants={plants}
|
plants={plants}
|
||||||
onCompleteBulkTasks={(tasks) => void completeBulkTasks(tasks)}
|
onOpenCare={() => setView('care')}
|
||||||
onCompleteTask={(task) => void completeTask(task)}
|
onOpenPlants={() => setView('plant-management')}
|
||||||
onOpenPlant={openPlantDetail}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import type {
|
|||||||
BulkCompleteCareTasksPayload,
|
BulkCompleteCareTasksPayload,
|
||||||
CareTask,
|
CareTask,
|
||||||
BulkPlantCareSchedulePayload,
|
BulkPlantCareSchedulePayload,
|
||||||
|
DismissCareTasksPayload,
|
||||||
Recipe,
|
Recipe,
|
||||||
RecipePayload,
|
RecipePayload,
|
||||||
Plant,
|
Plant,
|
||||||
|
PlantCareScheduleRule,
|
||||||
PlantInfoSearchResult,
|
PlantInfoSearchResult,
|
||||||
AssignPlantFlagPayload,
|
AssignPlantFlagPayload,
|
||||||
PlantPayload,
|
PlantPayload,
|
||||||
@@ -23,7 +25,6 @@ import type {
|
|||||||
PlantLocation,
|
PlantLocation,
|
||||||
PlantLocationPayload,
|
PlantLocationPayload,
|
||||||
PlantTaxon,
|
PlantTaxon,
|
||||||
PlantTaxonPayload,
|
|
||||||
} from './domain';
|
} from './domain';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
@@ -157,13 +158,6 @@ export async function deletePlantGroup(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPlantTaxon(payload: PlantTaxonPayload) {
|
|
||||||
return request<PlantTaxon>('/api/plant-taxa', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importPlantTaxon(payload: PlantInfoSearchResult) {
|
export async function importPlantTaxon(payload: PlantInfoSearchResult) {
|
||||||
return request<PlantTaxon>('/api/plant-taxa/import', {
|
return request<PlantTaxon>('/api/plant-taxa/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -171,13 +165,6 @@ export async function importPlantTaxon(payload: PlantInfoSearchResult) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlantTaxon(id: number, payload: PlantTaxonPayload) {
|
|
||||||
return request<PlantTaxon>(`/api/plant-taxa/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePlantTaxon(id: number) {
|
export async function deletePlantTaxon(id: number) {
|
||||||
return request<void>(`/api/plant-taxa/${id}`, {
|
return request<void>(`/api/plant-taxa/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -347,23 +334,47 @@ export async function getCareTasks() {
|
|||||||
return request<CareTask[]>('/api/care-tasks/upcoming');
|
return request<CareTask[]>('/api/care-tasks/upcoming');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
|
export async function getPlantCareSchedules() {
|
||||||
return request<{ updated: number }>('/api/plant-care-schedules/bulk', {
|
return request<PlantCareScheduleRule[]>('/api/plant-care-schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlantCareSchedule(payload: BulkPlantCareSchedulePayload) {
|
||||||
|
return request<PlantCareScheduleRule>('/api/plant-care-schedules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
|
export async function updatePlantCareSchedule(id: number, payload: BulkPlantCareSchedulePayload) {
|
||||||
return request<{ removed: number }>('/api/plant-care-schedules/bulk-remove', {
|
return request<PlantCareScheduleRule>(`/api/plant-care-schedules/${id}`, {
|
||||||
method: 'POST',
|
method: 'PUT',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deletePlantCareSchedule(id: number) {
|
||||||
|
return request<void>(`/api/plant-care-schedules/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
|
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
|
||||||
return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
|
return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dismissCareTasksBulk(payload: DismissCareTasksPayload) {
|
||||||
|
return request<{ dismissed: number }>('/api/care-tasks/dismiss-bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActionLogsBulk(payload: BulkCompleteCareTasksPayload) {
|
||||||
|
return request<{ completed: number }>('/api/action-logs/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionResource, CareAction, CareActivity } from '../domain';
|
import type { ActionResource, CareAction, CareActivity, CareActivityRecipeComponent } from '../domain';
|
||||||
import type { ActivityFormState } from '../form-state';
|
import type { ActivityFormState } from '../form-state';
|
||||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||||
|
|
||||||
@@ -147,14 +147,32 @@ export function ActivitiesView({
|
|||||||
<h4>{activityAction.name}</h4>
|
<h4>{activityAction.name}</h4>
|
||||||
<p>{activityAction.description ?? 'No description'}</p>
|
<p>{activityAction.description ?? 'No description'}</p>
|
||||||
{activityAction.resources.length > 0 ? (
|
{activityAction.resources.length > 0 ? (
|
||||||
<p>
|
<div className="activity-resource-detail-list">
|
||||||
{activityAction.resources.map((resource) => {
|
{activityAction.resources.map((resource) => (
|
||||||
const amount = resource.quantity === null
|
<div className="activity-resource-detail" key={resource.actionResourceId}>
|
||||||
? ''
|
<p>{formatActivityResource(resource)}</p>
|
||||||
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
{resource.producedByRecipe ? (
|
||||||
return `${resource.name}${amount}`;
|
<div className="recipe-procedure">
|
||||||
}).join(', ')}
|
<h5>{resource.producedByRecipe.name}</h5>
|
||||||
</p>
|
{resource.producedByRecipe.components.length > 0 ? (
|
||||||
|
<ol>
|
||||||
|
{resource.producedByRecipe.components.map((component) => (
|
||||||
|
<li key={component.actionResourceId}>
|
||||||
|
{formatRecipeComponent(component)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p>No recipe components configured.</p>
|
||||||
|
)}
|
||||||
|
{resource.producedByRecipe.notes ? (
|
||||||
|
<p>{resource.producedByRecipe.notes}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p>No resources</p>
|
<p>No resources</p>
|
||||||
)}
|
)}
|
||||||
@@ -406,3 +424,18 @@ function formatActivitySummary(activity: CareActivity) {
|
|||||||
})
|
})
|
||||||
.join(' | ');
|
.join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatActivityResource(resource: CareActivity['actions'][number]['resources'][number]) {
|
||||||
|
const amount = resource.quantity === null
|
||||||
|
? ''
|
||||||
|
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
||||||
|
return `${resource.name}${amount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecipeComponent(component: CareActivityRecipeComponent) {
|
||||||
|
const amount = component.quantity === null
|
||||||
|
? ''
|
||||||
|
: ` (${component.quantity}${component.unit ? ` ${component.unit}` : ''})`;
|
||||||
|
const notes = component.notes ? ` - ${component.notes}` : '';
|
||||||
|
return `${component.name}${amount}${notes}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { BulkCompleteCareTasksPayload, CareActivity, CareActivityActionResource, CareActivityRecipeComponent, CareTask, DismissCareTasksPayload, Plant } from '../domain';
|
||||||
|
import { SummaryStrip } from './Ui';
|
||||||
|
|
||||||
|
type CareMode = 'due' | 'upcoming' | 'adHoc';
|
||||||
|
|
||||||
|
type CareViewProps = {
|
||||||
|
activities: CareActivity[];
|
||||||
|
careTasks: CareTask[];
|
||||||
|
error: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
plants: Plant[];
|
||||||
|
onDismissCare: (payload: DismissCareTasksPayload) => void;
|
||||||
|
onLogCare: (payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeOptions: { value: CareMode; label: string }[] = [
|
||||||
|
{ value: 'due', label: 'Due' },
|
||||||
|
{ value: 'upcoming', label: 'Upcoming' },
|
||||||
|
{ value: 'adHoc', label: 'Ad Hoc' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CareView({
|
||||||
|
activities,
|
||||||
|
careTasks,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
plants,
|
||||||
|
onDismissCare,
|
||||||
|
onLogCare,
|
||||||
|
}: CareViewProps) {
|
||||||
|
const [mode, setMode] = useState<CareMode>('due');
|
||||||
|
const [activityId, setActivityId] = useState('');
|
||||||
|
const [selectedPlantIds, setSelectedPlantIds] = useState<string[]>([]);
|
||||||
|
const [plantQuery, setPlantQuery] = useState('');
|
||||||
|
const [performedOn, setPerformedOn] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [resourceEdits, setResourceEdits] = useState<Record<number, { quantity: string; unit: string }>>({});
|
||||||
|
|
||||||
|
const modeTasks = careTasks.filter((task) => mode === 'due' ? task.status === 'due' : task.status === 'soon');
|
||||||
|
const activityOptions = mode === 'adHoc'
|
||||||
|
? activities
|
||||||
|
: activities.filter((activity) => modeTasks.some((task) => task.careActivityId === activity.id));
|
||||||
|
const selectedActivity = activities.find((activity) => String(activity.id) === activityId)
|
||||||
|
?? activityOptions[0];
|
||||||
|
const selectedActivityId = selectedActivity?.id ?? 0;
|
||||||
|
const taskPlants = modeTasks.filter((task) => task.careActivityId === selectedActivityId);
|
||||||
|
const visiblePlants = useMemo(
|
||||||
|
() => filterPlants(mode === 'adHoc' ? plants : plantsForTasks(taskPlants, plants), plantQuery),
|
||||||
|
[mode, plants, plantQuery, taskPlants],
|
||||||
|
);
|
||||||
|
const selectedPlantIdSet = new Set(selectedPlantIds);
|
||||||
|
const activityResources = selectedActivity ? getActivityResources(selectedActivity) : [];
|
||||||
|
|
||||||
|
function togglePlant(plantId: number, checked: boolean) {
|
||||||
|
const value = String(plantId);
|
||||||
|
setSelectedPlantIds((current) => checked
|
||||||
|
? [...new Set([...current, value])]
|
||||||
|
: current.filter((id) => id !== value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMode(nextMode: CareMode) {
|
||||||
|
setMode(nextMode);
|
||||||
|
setActivityId('');
|
||||||
|
setSelectedPlantIds([]);
|
||||||
|
setPlantQuery('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeActivity(nextActivityId: string) {
|
||||||
|
setActivityId(nextActivityId);
|
||||||
|
setSelectedPlantIds([]);
|
||||||
|
setResourceEdits({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (!selectedActivity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogCare({
|
||||||
|
careActivityId: selectedActivity.id,
|
||||||
|
plantIds: selectedPlantIds.map((id) => Number(id)),
|
||||||
|
performedOn,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
resources: activityResources.map((resource) => ({
|
||||||
|
actionResourceId: resource.actionResourceId,
|
||||||
|
quantity: normalizeQuantity(resourceEdits[resource.actionResourceId]?.quantity, resource.quantity),
|
||||||
|
unit: resourceEdits[resource.actionResourceId]?.unit.trim() || resource.unit,
|
||||||
|
})),
|
||||||
|
}, mode === 'due');
|
||||||
|
|
||||||
|
setSelectedPlantIds([]);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (!selectedActivity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDismissCare({
|
||||||
|
careActivityId: selectedActivity.id,
|
||||||
|
plantIds: selectedPlantIds.map((id) => Number(id)),
|
||||||
|
dismissedOn: performedOn,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedPlantIds([]);
|
||||||
|
setNotes('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SummaryStrip ariaLabel="Care summary">
|
||||||
|
<p>{error ?? 'Choose care to perform, review the procedure, then log the work.'}</p>
|
||||||
|
</SummaryStrip>
|
||||||
|
|
||||||
|
<section className="work-panel" aria-labelledby="care-workflow-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="care-workflow-heading">Care</h2>
|
||||||
|
</div>
|
||||||
|
<span className="schedule-count">{selectedPlantIds.length} selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="care-mode-switch" role="tablist" aria-label="Care mode">
|
||||||
|
{modeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === option.value}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => changeMode(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plant-form">
|
||||||
|
<label>
|
||||||
|
Activity
|
||||||
|
<select
|
||||||
|
disabled={isSaving || activityOptions.length === 0}
|
||||||
|
value={selectedActivity ? String(selectedActivity.id) : ''}
|
||||||
|
onChange={(event) => changeActivity(event.target.value)}
|
||||||
|
>
|
||||||
|
{activityOptions.length === 0 ? (
|
||||||
|
<option value="">No activities available</option>
|
||||||
|
) : null}
|
||||||
|
{activityOptions.map((activity) => (
|
||||||
|
<option key={activity.id} value={activity.id}>
|
||||||
|
{activity.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Date
|
||||||
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
|
type="date"
|
||||||
|
value={performedOn}
|
||||||
|
onChange={(event) => setPerformedOn(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Search plants
|
||||||
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
|
type="search"
|
||||||
|
value={plantQuery}
|
||||||
|
onChange={(event) => setPlantQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="care-layout">
|
||||||
|
<div className="work-panel care-subpanel" aria-labelledby="care-plants-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 id="care-plants-heading">Plants</h2>
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving || visiblePlants.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const allVisibleSelected = visiblePlants.every((plant) => selectedPlantIdSet.has(String(plant.id)));
|
||||||
|
setSelectedPlantIds(allVisibleSelected
|
||||||
|
? selectedPlantIds.filter((id) => !visiblePlants.some((plant) => String(plant.id) === id))
|
||||||
|
: [...new Set([...selectedPlantIds, ...visiblePlants.map((plant) => String(plant.id))])]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select visible
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plant-list compact-plant-list">
|
||||||
|
{!isLoading && visiblePlants.length === 0 ? (
|
||||||
|
<p className="empty-state">{mode === 'adHoc' ? 'No plants match that search.' : 'No care tasks in this queue.'}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{visiblePlants.map((plant) => {
|
||||||
|
const task = taskPlants.find((item) => item.plantId === plant.id);
|
||||||
|
return (
|
||||||
|
<label className="plant-row check-row" key={plant.id}>
|
||||||
|
<span>
|
||||||
|
<input
|
||||||
|
checked={selectedPlantIdSet.has(String(plant.id))}
|
||||||
|
disabled={isSaving}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(event) => togglePlant(plant.id, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<strong>{plant.nickname}</strong>
|
||||||
|
<small>{mode === 'adHoc' ? `${plant.taxon} - ${plant.location}` : task?.due ?? plant.nextCare}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="work-panel care-subpanel" aria-labelledby="care-procedure-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 id="care-procedure-heading">Procedure</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedActivity ? (
|
||||||
|
<p className="empty-state">Select an activity.</p>
|
||||||
|
) : (
|
||||||
|
<div className="detail-list">
|
||||||
|
{selectedActivity.actions.map((action) => (
|
||||||
|
<div className="detail-row" key={action.careActionId}>
|
||||||
|
<div>
|
||||||
|
<h4>{action.name}</h4>
|
||||||
|
<p>{action.description ?? 'No description'}</p>
|
||||||
|
{action.resources.length === 0 ? (
|
||||||
|
<p>No resources configured.</p>
|
||||||
|
) : action.resources.map((resource) => (
|
||||||
|
<div className="activity-resource-detail" key={resource.actionResourceId}>
|
||||||
|
<p>{formatResource(resource)}</p>
|
||||||
|
<div className="care-resource-edit">
|
||||||
|
<input
|
||||||
|
aria-label={`${resource.name} quantity`}
|
||||||
|
disabled={isSaving}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={resourceEdits[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity)}
|
||||||
|
onChange={(event) => setResourceEdits((current) => ({
|
||||||
|
...current,
|
||||||
|
[resource.actionResourceId]: {
|
||||||
|
quantity: event.target.value,
|
||||||
|
unit: current[resource.actionResourceId]?.unit ?? resource.unit ?? '',
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={`${resource.name} unit`}
|
||||||
|
disabled={isSaving}
|
||||||
|
value={resourceEdits[resource.actionResourceId]?.unit ?? resource.unit ?? ''}
|
||||||
|
onChange={(event) => setResourceEdits((current) => ({
|
||||||
|
...current,
|
||||||
|
[resource.actionResourceId]: {
|
||||||
|
quantity: current[resource.actionResourceId]?.quantity ?? formatNumber(resource.quantity),
|
||||||
|
unit: event.target.value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resource.producedByRecipe ? (
|
||||||
|
<div className="recipe-procedure">
|
||||||
|
<h5>{resource.producedByRecipe.name}</h5>
|
||||||
|
<ol>
|
||||||
|
{resource.producedByRecipe.components.map((component) => (
|
||||||
|
<li key={component.actionResourceId}>{formatRecipeComponent(component)}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{resource.producedByRecipe.notes ? <p>{resource.producedByRecipe.notes}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="care-notes">
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
disabled={isSaving}
|
||||||
|
value={notes}
|
||||||
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
className="primary-action"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving' : 'Log'}
|
||||||
|
</button>
|
||||||
|
{mode === 'due' ? (
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving || !selectedActivity || selectedPlantIds.length === 0}
|
||||||
|
onClick={dismiss}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plantsForTasks(tasks: CareTask[], plants: Plant[]) {
|
||||||
|
const taskPlantIds = new Set(tasks.map((task) => task.plantId));
|
||||||
|
return plants.filter((plant) => taskPlantIds.has(plant.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPlants(plants: Plant[], query: string) {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return plants;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plants.filter((plant) => `${plant.nickname} ${plant.taxon} ${plant.location}`.toLowerCase().includes(normalizedQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityResources(activity: CareActivity) {
|
||||||
|
const resources = new Map<number, CareActivityActionResource>();
|
||||||
|
for (const action of activity.actions) {
|
||||||
|
for (const resource of action.resources) {
|
||||||
|
if (!resources.has(resource.actionResourceId)) {
|
||||||
|
resources.set(resource.actionResourceId, resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...resources.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuantity(value: string | undefined, fallback: number | null) {
|
||||||
|
if (value === undefined || value.trim() === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResource(resource: CareActivityActionResource) {
|
||||||
|
const quantity = resource.quantity === null ? '' : `${resource.quantity} `;
|
||||||
|
const unit = resource.unit ? `${resource.unit} ` : '';
|
||||||
|
return `${quantity}${unit}${resource.name}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecipeComponent(component: CareActivityRecipeComponent) {
|
||||||
|
const quantity = component.quantity === null ? '' : `${component.quantity}`;
|
||||||
|
const unit = component.unit ?? '';
|
||||||
|
return `${component.name}: ${quantity}${unit ? ` ${unit}` : ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | null) {
|
||||||
|
return value === null ? '' : String(value);
|
||||||
|
}
|
||||||
@@ -1,332 +1,124 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import type { ActionLog, CareTask, Plant } from '../domain';
|
||||||
import type { CareTask, Plant, PlantGroup } from '../domain';
|
|
||||||
import { PlantCard } from './PlantCard';
|
|
||||||
import { SummaryStrip } from './Ui';
|
import { SummaryStrip } from './Ui';
|
||||||
|
|
||||||
type HomeViewProps = {
|
type HomeViewProps = {
|
||||||
careTasks: CareTask[];
|
careTasks: CareTask[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
groups: PlantGroup[];
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
plants: Plant[];
|
plants: Plant[];
|
||||||
onCompleteBulkTasks: (tasks: CareTask[]) => void;
|
onOpenCare: () => void;
|
||||||
onCompleteTask: (task: CareTask) => void;
|
onOpenPlants: () => void;
|
||||||
onOpenPlant: (plant: Plant) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HomeView({
|
export function HomeView({
|
||||||
careTasks,
|
careTasks,
|
||||||
error,
|
error,
|
||||||
groups,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
plants,
|
plants,
|
||||||
onCompleteBulkTasks,
|
onOpenCare,
|
||||||
onCompleteTask,
|
onOpenPlants,
|
||||||
onOpenPlant,
|
|
||||||
}: HomeViewProps) {
|
}: HomeViewProps) {
|
||||||
const dueTaskGroups = groupDueTasks(careTasks);
|
const dueCount = careTasks.filter((task) => task.status === 'due').length;
|
||||||
const weekDays = useMemo(() => getWeekDays(), []);
|
const upcomingCount = careTasks.filter((task) => task.status === 'soon').length;
|
||||||
const [selectedDate, setSelectedDate] = useState(() => weekDays[0]?.dateKey ?? getDateKey(new Date()));
|
const activeFlagCount = plants.reduce(
|
||||||
const selectedDay = weekDays.find((day) => day.dateKey === selectedDate) ?? weekDays[0];
|
(count, plant) => count + plant.flags.filter((flag) => flag.resolvedOn === null).length,
|
||||||
const selectedTasks = getTasksForDate(careTasks, selectedDate);
|
0,
|
||||||
const groupDueTaskGroups = getGroupDueTaskGroups(careTasks, groups);
|
);
|
||||||
|
const recentLogs = getRecentLogs(plants);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SummaryStrip ariaLabel="Dashboard summary">
|
<SummaryStrip ariaLabel="Dashboard summary">
|
||||||
<p>{error ?? 'Start with the plants that need attention now.'}</p>
|
<p>{error ?? 'Quick status for care and collection health.'}</p>
|
||||||
</SummaryStrip>
|
</SummaryStrip>
|
||||||
|
|
||||||
<section className="work-panel" aria-labelledby="calendar-heading">
|
<div className="dashboard-panel-grid">
|
||||||
|
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-care-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 id="dashboard-care-heading">Care</h2>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-stat-grid">
|
||||||
|
<DashboardStat label="Due" value={dueCount} />
|
||||||
|
<DashboardStat label="Upcoming" value={upcomingCount} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="primary-action" type="button" onClick={onOpenCare}>
|
||||||
|
Open Care
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="work-panel dashboard-panel" aria-labelledby="dashboard-plants-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 id="dashboard-plants-heading">Plants</h2>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-stat-grid">
|
||||||
|
<DashboardStat label="Total" value={plants.length} />
|
||||||
|
<DashboardStat label="Flagged" value={activeFlagCount} />
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button className="primary-action" type="button" onClick={onOpenPlants}>
|
||||||
|
Open Plant Management
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="work-panel" aria-labelledby="recent-care-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<h2 id="calendar-heading">Care Calendar</h2>
|
<h2 id="recent-care-heading">Recent Care</h2>
|
||||||
<span className="schedule-count">{selectedTasks.length} selected</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="week-strip" role="tablist" aria-label="Care tasks by day">
|
<div className="detail-list">
|
||||||
{weekDays.map((day) => {
|
{!isLoading && recentLogs.length === 0 ? (
|
||||||
const dayTasks = getTasksForDate(careTasks, day.dateKey);
|
<p className="empty-state">No care has been logged yet.</p>
|
||||||
const hasDueTasks = dayTasks.some((task) => task.status === 'due');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="week-day"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={day.dateKey === selectedDate}
|
|
||||||
key={day.dateKey}
|
|
||||||
onClick={() => setSelectedDate(day.dateKey)}
|
|
||||||
>
|
|
||||||
<span>{day.label}</span>
|
|
||||||
<strong>{day.dayNumber}</strong>
|
|
||||||
<small>{dayTasks.length}</small>
|
|
||||||
{hasDueTasks ? <span className="week-day-alert" aria-hidden="true" /> : null}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="task-list">
|
|
||||||
<p className="task-list-label">{selectedDay?.heading ?? 'Selected day'}</p>
|
|
||||||
{!isLoading && selectedTasks.length === 0 ? (
|
|
||||||
<p className="empty-state">No care scheduled for this day.</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTasks.map((task) => (
|
{recentLogs.map((log) => (
|
||||||
<article className="task-row" key={`calendar-${task.id}`}>
|
<article className="detail-row" key={log.id}>
|
||||||
<span className={`status-dot ${task.status}`} />
|
|
||||||
<div>
|
<div>
|
||||||
<h3>{task.action}</h3>
|
<h4>{log.action}</h4>
|
||||||
<p>{task.plantName} - {task.due}</p>
|
<p>{log.plantName} - {formatDate(log.performedOn)}</p>
|
||||||
|
{log.notes ? <p>{log.notes}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="small-action"
|
|
||||||
type="button"
|
|
||||||
disabled={task.status !== 'due'}
|
|
||||||
onClick={() => onCompleteTask(task)}
|
|
||||||
>
|
|
||||||
Log
|
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="work-panel" aria-labelledby="care-heading">
|
|
||||||
<div className="section-heading">
|
|
||||||
<h2 id="care-heading">Due Care</h2>
|
|
||||||
<button className="text-button" type="button">View all</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="task-list">
|
|
||||||
{!isLoading && careTasks.length === 0 ? (
|
|
||||||
<p className="empty-state">No care tasks yet.</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dueTaskGroups.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<p className="task-list-label">Bulk actions</p>
|
|
||||||
{dueTaskGroups.map((group) => (
|
|
||||||
<article className="task-row task-row-group" key={group.careActivityId}>
|
|
||||||
<span className="status-dot due" />
|
|
||||||
<div>
|
|
||||||
<h3>{group.action}</h3>
|
|
||||||
<p>
|
|
||||||
{group.tasks.length} due - {formatPlantNames(group.tasks)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="small-action"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
|
||||||
>
|
|
||||||
Log all due
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : 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)}
|
|
||||||
>
|
|
||||||
Log group
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{careTasks.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<p className="task-list-label">Individual tasks</p>
|
|
||||||
{careTasks.map((task) => (
|
|
||||||
<article className="task-row" key={task.id}>
|
|
||||||
<span className={`status-dot ${task.status}`} />
|
|
||||||
<div>
|
|
||||||
<h3>{task.action}</h3>
|
|
||||||
<p>{task.plantName} - {task.due}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="small-action"
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCompleteTask(task)}
|
|
||||||
>
|
|
||||||
Log
|
|
||||||
</button>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="work-panel" aria-labelledby="plants-heading">
|
|
||||||
<div className="section-heading">
|
|
||||||
<h2 id="plants-heading">My Plants</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="plant-grid">
|
|
||||||
{!isLoading && plants.length === 0 ? (
|
|
||||||
<p className="empty-state">No plants yet.</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{plants.map((plant) => (
|
|
||||||
<PlantCard plant={plant} key={plant.id} onOpen={onOpenPlant} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDueTasks(tasks: CareTask[]) {
|
function DashboardStat({ label, value }: { label: string; value: number }) {
|
||||||
const groups = new Map<number, { careActivityId: number; action: string; tasks: CareTask[] }>();
|
return (
|
||||||
for (const task of tasks) {
|
<div className="dashboard-stat">
|
||||||
if (task.status !== 'due') {
|
<strong>{value}</strong>
|
||||||
continue;
|
<span>{label}</span>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const group = groups.get(task.careActivityId);
|
function getRecentLogs(plants: Plant[]) {
|
||||||
if (group) {
|
return plants
|
||||||
group.tasks.push(task);
|
.flatMap((plant) => plant.actionLogs.map((log) => ({ ...log, plantName: log.plantName || plant.nickname })))
|
||||||
continue;
|
.sort(compareLogs)
|
||||||
}
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
groups.set(task.careActivityId, {
|
function compareLogs(left: ActionLog, right: ActionLog) {
|
||||||
careActivityId: task.careActivityId,
|
const dateComparison = right.performedOn.localeCompare(left.performedOn);
|
||||||
action: task.action,
|
if (dateComparison !== 0) {
|
||||||
tasks: [task],
|
return dateComparison;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action));
|
return right.id - left.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupDueTaskGroups(tasks: CareTask[], groups: PlantGroup[]) {
|
function formatDate(value: string) {
|
||||||
const dueTasks = tasks.filter((task) => task.status === 'due');
|
const [year, month, day] = value.split('-');
|
||||||
|
if (!year || !month || !day) {
|
||||||
return groups.flatMap((group) => {
|
return value;
|
||||||
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) {
|
|
||||||
return names.join(', ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${names.slice(0, 3).join(', ')} + ${names.length - 3} more`;
|
return `${month}/${day}/${year}`;
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekDays() {
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
return Array.from({ length: 7 }, (_, index) => {
|
|
||||||
const date = new Date(today);
|
|
||||||
date.setDate(today.getDate() + index);
|
|
||||||
const dateKey = getDateKey(date);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dateKey,
|
|
||||||
dayNumber: date.getDate().toString(),
|
|
||||||
heading: index === 0
|
|
||||||
? 'Today'
|
|
||||||
: date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' }),
|
|
||||||
label: index === 0
|
|
||||||
? 'Today'
|
|
||||||
: date.toLocaleDateString(undefined, { weekday: 'short' }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTasksForDate(tasks: CareTask[], dateKey: string) {
|
|
||||||
const todayKey = getDateKey(new Date());
|
|
||||||
|
|
||||||
return tasks
|
|
||||||
.filter((task) => {
|
|
||||||
const taskDateKey = getTaskDateKey(task);
|
|
||||||
if (!taskDateKey) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateKey === todayKey) {
|
|
||||||
return taskDateKey <= todayKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
return taskDateKey === dateKey;
|
|
||||||
})
|
|
||||||
.sort(compareCareTasks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareCareTasks(left: CareTask, right: CareTask) {
|
|
||||||
const dueComparison = (getTaskDateKey(left) ?? '').localeCompare(getTaskDateKey(right) ?? '');
|
|
||||||
if (dueComparison !== 0) {
|
|
||||||
return dueComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
const plantComparison = left.plantName.localeCompare(right.plantName);
|
|
||||||
if (plantComparison !== 0) {
|
|
||||||
return plantComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.action.localeCompare(right.action);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTaskDateKey(task: CareTask) {
|
|
||||||
if (task.dueDate) {
|
|
||||||
return task.dueDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
if (task.due === 'Today' || task.due === 'Yesterday' || task.due.endsWith(' days ago')) {
|
|
||||||
return getDateKey(today);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.due === 'Tomorrow') {
|
|
||||||
return getOffsetDateKey(today, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativeMatch = /^In (\d+) days$/.exec(task.due);
|
|
||||||
if (relativeMatch) {
|
|
||||||
return getOffsetDateKey(today, Number(relativeMatch[1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOffsetDateKey(date: Date, offsetDays: number) {
|
|
||||||
const nextDate = new Date(date);
|
|
||||||
nextDate.setDate(date.getDate() + offsetDays);
|
|
||||||
|
|
||||||
return getDateKey(nextDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDateKey(date: Date) {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type PlantManagementViewProps = {
|
|||||||
onDeletePlant: (plant: Plant) => void;
|
onDeletePlant: (plant: Plant) => void;
|
||||||
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
|
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
|
||||||
onNewPlant: () => void;
|
onNewPlant: () => void;
|
||||||
onPlantFieldChange: (field: keyof PlantFormState, value: string) => void;
|
onPlantFieldChange: (field: keyof PlantFormState, value: PlantFormState[keyof PlantFormState]) => void;
|
||||||
onRemoveFlag: (flag: PlantFlag) => void;
|
onRemoveFlag: (flag: PlantFlag) => void;
|
||||||
onResolveFlag: (flag: PlantFlag) => void;
|
onResolveFlag: (flag: PlantFlag) => void;
|
||||||
onSavePlant: () => void;
|
onSavePlant: () => void;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { CareActivity, Plant, PlantGroup } from '../domain';
|
import type { CareActivity, Plant, PlantCareScheduleRule, PlantGroup } from '../domain';
|
||||||
import type { BulkScheduleFormState } from '../form-state';
|
import type { BulkScheduleFormState } from '../form-state';
|
||||||
import { SummaryStrip } from './Ui';
|
import { SummaryStrip } from './Ui';
|
||||||
|
|
||||||
type SchedulesViewProps = {
|
type SchedulesViewProps = {
|
||||||
activities: CareActivity[];
|
activities: CareActivity[];
|
||||||
|
editingScheduleId: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
form: BulkScheduleFormState;
|
form: BulkScheduleFormState;
|
||||||
groups: PlantGroup[];
|
groups: PlantGroup[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
plants: Plant[];
|
plants: Plant[];
|
||||||
|
schedules: PlantCareScheduleRule[];
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete: (schedule: PlantCareScheduleRule) => void;
|
||||||
|
onEdit: (schedule: PlantCareScheduleRule) => void;
|
||||||
onFieldChange: (field: keyof BulkScheduleFormState, value: string | string[]) => void;
|
onFieldChange: (field: keyof BulkScheduleFormState, value: string | string[]) => void;
|
||||||
onRemove: () => void;
|
onNew: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,14 +42,19 @@ const weekdayOptions = [
|
|||||||
|
|
||||||
export function SchedulesView({
|
export function SchedulesView({
|
||||||
activities,
|
activities,
|
||||||
|
editingScheduleId,
|
||||||
error,
|
error,
|
||||||
form,
|
form,
|
||||||
groups,
|
groups,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
plants,
|
plants,
|
||||||
|
schedules,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onRemove,
|
onNew,
|
||||||
onSave,
|
onSave,
|
||||||
}: SchedulesViewProps) {
|
}: SchedulesViewProps) {
|
||||||
const [plantQuery, setPlantQuery] = useState('');
|
const [plantQuery, setPlantQuery] = useState('');
|
||||||
@@ -67,12 +77,15 @@ export function SchedulesView({
|
|||||||
<>
|
<>
|
||||||
<SummaryStrip ariaLabel="Schedules summary">
|
<SummaryStrip ariaLabel="Schedules summary">
|
||||||
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
||||||
|
<button className="primary-action" type="button" disabled={isSaving} onClick={onNew}>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
</SummaryStrip>
|
</SummaryStrip>
|
||||||
|
|
||||||
<section className="work-panel" aria-labelledby="bulk-schedule-heading">
|
<section className="work-panel" aria-labelledby="bulk-schedule-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="bulk-schedule-heading">Apply activity interval</h2>
|
<h2 id="bulk-schedule-heading">{editingScheduleId === null ? 'New Schedule' : 'Edit Schedule'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="schedule-count">
|
<div className="schedule-count">
|
||||||
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
|
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
|
||||||
@@ -104,27 +117,6 @@ export function SchedulesView({
|
|||||||
onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
|
onChange={(event) => onFieldChange('scheduledFor', event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
Target group
|
|
||||||
<select
|
|
||||||
disabled={isSaving}
|
|
||||||
value={selectedGroupId}
|
|
||||||
onChange={(event) => {
|
|
||||||
const groupId = event.target.value;
|
|
||||||
const group = groups.find((item) => String(item.id) === groupId);
|
|
||||||
setSelectedGroupId(groupId);
|
|
||||||
setPlantQuery('');
|
|
||||||
onFieldChange('plantIds', group ? group.plants.map((plant) => String(plant.id)) : []);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Individual plants</option>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name} ({group.plants.length})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="schedule-options">
|
<fieldset className="schedule-options">
|
||||||
@@ -195,6 +187,17 @@ export function SchedulesView({
|
|||||||
{form.recurrenceMode !== 'none' ? (
|
{form.recurrenceMode !== 'none' ? (
|
||||||
<fieldset className="schedule-end-options">
|
<fieldset className="schedule-end-options">
|
||||||
<legend>Ends</legend>
|
<legend>Ends</legend>
|
||||||
|
<label className="check-option">
|
||||||
|
<input
|
||||||
|
checked={form.endsMode === 'never'}
|
||||||
|
disabled={isSaving}
|
||||||
|
type="radio"
|
||||||
|
name="endsMode"
|
||||||
|
value="never"
|
||||||
|
onChange={(event) => onFieldChange('endsMode', event.target.value)}
|
||||||
|
/>
|
||||||
|
Never
|
||||||
|
</label>
|
||||||
<label className="check-option">
|
<label className="check-option">
|
||||||
<input
|
<input
|
||||||
checked={form.endsMode === 'on'}
|
checked={form.endsMode === 'on'}
|
||||||
@@ -278,16 +281,39 @@ export function SchedulesView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="compact-search">
|
<div className="plant-form">
|
||||||
<span className="sr-only">Search target plants</span>
|
<label>
|
||||||
<input
|
Target group
|
||||||
disabled={isSaving}
|
<select
|
||||||
type="search"
|
disabled={isSaving}
|
||||||
value={plantQuery}
|
value={selectedGroupId}
|
||||||
placeholder="Search plants"
|
onChange={(event) => {
|
||||||
onChange={(event) => setPlantQuery(event.target.value)}
|
const groupId = event.target.value;
|
||||||
/>
|
const group = groups.find((item) => String(item.id) === groupId);
|
||||||
</label>
|
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>
|
||||||
|
<label>
|
||||||
|
Search plants
|
||||||
|
<input
|
||||||
|
disabled={isSaving}
|
||||||
|
type="search"
|
||||||
|
value={plantQuery}
|
||||||
|
placeholder="Search plants"
|
||||||
|
onChange={(event) => setPlantQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="plant-list compact-plant-list">
|
<div className="plant-list compact-plant-list">
|
||||||
{!isLoading && plants.length === 0 ? (
|
{!isLoading && plants.length === 0 ? (
|
||||||
@@ -332,22 +358,64 @@ export function SchedulesView({
|
|||||||
setSelectedGroupId('');
|
setSelectedGroupId('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`}
|
{isSaving ? 'Saving' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="text-button danger"
|
className="text-button"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId}
|
disabled={isSaving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRemove();
|
onCancel();
|
||||||
setSelectedGroupId('');
|
setSelectedGroupId('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove from {form.plantIds.length} plants
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="work-panel" aria-labelledby="schedules-list-heading">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2 id="schedules-list-heading">Schedules</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-list">
|
||||||
|
{!isLoading && schedules.length === 0 ? (
|
||||||
|
<p className="empty-state">No schedules yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<article className="detail-row schedule-detail-row" key={schedule.id}>
|
||||||
|
<div>
|
||||||
|
<h4>{schedule.action}</h4>
|
||||||
|
<p>{formatRecurrence(schedule)}</p>
|
||||||
|
<p>{formatSchedulePlants(schedule)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
className="text-button"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => onEdit(schedule)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-button danger"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => onDelete(schedule)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="work-panel" aria-labelledby="current-schedules-heading">
|
<section className="work-panel" aria-labelledby="current-schedules-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -398,6 +466,19 @@ function formatPlantScheduleSummary(plant: Plant, selectedActivity?: CareActivit
|
|||||||
return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.nextCare}`;
|
return `${selectedActivity.name}: ${formatRecurrence(schedule)} - ${schedule.nextCare}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSchedulePlants(schedule: PlantCareScheduleRule) {
|
||||||
|
if (schedule.plants.length === 0) {
|
||||||
|
return 'No plants assigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = schedule.plants.map((plant) => plant.nickname);
|
||||||
|
if (names.length <= 4) {
|
||||||
|
return names.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${names.slice(0, 4).join(', ')} + ${names.length - 4} more`;
|
||||||
|
}
|
||||||
|
|
||||||
function filterPlants(plants: Plant[], query: string) {
|
function filterPlants(plants: Plant[], query: string) {
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
|
import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
|
||||||
import type { TaxonFormState } from '../form-state';
|
|
||||||
import { formatTaxon } from '../form-state';
|
import { formatTaxon } from '../form-state';
|
||||||
import { CatalogFilterSection, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
import { CatalogFilterSection, EntityList, SummaryStrip } from './Ui';
|
||||||
|
|
||||||
type TaxaViewProps = {
|
type TaxaViewProps = {
|
||||||
activeTaxonName?: string;
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
form: TaxonFormState;
|
|
||||||
hasSearched: boolean;
|
hasSearched: boolean;
|
||||||
isEditorOpen: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isSearching: boolean;
|
isSearching: boolean;
|
||||||
@@ -17,16 +13,10 @@ type TaxaViewProps = {
|
|||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedTaxon?: PlantTaxon;
|
selectedTaxon?: PlantTaxon;
|
||||||
taxa: PlantTaxon[];
|
taxa: PlantTaxon[];
|
||||||
onCancel: () => void;
|
|
||||||
onCloseDetail: () => void;
|
onCloseDetail: () => void;
|
||||||
onDelete: (taxon: PlantTaxon) => void;
|
onDelete: (taxon: PlantTaxon) => void;
|
||||||
onEdit: (taxon: PlantTaxon) => void;
|
|
||||||
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
|
|
||||||
onImportResult: (result: PlantInfoSearchResult) => void;
|
onImportResult: (result: PlantInfoSearchResult) => void;
|
||||||
onNew: () => void;
|
|
||||||
onOpenDetail: (taxon: PlantTaxon) => void;
|
onOpenDetail: (taxon: PlantTaxon) => void;
|
||||||
onPrefillResult: (result: PlantInfoSearchResult) => void;
|
|
||||||
onSave: () => void;
|
|
||||||
onSearch: (query?: string) => void;
|
onSearch: (query?: string) => void;
|
||||||
onSearchQueryChange: (value: string) => void;
|
onSearchQueryChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
@@ -40,11 +30,8 @@ function getPlantInfoSubtitle(result: PlantInfoSearchResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TaxaView({
|
export function TaxaView({
|
||||||
activeTaxonName,
|
|
||||||
error,
|
error,
|
||||||
form,
|
|
||||||
hasSearched,
|
hasSearched,
|
||||||
isEditorOpen,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isSaving,
|
isSaving,
|
||||||
isSearching,
|
isSearching,
|
||||||
@@ -52,16 +39,10 @@ export function TaxaView({
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
selectedTaxon,
|
selectedTaxon,
|
||||||
taxa,
|
taxa,
|
||||||
onCancel,
|
|
||||||
onCloseDetail,
|
onCloseDetail,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
|
||||||
onFieldChange,
|
|
||||||
onImportResult,
|
onImportResult,
|
||||||
onNew,
|
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
onPrefillResult,
|
|
||||||
onSave,
|
|
||||||
onSearch,
|
onSearch,
|
||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
}: TaxaViewProps) {
|
}: TaxaViewProps) {
|
||||||
@@ -83,9 +64,8 @@ export function TaxaView({
|
|||||||
<>
|
<>
|
||||||
<SummaryStrip
|
<SummaryStrip
|
||||||
ariaLabel="Taxa summary"
|
ariaLabel="Taxa summary"
|
||||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
|
||||||
>
|
>
|
||||||
<p>{error ?? 'Create and maintain the plant identities used by your collection.'}</p>
|
<p>{error ?? 'Import GBIF-backed plant identities for use in your collection.'}</p>
|
||||||
</SummaryStrip>
|
</SummaryStrip>
|
||||||
|
|
||||||
<CatalogFilterSection
|
<CatalogFilterSection
|
||||||
@@ -101,14 +81,14 @@ export function TaxaView({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
items={filteredTaxa}
|
items={filteredTaxa}
|
||||||
renderActions={(taxon) => (
|
renderActions={(taxon) => (
|
||||||
<RecordActions
|
<>
|
||||||
deleteLabel={`Delete ${taxon.name}`}
|
<button className="icon-button compact" type="button" aria-label={`View ${taxon.name}`} onClick={() => onOpenDetail(taxon)}>
|
||||||
editLabel={`Edit ${taxon.name}`}
|
View
|
||||||
viewLabel={`View ${taxon.name}`}
|
</button>
|
||||||
onDelete={() => onDelete(taxon)}
|
<button className="icon-button compact danger" type="button" aria-label={`Delete ${taxon.name}`} onClick={() => onDelete(taxon)}>
|
||||||
onEdit={() => onEdit(taxon)}
|
Delete
|
||||||
onView={() => onOpenDetail(taxon)}
|
</button>
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>}
|
renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>}
|
||||||
/>
|
/>
|
||||||
@@ -116,7 +96,7 @@ export function TaxaView({
|
|||||||
<section className="work-panel" aria-labelledby="taxa-search-heading">
|
<section className="work-panel" aria-labelledby="taxa-search-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="taxa-search-heading">Search plant info</h2>
|
<h2 id="taxa-search-heading">Search GBIF</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -187,9 +167,6 @@ export function TaxaView({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<button className="small-action" type="button" onClick={() => onPrefillResult(result)}>
|
|
||||||
Prefill
|
|
||||||
</button>
|
|
||||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
|
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
@@ -202,16 +179,13 @@ export function TaxaView({
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{selectedTaxon && !isEditorOpen ? (
|
{selectedTaxon ? (
|
||||||
<section className="work-panel" aria-labelledby="taxon-detail-heading">
|
<section className="work-panel" aria-labelledby="taxon-detail-heading">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2>
|
<h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedTaxon.name}`} onClick={() => onEdit(selectedTaxon)}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
|
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
@@ -254,87 +228,6 @@ export function TaxaView({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isEditorOpen ? (
|
|
||||||
<section className="work-panel" aria-labelledby="taxon-editor-heading">
|
|
||||||
<div className="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2 id="taxon-editor-heading">{activeTaxonName ?? 'New taxon'}</h2>
|
|
||||||
</div>
|
|
||||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancel}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="plant-form">
|
|
||||||
<label>
|
|
||||||
Common name
|
|
||||||
<input
|
|
||||||
value={form.name}
|
|
||||||
onChange={(event) => onFieldChange('name', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Genus
|
|
||||||
<input
|
|
||||||
value={form.genus}
|
|
||||||
onChange={(event) => onFieldChange('genus', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Species
|
|
||||||
<input
|
|
||||||
value={form.species}
|
|
||||||
onChange={(event) => onFieldChange('species', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Cultivar
|
|
||||||
<input
|
|
||||||
value={form.cultivar}
|
|
||||||
onChange={(event) => onFieldChange('cultivar', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Variety
|
|
||||||
<input
|
|
||||||
value={form.variety}
|
|
||||||
onChange={(event) => onFieldChange('variety', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Authority
|
|
||||||
<input
|
|
||||||
value={form.authority}
|
|
||||||
onChange={(event) => onFieldChange('authority', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Family
|
|
||||||
<input
|
|
||||||
value={form.family}
|
|
||||||
onChange={(event) => onFieldChange('family', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
GBIF ID
|
|
||||||
<input
|
|
||||||
value={form.externalId}
|
|
||||||
onChange={(event) => onFieldChange('externalId', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
|
||||||
{isSaving ? 'Saving' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button className="text-button" type="button" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,31 @@ export type PlantCareSchedule = {
|
|||||||
status: CareStatus;
|
status: CareStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlantCareScheduleAssignment = {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlantCareScheduleRule = {
|
||||||
|
id: number;
|
||||||
|
careActivityId: number;
|
||||||
|
careActionId: number;
|
||||||
|
action: string;
|
||||||
|
everyDays: number;
|
||||||
|
scheduledFor: string | null;
|
||||||
|
recurrenceMode: ScheduleRecurrenceMode;
|
||||||
|
repeatEvery: number;
|
||||||
|
repeatUnit: ScheduleRepeatUnit;
|
||||||
|
repeatOnDays: string | null;
|
||||||
|
endsMode: ScheduleEndsMode;
|
||||||
|
endsOn: string | null;
|
||||||
|
endsAfterOccurrences: number | null;
|
||||||
|
plants: PlantCareScheduleAssignment[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
export type ScheduleRecurrenceMode = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||||
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
|
export type ScheduleRepeatUnit = 'day' | 'week' | 'month' | 'year';
|
||||||
export type ScheduleEndsMode = 'on' | 'after';
|
export type ScheduleEndsMode = 'never' | 'on' | 'after';
|
||||||
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
|
export type RecipeMeasurementMode = 'quantity' | 'total_percent' | 'bakers_percent';
|
||||||
|
|
||||||
export type PlantTaxon = {
|
export type PlantTaxon = {
|
||||||
@@ -183,6 +205,24 @@ export type CareActivityActionResource = {
|
|||||||
quantity: number | null;
|
quantity: number | null;
|
||||||
unit: string | null;
|
unit: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
producedByRecipe: CareActivityRecipe | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareActivityRecipe = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
measurementMode: RecipeMeasurementMode;
|
||||||
|
components: CareActivityRecipeComponent[];
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CareActivityRecipeComponent = {
|
||||||
|
actionResourceId: number;
|
||||||
|
name: string;
|
||||||
|
quantity: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
sortOrder: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlantFlagDefinition = {
|
export type PlantFlagDefinition = {
|
||||||
@@ -255,19 +295,6 @@ export type BulkPlantCareSchedulePayload = {
|
|||||||
endsAfterOccurrences: number | null;
|
endsAfterOccurrences: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlantTaxonPayload = {
|
|
||||||
name: string;
|
|
||||||
genus: string;
|
|
||||||
species: string;
|
|
||||||
cultivar: string | null;
|
|
||||||
variety: string | null;
|
|
||||||
authority: string | null;
|
|
||||||
family: string | null;
|
|
||||||
commonName: string | null;
|
|
||||||
externalSource: string | null;
|
|
||||||
externalId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlantLocationPayload = {
|
export type PlantLocationPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -341,6 +368,13 @@ export type BulkCompleteCareTasksPayload = {
|
|||||||
resources: CareLogResourcePayload[];
|
resources: CareLogResourcePayload[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DismissCareTasksPayload = {
|
||||||
|
careActivityId: number;
|
||||||
|
plantIds: number[];
|
||||||
|
notes: string | null;
|
||||||
|
dismissedOn: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type CareLogResourcePayload = {
|
export type CareLogResourcePayload = {
|
||||||
actionResourceId: number;
|
actionResourceId: number;
|
||||||
quantity: number | null;
|
quantity: number | null;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
Recipe,
|
Recipe,
|
||||||
RecipePayload,
|
RecipePayload,
|
||||||
Plant,
|
Plant,
|
||||||
PlantInfoSearchResult,
|
PlantCareScheduleRule,
|
||||||
AssignPlantFlagPayload,
|
AssignPlantFlagPayload,
|
||||||
PlantPayload,
|
PlantPayload,
|
||||||
PlantFlagDefinition,
|
PlantFlagDefinition,
|
||||||
@@ -23,7 +23,6 @@ import type {
|
|||||||
ScheduleRecurrenceMode,
|
ScheduleRecurrenceMode,
|
||||||
ScheduleRepeatUnit,
|
ScheduleRepeatUnit,
|
||||||
PlantTaxon,
|
PlantTaxon,
|
||||||
PlantTaxonPayload,
|
|
||||||
} from './domain';
|
} from './domain';
|
||||||
|
|
||||||
export const emptyPlantForm = {
|
export const emptyPlantForm = {
|
||||||
@@ -33,19 +32,6 @@ export const emptyPlantForm = {
|
|||||||
locationId: '',
|
locationId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const emptyTaxonForm = {
|
|
||||||
name: '',
|
|
||||||
genus: '',
|
|
||||||
species: '',
|
|
||||||
cultivar: '',
|
|
||||||
variety: '',
|
|
||||||
authority: '',
|
|
||||||
family: '',
|
|
||||||
commonName: '',
|
|
||||||
externalSource: '',
|
|
||||||
externalId: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emptyLocationForm = {
|
export const emptyLocationForm = {
|
||||||
name: '',
|
name: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
@@ -119,14 +105,13 @@ export const emptyBulkScheduleForm = {
|
|||||||
repeatEvery: '1',
|
repeatEvery: '1',
|
||||||
repeatUnit: 'week',
|
repeatUnit: 'week',
|
||||||
repeatOnDays: [] as string[],
|
repeatOnDays: [] as string[],
|
||||||
endsMode: 'after',
|
endsMode: 'never',
|
||||||
endsOn: '',
|
endsOn: '',
|
||||||
endsAfterOccurrences: '12',
|
endsAfterOccurrences: '12',
|
||||||
plantIds: [] as string[],
|
plantIds: [] as string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlantFormState = typeof emptyPlantForm;
|
export type PlantFormState = typeof emptyPlantForm;
|
||||||
export type TaxonFormState = typeof emptyTaxonForm;
|
|
||||||
export type LocationFormState = typeof emptyLocationForm;
|
export type LocationFormState = typeof emptyLocationForm;
|
||||||
export type PlantGroupFormState = typeof emptyPlantGroupForm;
|
export type PlantGroupFormState = typeof emptyPlantGroupForm;
|
||||||
export type ActionFormState = typeof emptyActionForm;
|
export type ActionFormState = typeof emptyActionForm;
|
||||||
@@ -136,7 +121,7 @@ export type RecipeFormState = typeof emptyRecipeForm;
|
|||||||
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
||||||
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
||||||
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
||||||
export type View = 'home' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
export type View = 'home' | 'plant-management' | 'care' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||||
|
|
||||||
export function toPlantForm(plant: Plant): PlantFormState {
|
export function toPlantForm(plant: Plant): PlantFormState {
|
||||||
return {
|
return {
|
||||||
@@ -187,51 +172,6 @@ export function toPlantGroupPayload(form: PlantGroupFormState): PlantGroupPayloa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toTaxonForm(taxon: PlantTaxon): TaxonFormState {
|
|
||||||
return {
|
|
||||||
name: taxon.name,
|
|
||||||
genus: taxon.genus,
|
|
||||||
species: taxon.species,
|
|
||||||
cultivar: taxon.cultivar ?? '',
|
|
||||||
variety: taxon.variety ?? '',
|
|
||||||
authority: taxon.authority ?? '',
|
|
||||||
family: taxon.family ?? '',
|
|
||||||
commonName: taxon.commonName ?? '',
|
|
||||||
externalSource: taxon.externalSource ?? '',
|
|
||||||
externalId: taxon.externalId ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toTaxonFormFromPlantInfo(result: PlantInfoSearchResult): TaxonFormState {
|
|
||||||
const canonicalName = result.canonicalName ?? result.scientificName;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...emptyTaxonForm,
|
|
||||||
name: result.commonName ?? canonicalName,
|
|
||||||
genus: result.genus ?? '',
|
|
||||||
species: result.species ?? canonicalName.split(' ')[1] ?? '',
|
|
||||||
family: result.family ?? '',
|
|
||||||
commonName: result.commonName ?? '',
|
|
||||||
externalSource: result.source,
|
|
||||||
externalId: result.externalId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toTaxonPayload(form: TaxonFormState): PlantTaxonPayload {
|
|
||||||
return {
|
|
||||||
name: form.name.trim(),
|
|
||||||
genus: form.genus.trim(),
|
|
||||||
species: form.species.trim(),
|
|
||||||
cultivar: form.cultivar.trim() || null,
|
|
||||||
variety: form.variety.trim() || null,
|
|
||||||
authority: form.authority.trim() || null,
|
|
||||||
family: form.family.trim() || null,
|
|
||||||
commonName: form.commonName.trim() || null,
|
|
||||||
externalSource: form.externalSource.trim() || null,
|
|
||||||
externalId: form.externalId.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toActionForm(action: CareAction): ActionFormState {
|
export function toActionForm(action: CareAction): ActionFormState {
|
||||||
return {
|
return {
|
||||||
name: action.name,
|
name: action.name,
|
||||||
@@ -364,6 +304,22 @@ export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCar
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toBulkScheduleForm(schedule: PlantCareScheduleRule): BulkScheduleFormState {
|
||||||
|
return {
|
||||||
|
careActivityId: String(schedule.careActivityId),
|
||||||
|
everyDays: String(schedule.everyDays),
|
||||||
|
scheduledFor: schedule.scheduledFor ?? '',
|
||||||
|
recurrenceMode: schedule.recurrenceMode,
|
||||||
|
repeatEvery: String(schedule.repeatEvery),
|
||||||
|
repeatUnit: schedule.repeatUnit,
|
||||||
|
repeatOnDays: schedule.repeatOnDays ? schedule.repeatOnDays.split(',').filter(Boolean) : [],
|
||||||
|
endsMode: schedule.endsMode,
|
||||||
|
endsOn: schedule.endsOn ?? '',
|
||||||
|
endsAfterOccurrences: schedule.endsAfterOccurrences === null ? '12' : String(schedule.endsAfterOccurrences),
|
||||||
|
plantIds: schedule.plants.map((plant) => String(plant.id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function formatTaxon(taxon: PlantTaxon) {
|
export function formatTaxon(taxon: PlantTaxon) {
|
||||||
const botanical = `${taxon.genus} ${taxon.species}`.trim();
|
const botanical = `${taxon.genus} ${taxon.species}`.trim();
|
||||||
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
|
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
|
||||||
|
|||||||
@@ -1013,6 +1013,47 @@ dd {
|
|||||||
justify-self: start;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-resource-detail-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-resource-detail > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-procedure {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f6ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-procedure h5 {
|
||||||
|
margin: 0;
|
||||||
|
color: #24342c;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-procedure ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-procedure p,
|
||||||
|
.recipe-procedure li {
|
||||||
|
margin: 0;
|
||||||
|
color: #59675d;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.activity-action-cell {
|
.activity-action-cell {
|
||||||
padding: 8px 0 8px 12px;
|
padding: 8px 0 8px 12px;
|
||||||
}
|
}
|
||||||
@@ -1168,6 +1209,95 @@ dd {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.care-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-mode-switch button {
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-mode-switch button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-mode-switch button[aria-selected="true"] {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.9fr) minmax(320px, 1.1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-subpanel {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-resource-edit {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(90px, 120px) minmax(90px, 140px);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.care-notes textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-panel-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-panel {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat strong {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stat span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.plant-detail-meta,
|
.plant-detail-meta,
|
||||||
.plant-detail-grid {
|
.plant-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1499,6 +1629,14 @@ dd {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.care-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-panel-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-nav {
|
.catalog-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -6,42 +6,42 @@ import {
|
|||||||
createActionResource,
|
createActionResource,
|
||||||
createCareAction,
|
createCareAction,
|
||||||
createCareActivity,
|
createCareActivity,
|
||||||
|
createActionLogsBulk,
|
||||||
|
createPlantCareSchedule,
|
||||||
createPlant,
|
createPlant,
|
||||||
createPlantFlag,
|
createPlantFlag,
|
||||||
createPlantGroup,
|
createPlantGroup,
|
||||||
createPlantLocation,
|
createPlantLocation,
|
||||||
createPlantTaxon,
|
|
||||||
createRecipe,
|
createRecipe,
|
||||||
deleteActionResource,
|
deleteActionResource,
|
||||||
deleteCareAction,
|
deleteCareAction,
|
||||||
deleteCareActivity,
|
deleteCareActivity,
|
||||||
deletePlant,
|
deletePlant,
|
||||||
|
deletePlantCareSchedule,
|
||||||
deletePlantFlag,
|
deletePlantFlag,
|
||||||
deletePlantGroup,
|
deletePlantGroup,
|
||||||
deletePlantLocation,
|
deletePlantLocation,
|
||||||
deletePlantTaxon,
|
deletePlantTaxon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
|
dismissCareTasksBulk,
|
||||||
downloadSpreadsheetExport,
|
downloadSpreadsheetExport,
|
||||||
importPlantTaxon,
|
importPlantTaxon,
|
||||||
previewCatalogImport,
|
previewCatalogImport,
|
||||||
removePlantCareSchedulesBulk,
|
|
||||||
removePlantFlagAssignment,
|
removePlantFlagAssignment,
|
||||||
resolvePlantFlag,
|
resolvePlantFlag,
|
||||||
savePlantCareSchedulesBulk,
|
|
||||||
searchPlantInfo,
|
searchPlantInfo,
|
||||||
updateActionResource,
|
updateActionResource,
|
||||||
updateCareAction,
|
updateCareAction,
|
||||||
updateCareActivity,
|
updateCareActivity,
|
||||||
updatePlant,
|
updatePlant,
|
||||||
|
updatePlantCareSchedule,
|
||||||
updatePlantFlag,
|
updatePlantFlag,
|
||||||
updatePlantGroup,
|
updatePlantGroup,
|
||||||
updatePlantLocation,
|
updatePlantLocation,
|
||||||
updatePlantTaxon,
|
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type { CareTask, CatalogImportResult, Plant, PlantFlag, PlantInfoSearchResult } from './domain';
|
import type { BulkCompleteCareTasksPayload, CareTask, CatalogImportResult, DismissCareTasksPayload, Plant, PlantCareScheduleRule, PlantFlag, PlantInfoSearchResult, PlantTaxon } from './domain';
|
||||||
import {
|
import {
|
||||||
emptyBulkScheduleForm,
|
|
||||||
emptyPlantFlagForm,
|
emptyPlantFlagForm,
|
||||||
toActionPayload,
|
toActionPayload,
|
||||||
toActivityPayload,
|
toActivityPayload,
|
||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
toPlantPayload,
|
toPlantPayload,
|
||||||
toRecipePayload,
|
toRecipePayload,
|
||||||
toResourcePayload,
|
toResourcePayload,
|
||||||
toTaxonPayload,
|
|
||||||
} from './form-state';
|
} from './form-state';
|
||||||
import type { useAppEditors } from './use-app-editors';
|
import type { useAppEditors } from './use-app-editors';
|
||||||
import type { useDashboardData } from './use-dashboard-data';
|
import type { useDashboardData } from './use-dashboard-data';
|
||||||
@@ -222,29 +221,6 @@ export function useAppActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTaxon() {
|
|
||||||
if (!editors.taxonForm.name.trim() || !editors.taxonForm.genus.trim() || !editors.taxonForm.species.trim()) {
|
|
||||||
setError('Name, genus, and species are required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const payload = toTaxonPayload(editors.taxonForm);
|
|
||||||
if (editors.editingTaxonId === null) {
|
|
||||||
await createPlantTaxon(payload);
|
|
||||||
} else {
|
|
||||||
await updatePlantTaxon(editors.editingTaxonId, payload);
|
|
||||||
}
|
|
||||||
editors.cancelEditingTaxon();
|
|
||||||
await loadTaxaAndPlants();
|
|
||||||
} catch {
|
|
||||||
setError('Could not save the taxon.');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchTaxonInfo(
|
async function searchTaxonInfo(
|
||||||
queryOverride?: string,
|
queryOverride?: string,
|
||||||
options: { updateQuery?: boolean } = {},
|
options: { updateQuery?: boolean } = {},
|
||||||
@@ -301,7 +277,7 @@ export function useAppActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTaxon(taxon: Parameters<typeof editors.startEditingTaxon>[0]) {
|
async function removeTaxon(taxon: PlantTaxon) {
|
||||||
const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`);
|
const confirmed = window.confirm(`Delete ${taxon.name}? Taxa used by plants cannot be deleted.`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
@@ -310,9 +286,6 @@ export function useAppActions({
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await deletePlantTaxon(taxon.id);
|
await deletePlantTaxon(taxon.id);
|
||||||
if (editors.editingTaxonId === taxon.id) {
|
|
||||||
editors.cancelEditingTaxon();
|
|
||||||
}
|
|
||||||
await loadTaxaAndPlants();
|
await loadTaxaAndPlants();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not delete the taxon. It may still be used by a plant.');
|
setError('Could not delete the taxon. It may still be used by a plant.');
|
||||||
@@ -618,29 +591,36 @@ export function useAppActions({
|
|||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await savePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
|
const payload = toBulkSchedulePayload(editors.bulkScheduleForm);
|
||||||
editors.setBulkScheduleForm(emptyBulkScheduleForm);
|
if (editors.editingScheduleId === null) {
|
||||||
|
await createPlantCareSchedule(payload);
|
||||||
|
} else {
|
||||||
|
await updatePlantCareSchedule(editors.editingScheduleId, payload);
|
||||||
|
}
|
||||||
|
editors.cancelEditingSchedule();
|
||||||
await loadPlantsAndCareTasks();
|
await loadPlantsAndCareTasks();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not apply the care schedule.');
|
setError('Could not save the care schedule.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeBulkSchedule() {
|
async function removeSchedule(schedule: PlantCareScheduleRule) {
|
||||||
if (!editors.bulkScheduleForm.careActivityId || editors.bulkScheduleForm.plantIds.length === 0) {
|
const confirmed = window.confirm(`Delete ${schedule.action} schedule?`);
|
||||||
setError('Select an activity and at least one plant.');
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await removePlantCareSchedulesBulk(toBulkSchedulePayload(editors.bulkScheduleForm));
|
await deletePlantCareSchedule(schedule.id);
|
||||||
editors.setBulkScheduleForm(emptyBulkScheduleForm);
|
if (editors.editingScheduleId === schedule.id) {
|
||||||
|
editors.cancelEditingSchedule();
|
||||||
|
}
|
||||||
await loadPlantsAndCareTasks();
|
await loadPlantsAndCareTasks();
|
||||||
} catch {
|
} catch {
|
||||||
setError('Could not remove the care schedule.');
|
setError('Could not delete the care schedule.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -769,6 +749,44 @@ export function useAppActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function logCare(payload: BulkCompleteCareTasksPayload, requireDueSchedule: boolean) {
|
||||||
|
if (payload.plantIds.length === 0 || !payload.careActivityId) {
|
||||||
|
setError('Select an activity and at least one plant.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (requireDueSchedule) {
|
||||||
|
await completeCareTasksBulk(payload);
|
||||||
|
} else {
|
||||||
|
await createActionLogsBulk(payload);
|
||||||
|
}
|
||||||
|
await loadPlantsAndCareTasks();
|
||||||
|
} catch {
|
||||||
|
setError('Could not log care.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissCare(payload: DismissCareTasksPayload) {
|
||||||
|
if (payload.plantIds.length === 0 || !payload.careActivityId) {
|
||||||
|
setError('Select an activity and at least one plant.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await dismissCareTasksBulk(payload);
|
||||||
|
await loadPlantsAndCareTasks();
|
||||||
|
} catch {
|
||||||
|
setError('Could not dismiss care.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
assignFlagToSelectedPlant,
|
assignFlagToSelectedPlant,
|
||||||
applyCatalogImportFile,
|
applyCatalogImportFile,
|
||||||
@@ -776,18 +794,20 @@ export function useAppActions({
|
|||||||
completeBulkTasks,
|
completeBulkTasks,
|
||||||
completeTask,
|
completeTask,
|
||||||
exportSpreadsheet,
|
exportSpreadsheet,
|
||||||
|
dismissCare,
|
||||||
isImportingCatalog,
|
isImportingCatalog,
|
||||||
hasSearchedPlantInfo,
|
hasSearchedPlantInfo,
|
||||||
isExporting,
|
isExporting,
|
||||||
isSearchingPlantInfo,
|
isSearchingPlantInfo,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
logCare,
|
||||||
importTaxonFromPlantInfo,
|
importTaxonFromPlantInfo,
|
||||||
plantInfoQuery,
|
plantInfoQuery,
|
||||||
plantInfoResults,
|
plantInfoResults,
|
||||||
removeAction,
|
removeAction,
|
||||||
removeActivity,
|
removeActivity,
|
||||||
removeAssignedPlantFlag,
|
removeAssignedPlantFlag,
|
||||||
removeBulkSchedule,
|
removeSchedule,
|
||||||
removeFlagDefinition,
|
removeFlagDefinition,
|
||||||
removeLocation,
|
removeLocation,
|
||||||
removePlant,
|
removePlant,
|
||||||
@@ -805,7 +825,6 @@ export function useAppActions({
|
|||||||
savePlantGroup,
|
savePlantGroup,
|
||||||
saveRecipe,
|
saveRecipe,
|
||||||
saveResource,
|
saveResource,
|
||||||
saveTaxon,
|
|
||||||
searchTaxonInfo,
|
searchTaxonInfo,
|
||||||
previewCatalogImportFile,
|
previewCatalogImportFile,
|
||||||
setPlantInfoQuery,
|
setPlantInfoQuery,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
CareActivity,
|
CareActivity,
|
||||||
CareAction,
|
CareAction,
|
||||||
Plant,
|
Plant,
|
||||||
PlantInfoSearchResult,
|
PlantCareScheduleRule,
|
||||||
PlantFlagDefinition,
|
PlantFlagDefinition,
|
||||||
PlantGroup,
|
PlantGroup,
|
||||||
PlantLocation,
|
PlantLocation,
|
||||||
@@ -22,17 +22,15 @@ import {
|
|||||||
emptyPlantFlagForm,
|
emptyPlantFlagForm,
|
||||||
emptyRecipeForm,
|
emptyRecipeForm,
|
||||||
emptyResourceForm,
|
emptyResourceForm,
|
||||||
emptyTaxonForm,
|
|
||||||
toActionForm,
|
toActionForm,
|
||||||
toActivityForm,
|
toActivityForm,
|
||||||
|
toBulkScheduleForm,
|
||||||
toFlagDefinitionForm,
|
toFlagDefinitionForm,
|
||||||
toLocationForm,
|
toLocationForm,
|
||||||
toPlantForm,
|
toPlantForm,
|
||||||
toPlantGroupForm,
|
toPlantGroupForm,
|
||||||
toRecipeForm,
|
toRecipeForm,
|
||||||
toResourceForm,
|
toResourceForm,
|
||||||
toTaxonForm,
|
|
||||||
toTaxonFormFromPlantInfo,
|
|
||||||
type ActionFormState,
|
type ActionFormState,
|
||||||
type ActivityFormState,
|
type ActivityFormState,
|
||||||
type BulkScheduleFormState,
|
type BulkScheduleFormState,
|
||||||
@@ -43,7 +41,6 @@ import {
|
|||||||
type PlantFlagFormState,
|
type PlantFlagFormState,
|
||||||
type RecipeFormState,
|
type RecipeFormState,
|
||||||
type ResourceFormState,
|
type ResourceFormState,
|
||||||
type TaxonFormState,
|
|
||||||
type View,
|
type View,
|
||||||
} from './form-state';
|
} from './form-state';
|
||||||
|
|
||||||
@@ -61,7 +58,6 @@ type AppEditorData = {
|
|||||||
|
|
||||||
export function useAppEditors(data: AppEditorData, setView: (view: View) => void) {
|
export function useAppEditors(data: AppEditorData, setView: (view: View) => void) {
|
||||||
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
|
const [editingPlantId, setEditingPlantId] = useState<number | null>(null);
|
||||||
const [editingTaxonId, setEditingTaxonId] = useState<number | null>(null);
|
|
||||||
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
|
const [editingLocationId, setEditingLocationId] = useState<number | null>(null);
|
||||||
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
|
const [editingPlantGroupId, setEditingPlantGroupId] = useState<number | null>(null);
|
||||||
const [editingActionId, setEditingActionId] = useState<number | null>(null);
|
const [editingActionId, setEditingActionId] = useState<number | null>(null);
|
||||||
@@ -69,8 +65,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
|
const [editingRecipeId, setEditingRecipeId] = useState<number | null>(null);
|
||||||
const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
|
const [editingActivityId, setEditingActivityId] = useState<number | null>(null);
|
||||||
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
|
const [editingFlagDefinitionId, setEditingFlagDefinitionId] = useState<number | null>(null);
|
||||||
|
const [editingScheduleId, setEditingScheduleId] = useState<number | null>(null);
|
||||||
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
|
const [isPlantEditorOpen, setIsPlantEditorOpen] = useState(false);
|
||||||
const [isTaxonEditorOpen, setIsTaxonEditorOpen] = useState(false);
|
|
||||||
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
|
const [isLocationEditorOpen, setIsLocationEditorOpen] = useState(false);
|
||||||
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
|
const [isPlantGroupEditorOpen, setIsPlantGroupEditorOpen] = useState(false);
|
||||||
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
|
const [isActionEditorOpen, setIsActionEditorOpen] = useState(false);
|
||||||
@@ -87,7 +83,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
|
const [selectedActivityId, setSelectedActivityId] = useState<number | null>(null);
|
||||||
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
|
const [selectedFlagDefinitionId, setSelectedFlagDefinitionId] = useState<number | null>(null);
|
||||||
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
|
const [form, setForm] = useState<PlantFormState>(emptyPlantForm);
|
||||||
const [taxonForm, setTaxonForm] = useState<TaxonFormState>(emptyTaxonForm);
|
|
||||||
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
|
const [locationForm, setLocationForm] = useState<LocationFormState>(emptyLocationForm);
|
||||||
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
|
const [plantGroupForm, setPlantGroupForm] = useState<PlantGroupFormState>(emptyPlantGroupForm);
|
||||||
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
|
const [actionForm, setActionForm] = useState<ActionFormState>(emptyActionForm);
|
||||||
@@ -100,7 +95,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
|
|
||||||
const activePlant = data.plants.find((plant) => plant.id === editingPlantId);
|
const activePlant = data.plants.find((plant) => plant.id === editingPlantId);
|
||||||
const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId);
|
const selectedPlant = data.plants.find((plant) => plant.id === selectedPlantId);
|
||||||
const activeTaxon = data.plantTaxa.find((taxon) => taxon.id === editingTaxonId);
|
|
||||||
const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
|
const selectedTaxon = data.plantTaxa.find((taxon) => taxon.id === selectedTaxonId);
|
||||||
const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId);
|
const activeLocation = data.plantLocations.find((location) => location.id === editingLocationId);
|
||||||
const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId);
|
const selectedLocation = data.plantLocations.find((location) => location.id === selectedLocationId);
|
||||||
@@ -120,10 +114,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
setForm((current) => ({ ...current, [field]: value }));
|
setForm((current) => ({ ...current, [field]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTaxonForm(field: keyof TaxonFormState, value: string) {
|
|
||||||
setTaxonForm((current) => ({ ...current, [field]: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLocationForm(field: keyof LocationFormState, value: string) {
|
function updateLocationForm(field: keyof LocationFormState, value: string) {
|
||||||
setLocationForm((current) => ({ ...current, [field]: value }));
|
setLocationForm((current) => ({ ...current, [field]: value }));
|
||||||
}
|
}
|
||||||
@@ -163,6 +153,23 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
setBulkScheduleForm((current) => ({ ...current, [field]: value }));
|
setBulkScheduleForm((current) => ({ ...current, [field]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startNewSchedule() {
|
||||||
|
setEditingScheduleId(null);
|
||||||
|
setBulkScheduleForm(emptyBulkScheduleForm);
|
||||||
|
setView('schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingSchedule(schedule: PlantCareScheduleRule) {
|
||||||
|
setEditingScheduleId(schedule.id);
|
||||||
|
setBulkScheduleForm(toBulkScheduleForm(schedule));
|
||||||
|
setView('schedules');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingSchedule() {
|
||||||
|
setEditingScheduleId(null);
|
||||||
|
setBulkScheduleForm(emptyBulkScheduleForm);
|
||||||
|
}
|
||||||
|
|
||||||
function startAddingPlant() {
|
function startAddingPlant() {
|
||||||
setEditingPlantId(null);
|
setEditingPlantId(null);
|
||||||
setSelectedPlantId(null);
|
setSelectedPlantId(null);
|
||||||
@@ -195,36 +202,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
setPlantFlagForm(emptyPlantFlagForm);
|
setPlantFlagForm(emptyPlantFlagForm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAddingTaxon() {
|
|
||||||
setEditingTaxonId(null);
|
|
||||||
setSelectedTaxonId(null);
|
|
||||||
setTaxonForm(emptyTaxonForm);
|
|
||||||
setIsTaxonEditorOpen(true);
|
|
||||||
setView('taxa');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditingTaxon(taxon: PlantTaxon) {
|
|
||||||
setSelectedTaxonId(null);
|
|
||||||
setEditingTaxonId(taxon.id);
|
|
||||||
setTaxonForm(toTaxonForm(taxon));
|
|
||||||
setIsTaxonEditorOpen(true);
|
|
||||||
setView('taxa');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAddingTaxonFromPlantInfo(result: PlantInfoSearchResult) {
|
|
||||||
setEditingTaxonId(null);
|
|
||||||
setSelectedTaxonId(null);
|
|
||||||
setTaxonForm(toTaxonFormFromPlantInfo(result));
|
|
||||||
setIsTaxonEditorOpen(true);
|
|
||||||
setView('taxa');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditingTaxon() {
|
|
||||||
setEditingTaxonId(null);
|
|
||||||
setTaxonForm(emptyTaxonForm);
|
|
||||||
setIsTaxonEditorOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAddingLocation() {
|
function startAddingLocation() {
|
||||||
setEditingLocationId(null);
|
setEditingLocationId(null);
|
||||||
setSelectedLocationId(null);
|
setSelectedLocationId(null);
|
||||||
@@ -406,9 +383,9 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
activePlantGroup,
|
activePlantGroup,
|
||||||
activeRecipe,
|
activeRecipe,
|
||||||
activeResource,
|
activeResource,
|
||||||
activeTaxon,
|
|
||||||
activityForm,
|
activityForm,
|
||||||
bulkScheduleForm,
|
bulkScheduleForm,
|
||||||
|
cancelEditingSchedule,
|
||||||
cancelEditing,
|
cancelEditing,
|
||||||
cancelEditingAction,
|
cancelEditingAction,
|
||||||
cancelEditingActivity,
|
cancelEditingActivity,
|
||||||
@@ -417,7 +394,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
cancelEditingPlantGroup,
|
cancelEditingPlantGroup,
|
||||||
cancelEditingRecipe,
|
cancelEditingRecipe,
|
||||||
cancelEditingResource,
|
cancelEditingResource,
|
||||||
cancelEditingTaxon,
|
|
||||||
editingActionId,
|
editingActionId,
|
||||||
editingActivityId,
|
editingActivityId,
|
||||||
editingFlagDefinitionId,
|
editingFlagDefinitionId,
|
||||||
@@ -426,7 +402,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
editingPlantId,
|
editingPlantId,
|
||||||
editingRecipeId,
|
editingRecipeId,
|
||||||
editingResourceId,
|
editingResourceId,
|
||||||
editingTaxonId,
|
editingScheduleId,
|
||||||
flagDefinitionForm,
|
flagDefinitionForm,
|
||||||
form,
|
form,
|
||||||
isActionEditorOpen,
|
isActionEditorOpen,
|
||||||
@@ -437,7 +413,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
isPlantGroupEditorOpen,
|
isPlantGroupEditorOpen,
|
||||||
isRecipeEditorOpen,
|
isRecipeEditorOpen,
|
||||||
isResourceEditorOpen,
|
isResourceEditorOpen,
|
||||||
isTaxonEditorOpen,
|
|
||||||
locationForm,
|
locationForm,
|
||||||
openPlantDetail,
|
openPlantDetail,
|
||||||
plantFlagForm,
|
plantFlagForm,
|
||||||
@@ -464,7 +439,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
setEditingPlantId,
|
setEditingPlantId,
|
||||||
setEditingRecipeId,
|
setEditingRecipeId,
|
||||||
setEditingResourceId,
|
setEditingResourceId,
|
||||||
setEditingTaxonId,
|
setEditingScheduleId,
|
||||||
setPlantFlagForm,
|
setPlantFlagForm,
|
||||||
setSelectedActionId,
|
setSelectedActionId,
|
||||||
setSelectedActivityId,
|
setSelectedActivityId,
|
||||||
@@ -482,8 +457,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
startAddingPlantGroup,
|
startAddingPlantGroup,
|
||||||
startAddingRecipe,
|
startAddingRecipe,
|
||||||
startAddingResource,
|
startAddingResource,
|
||||||
startAddingTaxon,
|
|
||||||
startAddingTaxonFromPlantInfo,
|
|
||||||
startEditingAction,
|
startEditingAction,
|
||||||
startEditingActivity,
|
startEditingActivity,
|
||||||
startEditingFlagDefinition,
|
startEditingFlagDefinition,
|
||||||
@@ -492,8 +465,8 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
startEditingPlantGroup,
|
startEditingPlantGroup,
|
||||||
startEditingRecipe,
|
startEditingRecipe,
|
||||||
startEditingResource,
|
startEditingResource,
|
||||||
startEditingTaxon,
|
startEditingSchedule,
|
||||||
taxonForm,
|
startNewSchedule,
|
||||||
updateActionForm,
|
updateActionForm,
|
||||||
updateActivityForm,
|
updateActivityForm,
|
||||||
updateBulkScheduleForm,
|
updateBulkScheduleForm,
|
||||||
@@ -504,6 +477,5 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
|||||||
updatePlantGroupForm,
|
updatePlantGroupForm,
|
||||||
updateRecipeForm,
|
updateRecipeForm,
|
||||||
updateResourceForm,
|
updateResourceForm,
|
||||||
updateTaxonForm,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getCareActions,
|
getCareActions,
|
||||||
getCareTasks,
|
getCareTasks,
|
||||||
getPlants,
|
getPlants,
|
||||||
|
getPlantCareSchedules,
|
||||||
getPlantFlags,
|
getPlantFlags,
|
||||||
getPlantGroups,
|
getPlantGroups,
|
||||||
getPlantLocations,
|
getPlantLocations,
|
||||||
@@ -17,6 +18,7 @@ import type {
|
|||||||
CareAction,
|
CareAction,
|
||||||
CareTask,
|
CareTask,
|
||||||
Plant,
|
Plant,
|
||||||
|
PlantCareScheduleRule,
|
||||||
PlantFlagDefinition,
|
PlantFlagDefinition,
|
||||||
PlantGroup,
|
PlantGroup,
|
||||||
PlantLocation,
|
PlantLocation,
|
||||||
@@ -33,6 +35,7 @@ export function useDashboardData() {
|
|||||||
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
|
const [actionResources, setActionResources] = useState<ActionResource[]>([]);
|
||||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||||
const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
|
const [careActivities, setCareActivities] = useState<CareActivity[]>([]);
|
||||||
|
const [plantCareSchedules, setPlantCareSchedules] = useState<PlantCareScheduleRule[]>([]);
|
||||||
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
|
const [plantFlagDefinitions, setPlantFlagDefinitions] = useState<PlantFlagDefinition[]>([]);
|
||||||
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
const [careTasks, setCareTasks] = useState<CareTask[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -43,6 +46,7 @@ export function useDashboardData() {
|
|||||||
const [
|
const [
|
||||||
plantsResponse,
|
plantsResponse,
|
||||||
tasksResponse,
|
tasksResponse,
|
||||||
|
schedulesResponse,
|
||||||
taxaResponse,
|
taxaResponse,
|
||||||
locationsResponse,
|
locationsResponse,
|
||||||
groupsResponse,
|
groupsResponse,
|
||||||
@@ -54,6 +58,7 @@ export function useDashboardData() {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPlants(),
|
getPlants(),
|
||||||
getCareTasks(),
|
getCareTasks(),
|
||||||
|
getPlantCareSchedules(),
|
||||||
getPlantTaxa(),
|
getPlantTaxa(),
|
||||||
getPlantLocations(),
|
getPlantLocations(),
|
||||||
getPlantGroups(),
|
getPlantGroups(),
|
||||||
@@ -67,6 +72,7 @@ export function useDashboardData() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setPlants(plantsResponse);
|
setPlants(plantsResponse);
|
||||||
setCareTasks(tasksResponse);
|
setCareTasks(tasksResponse);
|
||||||
|
setPlantCareSchedules(schedulesResponse);
|
||||||
setPlantTaxa(taxaResponse);
|
setPlantTaxa(taxaResponse);
|
||||||
setPlantLocations(locationsResponse);
|
setPlantLocations(locationsResponse);
|
||||||
setPlantGroups(groupsResponse);
|
setPlantGroups(groupsResponse);
|
||||||
@@ -108,14 +114,16 @@ export function useDashboardData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadPlantsAndCareTasks() {
|
async function loadPlantsAndCareTasks() {
|
||||||
const [plantsResponse, tasksResponse] = await Promise.all([
|
const [plantsResponse, tasksResponse, schedulesResponse] = await Promise.all([
|
||||||
getPlants(),
|
getPlants(),
|
||||||
getCareTasks(),
|
getCareTasks(),
|
||||||
|
getPlantCareSchedules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setPlants(plantsResponse);
|
setPlants(plantsResponse);
|
||||||
setCareTasks(tasksResponse);
|
setCareTasks(tasksResponse);
|
||||||
|
setPlantCareSchedules(schedulesResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTaxaAndPlants() {
|
async function loadTaxaAndPlants() {
|
||||||
@@ -144,6 +152,7 @@ export function useDashboardData() {
|
|||||||
const [
|
const [
|
||||||
plantsResponse,
|
plantsResponse,
|
||||||
tasksResponse,
|
tasksResponse,
|
||||||
|
schedulesResponse,
|
||||||
actionsResponse,
|
actionsResponse,
|
||||||
resourcesResponse,
|
resourcesResponse,
|
||||||
recipesResponse,
|
recipesResponse,
|
||||||
@@ -151,6 +160,7 @@ export function useDashboardData() {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getPlants(),
|
getPlants(),
|
||||||
getCareTasks(),
|
getCareTasks(),
|
||||||
|
getPlantCareSchedules(),
|
||||||
getCareActions(),
|
getCareActions(),
|
||||||
getActionResources(),
|
getActionResources(),
|
||||||
getRecipes(),
|
getRecipes(),
|
||||||
@@ -160,6 +170,7 @@ export function useDashboardData() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setPlants(plantsResponse);
|
setPlants(plantsResponse);
|
||||||
setCareTasks(tasksResponse);
|
setCareTasks(tasksResponse);
|
||||||
|
setPlantCareSchedules(schedulesResponse);
|
||||||
setCareActions(actionsResponse);
|
setCareActions(actionsResponse);
|
||||||
setActionResources(resourcesResponse);
|
setActionResources(resourcesResponse);
|
||||||
setRecipes(recipesResponse);
|
setRecipes(recipesResponse);
|
||||||
@@ -206,6 +217,7 @@ export function useDashboardData() {
|
|||||||
loadRecipesAndResources,
|
loadRecipesAndResources,
|
||||||
loadTaxaAndPlants,
|
loadTaxaAndPlants,
|
||||||
plantFlagDefinitions,
|
plantFlagDefinitions,
|
||||||
|
plantCareSchedules,
|
||||||
plantGroups,
|
plantGroups,
|
||||||
plantLocations,
|
plantLocations,
|
||||||
plantTaxa,
|
plantTaxa,
|
||||||
|
|||||||
+124
-31
@@ -18,6 +18,7 @@ namespace plant_manager
|
|||||||
|
|
||||||
public record SavePlantCareScheduleRequest(
|
public record SavePlantCareScheduleRequest(
|
||||||
int CareActivityId,
|
int CareActivityId,
|
||||||
|
IReadOnlyList<int>? PlantIds,
|
||||||
int? EveryDays,
|
int? EveryDays,
|
||||||
DateOnly? ScheduledFor,
|
DateOnly? ScheduledFor,
|
||||||
string? RecurrenceMode,
|
string? RecurrenceMode,
|
||||||
@@ -41,18 +42,6 @@ namespace plant_manager
|
|||||||
DateOnly? EndsOn,
|
DateOnly? EndsOn,
|
||||||
int? EndsAfterOccurrences);
|
int? EndsAfterOccurrences);
|
||||||
|
|
||||||
public record SavePlantTaxonRequest(
|
|
||||||
string Name,
|
|
||||||
string Genus,
|
|
||||||
string Species,
|
|
||||||
string? Cultivar,
|
|
||||||
string? Variety,
|
|
||||||
string? Authority,
|
|
||||||
string? Family,
|
|
||||||
string? CommonName,
|
|
||||||
string? ExternalSource,
|
|
||||||
string? ExternalId);
|
|
||||||
|
|
||||||
public record ImportPlantTaxonRequest(
|
public record ImportPlantTaxonRequest(
|
||||||
string Source,
|
string Source,
|
||||||
string ExternalId,
|
string ExternalId,
|
||||||
@@ -110,12 +99,50 @@ namespace plant_manager
|
|||||||
string? Unit,
|
string? Unit,
|
||||||
string? Notes);
|
string? Notes);
|
||||||
|
|
||||||
|
public record CareActivityRecipeComponentDto(
|
||||||
|
int ActionResourceId,
|
||||||
|
string Name,
|
||||||
|
decimal? Quantity,
|
||||||
|
string? Unit,
|
||||||
|
string? Notes,
|
||||||
|
int SortOrder)
|
||||||
|
{
|
||||||
|
public static CareActivityRecipeComponentDto FromRecipeComponent(RecipeComponent component) =>
|
||||||
|
new(
|
||||||
|
component.ActionResourceId,
|
||||||
|
component.ActionResource.Name,
|
||||||
|
component.Quantity,
|
||||||
|
component.Unit,
|
||||||
|
component.Notes,
|
||||||
|
component.SortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CareActivityRecipeDto(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string MeasurementMode,
|
||||||
|
IReadOnlyList<CareActivityRecipeComponentDto> Components,
|
||||||
|
string? Notes)
|
||||||
|
{
|
||||||
|
public static CareActivityRecipeDto FromRecipe(Recipe recipe) =>
|
||||||
|
new(
|
||||||
|
recipe.Id,
|
||||||
|
recipe.Name,
|
||||||
|
recipe.MeasurementMode,
|
||||||
|
recipe.Components
|
||||||
|
.OrderBy(component => component.SortOrder)
|
||||||
|
.Select(CareActivityRecipeComponentDto.FromRecipeComponent)
|
||||||
|
.ToList(),
|
||||||
|
recipe.Notes);
|
||||||
|
}
|
||||||
|
|
||||||
public record CareActivityActionResourceDto(
|
public record CareActivityActionResourceDto(
|
||||||
int ActionResourceId,
|
int ActionResourceId,
|
||||||
string Name,
|
string Name,
|
||||||
decimal? Quantity,
|
decimal? Quantity,
|
||||||
string? Unit,
|
string? Unit,
|
||||||
string? Notes)
|
string? Notes,
|
||||||
|
CareActivityRecipeDto? ProducedByRecipe)
|
||||||
{
|
{
|
||||||
public static CareActivityActionResourceDto FromCareActivityActionResource(
|
public static CareActivityActionResourceDto FromCareActivityActionResource(
|
||||||
CareActivityActionResource resource) =>
|
CareActivityActionResource resource) =>
|
||||||
@@ -124,7 +151,10 @@ namespace plant_manager
|
|||||||
resource.ActionResource.Name,
|
resource.ActionResource.Name,
|
||||||
resource.Quantity,
|
resource.Quantity,
|
||||||
resource.Unit,
|
resource.Unit,
|
||||||
resource.Notes);
|
resource.Notes,
|
||||||
|
resource.ActionResource.ProducedByRecipe is null
|
||||||
|
? null
|
||||||
|
: CareActivityRecipeDto.FromRecipe(resource.ActionResource.ProducedByRecipe));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CareActivityActionDto(
|
public record CareActivityActionDto(
|
||||||
@@ -186,6 +216,12 @@ namespace plant_manager
|
|||||||
string? Notes,
|
string? Notes,
|
||||||
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
IReadOnlyList<ActionLogResourceRequest>? Resources);
|
||||||
|
|
||||||
|
public record DismissCareTasksRequest(
|
||||||
|
int CareActivityId,
|
||||||
|
IReadOnlyList<int> PlantIds,
|
||||||
|
DateOnly? DismissedOn,
|
||||||
|
string? Notes);
|
||||||
|
|
||||||
public record CatalogImportIssue(
|
public record CatalogImportIssue(
|
||||||
string Sheet,
|
string Sheet,
|
||||||
int Row,
|
int Row,
|
||||||
@@ -401,21 +437,23 @@ namespace plant_manager
|
|||||||
public static PlantDto FromPlant(Plant plant)
|
public static PlantDto FromPlant(Plant plant)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
var schedules = plant.CareSchedules
|
var schedules = plant.CareScheduleAssignments
|
||||||
.OrderBy(schedule => schedule.CareActivity.Name)
|
.OrderBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
|
||||||
.Select(schedule => PlantCareScheduleDto.FromSchedule(
|
.Select(assignment => PlantCareScheduleDto.FromAssignment(
|
||||||
schedule,
|
assignment,
|
||||||
GetLatestPerformedOn(plant, schedule.CareActivityId),
|
GetLatestCareEventOn(plant, assignment.PlantCareSchedule.CareActivityId),
|
||||||
today))
|
today))
|
||||||
.ToList();
|
.ToList();
|
||||||
var nextCare = schedules
|
var nextCare = schedules
|
||||||
.Select(schedule =>
|
.Select(schedule =>
|
||||||
{
|
{
|
||||||
var source = plant.CareSchedules.First(item => item.Id == schedule.Id);
|
var source = plant.CareScheduleAssignments
|
||||||
|
.Select(assignment => assignment.PlantCareSchedule)
|
||||||
|
.First(item => item.Id == schedule.Id);
|
||||||
return PlantCareFormatter.GetNextCareDate(
|
return PlantCareFormatter.GetNextCareDate(
|
||||||
source,
|
source,
|
||||||
schedule.LastPerformedOn,
|
schedule.LastPerformedOn,
|
||||||
plant.ActionLogs.Count(log => log.CareActivityId == source.CareActivityId));
|
GetCompletedOccurrences(plant, source.CareActivityId));
|
||||||
})
|
})
|
||||||
.Where(date => date is not null)
|
.Where(date => date is not null)
|
||||||
.OrderBy(date => date)
|
.OrderBy(date => date)
|
||||||
@@ -449,11 +487,18 @@ namespace plant_manager
|
|||||||
schedules);
|
schedules);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) =>
|
private static DateOnly? GetLatestCareEventOn(Plant plant, int careActivityId) =>
|
||||||
plant.ActionLogs
|
plant.ActionLogs
|
||||||
.Where(log => log.CareActivityId == careActivityId)
|
.Where(log => log.CareActivityId == careActivityId)
|
||||||
.Select(log => (DateOnly?)log.PerformedOn)
|
.Select(log => (DateOnly?)log.PerformedOn)
|
||||||
|
.Concat(plant.CareDismissals
|
||||||
|
.Where(dismissal => dismissal.CareActivityId == careActivityId)
|
||||||
|
.Select(dismissal => (DateOnly?)dismissal.DismissedOn))
|
||||||
.Max();
|
.Max();
|
||||||
|
|
||||||
|
private static int GetCompletedOccurrences(Plant plant, int careActivityId) =>
|
||||||
|
plant.ActionLogs.Count(log => log.CareActivityId == careActivityId)
|
||||||
|
+ plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == careActivityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record PlantCareScheduleDto(
|
public record PlantCareScheduleDto(
|
||||||
@@ -475,12 +520,13 @@ namespace plant_manager
|
|||||||
string NextCare,
|
string NextCare,
|
||||||
string Status)
|
string Status)
|
||||||
{
|
{
|
||||||
public static PlantCareScheduleDto FromSchedule(
|
public static PlantCareScheduleDto FromAssignment(
|
||||||
PlantCareSchedule schedule,
|
PlantCareScheduleAssignment assignment,
|
||||||
DateOnly? lastPerformedOn,
|
DateOnly? lastPerformedOn,
|
||||||
DateOnly today)
|
DateOnly today)
|
||||||
{
|
{
|
||||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(schedule));
|
var schedule = assignment.PlantCareSchedule;
|
||||||
|
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, GetCompletedOccurrences(assignment));
|
||||||
|
|
||||||
return new PlantCareScheduleDto(
|
return new PlantCareScheduleDto(
|
||||||
schedule.Id,
|
schedule.Id,
|
||||||
@@ -502,8 +548,54 @@ namespace plant_manager
|
|||||||
PlantCareFormatter.GetStatus(nextCare, today));
|
PlantCareFormatter.GetStatus(nextCare, today));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int GetCompletedOccurrences(PlantCareSchedule schedule) =>
|
private static int GetCompletedOccurrences(PlantCareScheduleAssignment assignment) =>
|
||||||
schedule.Plant.ActionLogs.Count(log => log.CareActivityId == schedule.CareActivityId);
|
assignment.Plant.ActionLogs.Count(log => log.CareActivityId == assignment.PlantCareSchedule.CareActivityId)
|
||||||
|
+ assignment.Plant.CareDismissals.Count(dismissal => dismissal.CareActivityId == assignment.PlantCareSchedule.CareActivityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PlantCareScheduleAssignmentDto(
|
||||||
|
int Id,
|
||||||
|
string Nickname)
|
||||||
|
{
|
||||||
|
public static PlantCareScheduleAssignmentDto FromAssignment(PlantCareScheduleAssignment assignment) =>
|
||||||
|
new(assignment.PlantId, assignment.Plant.Nickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PlantCareScheduleRuleDto(
|
||||||
|
int Id,
|
||||||
|
int CareActivityId,
|
||||||
|
int CareActionId,
|
||||||
|
string Action,
|
||||||
|
int EveryDays,
|
||||||
|
DateOnly? ScheduledFor,
|
||||||
|
string RecurrenceMode,
|
||||||
|
int RepeatEvery,
|
||||||
|
string RepeatUnit,
|
||||||
|
string? RepeatOnDays,
|
||||||
|
string EndsMode,
|
||||||
|
DateOnly? EndsOn,
|
||||||
|
int? EndsAfterOccurrences,
|
||||||
|
IReadOnlyList<PlantCareScheduleAssignmentDto> Plants)
|
||||||
|
{
|
||||||
|
public static PlantCareScheduleRuleDto FromSchedule(PlantCareSchedule schedule) =>
|
||||||
|
new(
|
||||||
|
schedule.Id,
|
||||||
|
schedule.CareActivityId,
|
||||||
|
schedule.CareActionId,
|
||||||
|
schedule.CareActivity.Name,
|
||||||
|
schedule.EveryDays,
|
||||||
|
schedule.ScheduledFor,
|
||||||
|
schedule.RecurrenceMode,
|
||||||
|
schedule.RepeatEvery,
|
||||||
|
schedule.RepeatUnit,
|
||||||
|
schedule.RepeatOnDays,
|
||||||
|
schedule.EndsMode,
|
||||||
|
schedule.EndsOn,
|
||||||
|
schedule.EndsAfterOccurrences,
|
||||||
|
schedule.Assignments
|
||||||
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
|
.Select(PlantCareScheduleAssignmentDto.FromAssignment)
|
||||||
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class CareActivityExtensions
|
internal static class CareActivityExtensions
|
||||||
@@ -532,18 +624,19 @@ namespace plant_manager
|
|||||||
string Due,
|
string Due,
|
||||||
string Status)
|
string Status)
|
||||||
{
|
{
|
||||||
public static CareTaskDto FromSchedule(
|
public static CareTaskDto FromAssignment(
|
||||||
PlantCareSchedule schedule,
|
PlantCareScheduleAssignment assignment,
|
||||||
DateOnly? lastPerformedOn,
|
DateOnly? lastPerformedOn,
|
||||||
int completedOccurrences,
|
int completedOccurrences,
|
||||||
DateOnly today)
|
DateOnly today)
|
||||||
{
|
{
|
||||||
|
var schedule = assignment.PlantCareSchedule;
|
||||||
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
|
var nextCare = PlantCareFormatter.GetNextCareDate(schedule, lastPerformedOn, completedOccurrences);
|
||||||
|
|
||||||
return new CareTaskDto(
|
return new CareTaskDto(
|
||||||
schedule.Id,
|
schedule.Id,
|
||||||
schedule.PlantId,
|
assignment.PlantId,
|
||||||
schedule.Plant.Nickname,
|
assignment.Plant.Nickname,
|
||||||
schedule.CareActivityId,
|
schedule.CareActivityId,
|
||||||
schedule.CareActionId,
|
schedule.CareActionId,
|
||||||
schedule.CareActivity.Name,
|
schedule.CareActivity.Name,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ namespace plant_manager.Data
|
|||||||
public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; }
|
public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; }
|
||||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||||
public DbSet<ActionLogResource> ActionLogResources { get; set; }
|
public DbSet<ActionLogResource> ActionLogResources { get; set; }
|
||||||
|
public DbSet<CareDismissal> CareDismissals { get; set; }
|
||||||
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
|
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
|
||||||
|
public DbSet<PlantCareScheduleAssignment> PlantCareScheduleAssignments { get; set; }
|
||||||
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
|
public DbSet<PlantFlagDefinition> PlantFlagDefinitions { get; set; }
|
||||||
public DbSet<PlantFlag> PlantFlags { get; set; }
|
public DbSet<PlantFlag> PlantFlags { get; set; }
|
||||||
public DbSet<Recipe> Recipes { get; set; }
|
public DbSet<Recipe> Recipes { get; set; }
|
||||||
@@ -40,9 +42,9 @@ namespace plant_manager.Data
|
|||||||
entity.Property(e => e.Authority).HasMaxLength(120);
|
entity.Property(e => e.Authority).HasMaxLength(120);
|
||||||
entity.Property(e => e.Family).HasMaxLength(120);
|
entity.Property(e => e.Family).HasMaxLength(120);
|
||||||
entity.Property(e => e.CommonName).HasMaxLength(120);
|
entity.Property(e => e.CommonName).HasMaxLength(120);
|
||||||
entity.Property(e => e.ExternalSource).HasMaxLength(40);
|
entity.Property(e => e.ExternalSource).HasMaxLength(40).IsRequired();
|
||||||
entity.Property(e => e.ExternalId).HasMaxLength(80);
|
entity.Property(e => e.ExternalId).HasMaxLength(80).IsRequired();
|
||||||
entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique(false);
|
entity.HasIndex(e => new { e.ExternalSource, e.ExternalId }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Plant>(entity =>
|
modelBuilder.Entity<Plant>(entity =>
|
||||||
@@ -165,6 +167,23 @@ namespace plant_manager.Data
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<CareDismissal>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.Id)
|
||||||
|
.ValueGeneratedOnAdd();
|
||||||
|
entity.Property(e => e.Notes).HasMaxLength(1000);
|
||||||
|
entity.HasIndex(e => new { e.PlantId, e.CareActivityId, e.DismissedOn });
|
||||||
|
entity.HasOne(e => e.Plant)
|
||||||
|
.WithMany(e => e.CareDismissals)
|
||||||
|
.HasForeignKey(e => e.PlantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(e => e.CareActivity)
|
||||||
|
.WithMany(e => e.CareDismissals)
|
||||||
|
.HasForeignKey(e => e.CareActivityId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<PlantCareSchedule>(entity =>
|
modelBuilder.Entity<PlantCareSchedule>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
@@ -179,12 +198,6 @@ namespace plant_manager.Data
|
|||||||
entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired();
|
entity.Property(e => e.EndsMode).HasMaxLength(20).IsRequired();
|
||||||
entity.Property(e => e.EndsOn);
|
entity.Property(e => e.EndsOn);
|
||||||
entity.Property(e => e.EndsAfterOccurrences);
|
entity.Property(e => e.EndsAfterOccurrences);
|
||||||
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique(false);
|
|
||||||
entity.HasIndex(e => new { e.PlantId, e.CareActivityId }).IsUnique();
|
|
||||||
entity.HasOne(e => e.Plant)
|
|
||||||
.WithMany(e => e.CareSchedules)
|
|
||||||
.HasForeignKey(e => e.PlantId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
entity.HasOne(e => e.CareAction)
|
entity.HasOne(e => e.CareAction)
|
||||||
.WithMany(e => e.PlantCareSchedules)
|
.WithMany(e => e.PlantCareSchedules)
|
||||||
.HasForeignKey(e => e.CareActionId)
|
.HasForeignKey(e => e.CareActionId)
|
||||||
@@ -195,6 +208,20 @@ namespace plant_manager.Data
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<PlantCareScheduleAssignment>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => new { e.PlantCareScheduleId, e.PlantId });
|
||||||
|
entity.HasIndex(e => new { e.PlantId, e.PlantCareScheduleId }).IsUnique();
|
||||||
|
entity.HasOne(e => e.PlantCareSchedule)
|
||||||
|
.WithMany(e => e.Assignments)
|
||||||
|
.HasForeignKey(e => e.PlantCareScheduleId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(e => e.Plant)
|
||||||
|
.WithMany(e => e.CareScheduleAssignments)
|
||||||
|
.HasForeignKey(e => e.PlantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<PlantFlagDefinition>(entity =>
|
modelBuilder.Entity<PlantFlagDefinition>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
|
|||||||
+92
-14
@@ -11,7 +11,7 @@ using plant_manager.Data;
|
|||||||
namespace plant_manager.Data.Migrations
|
namespace plant_manager.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260612235128_InitialCreate")]
|
[Migration("20260614222157_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -201,6 +201,34 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.ToTable("CareActivityActionResources");
|
b.ToTable("CareActivityActionResources");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CareActivityId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("DismissedOn")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CareActivityId");
|
||||||
|
|
||||||
|
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
|
||||||
|
|
||||||
|
b.ToTable("CareDismissals");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -256,9 +284,6 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.Property<int>("EveryDays")
|
b.Property<int>("EveryDays")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("PlantId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("RecurrenceMode")
|
b.Property<string>("RecurrenceMode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
@@ -285,12 +310,23 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CareActivityId");
|
b.HasIndex("CareActivityId");
|
||||||
|
|
||||||
b.HasIndex("PlantId", "CareActionId");
|
b.ToTable("PlantCareSchedules");
|
||||||
|
});
|
||||||
|
|
||||||
b.HasIndex("PlantId", "CareActivityId")
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("PlantCareScheduleId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("PlantCareScheduleId", "PlantId");
|
||||||
|
|
||||||
|
b.HasIndex("PlantId", "PlantCareScheduleId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("PlantCareSchedules");
|
b.ToTable("PlantCareScheduleAssignments");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||||
@@ -428,10 +464,12 @@ namespace plant_manager.Data.Migrations
|
|||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ExternalId")
|
b.Property<string>("ExternalId")
|
||||||
|
.IsRequired()
|
||||||
.HasMaxLength(80)
|
.HasMaxLength(80)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ExternalSource")
|
b.Property<string>("ExternalSource")
|
||||||
|
.IsRequired()
|
||||||
.HasMaxLength(40)
|
.HasMaxLength(40)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -460,7 +498,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ExternalSource", "ExternalId");
|
b.HasIndex("ExternalSource", "ExternalId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("PlantTaxa");
|
b.ToTable("PlantTaxa");
|
||||||
});
|
});
|
||||||
@@ -616,6 +655,25 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.Navigation("CareActivityAction");
|
b.Navigation("CareActivityAction");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||||
|
.WithMany("CareDismissals")
|
||||||
|
.HasForeignKey("CareActivityId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||||
|
.WithMany("CareDismissals")
|
||||||
|
.HasForeignKey("PlantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CareActivity");
|
||||||
|
|
||||||
|
b.Navigation("Plant");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||||
@@ -647,17 +705,28 @@ namespace plant_manager.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CareAction");
|
||||||
|
|
||||||
|
b.Navigation("CareActivity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
|
||||||
|
.WithMany("Assignments")
|
||||||
|
.HasForeignKey("PlantCareScheduleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||||
.WithMany("CareSchedules")
|
.WithMany("CareScheduleAssignments")
|
||||||
.HasForeignKey("PlantId")
|
.HasForeignKey("PlantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("CareAction");
|
|
||||||
|
|
||||||
b.Navigation("CareActivity");
|
|
||||||
|
|
||||||
b.Navigation("Plant");
|
b.Navigation("Plant");
|
||||||
|
|
||||||
|
b.Navigation("PlantCareSchedule");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||||
@@ -758,6 +827,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Actions");
|
b.Navigation("Actions");
|
||||||
|
|
||||||
|
b.Navigation("CareDismissals");
|
||||||
|
|
||||||
b.Navigation("PlantCareSchedules");
|
b.Navigation("PlantCareSchedules");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -770,13 +841,20 @@ namespace plant_manager.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("ActionLogs");
|
b.Navigation("ActionLogs");
|
||||||
|
|
||||||
b.Navigation("CareSchedules");
|
b.Navigation("CareDismissals");
|
||||||
|
|
||||||
|
b.Navigation("CareScheduleAssignments");
|
||||||
|
|
||||||
b.Navigation("Flags");
|
b.Navigation("Flags");
|
||||||
|
|
||||||
b.Navigation("GroupMemberships");
|
b.Navigation("GroupMemberships");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assignments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("PlantFlags");
|
b.Navigation("PlantFlags");
|
||||||
+92
-35
@@ -109,8 +109,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||||
Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||||
CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||||
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||||
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true)
|
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
@@ -164,6 +164,41 @@ namespace plant_manager.Data.Migrations
|
|||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PlantCareSchedules",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||||
|
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||||
|
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
||||||
|
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
||||||
|
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PlantCareSchedules_CareActions_CareActionId",
|
||||||
|
column: x => x.CareActionId,
|
||||||
|
principalTable: "CareActions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
|
||||||
|
column: x => x.CareActivityId,
|
||||||
|
principalTable: "CareActivities",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Plants",
|
name: "Plants",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
@@ -285,41 +320,51 @@ namespace plant_manager.Data.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "PlantCareSchedules",
|
name: "CareDismissals",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
|
DismissedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||||
ScheduledFor = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||||
RecurrenceMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
|
||||||
RepeatEvery = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
RepeatUnit = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
|
||||||
RepeatOnDays = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
|
||||||
EndsMode = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
|
|
||||||
EndsOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
|
|
||||||
EndsAfterOccurrences = table.Column<int>(type: "INTEGER", nullable: true)
|
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_PlantCareSchedules", x => x.Id);
|
table.PrimaryKey("PK_CareDismissals", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_PlantCareSchedules_CareActions_CareActionId",
|
name: "FK_CareDismissals_CareActivities_CareActivityId",
|
||||||
column: x => x.CareActionId,
|
|
||||||
principalTable: "CareActions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Restrict);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_PlantCareSchedules_CareActivities_CareActivityId",
|
|
||||||
column: x => x.CareActivityId,
|
column: x => x.CareActivityId,
|
||||||
principalTable: "CareActivities",
|
principalTable: "CareActivities",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict);
|
onDelete: ReferentialAction.Restrict);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_PlantCareSchedules_Plants_PlantId",
|
name: "FK_CareDismissals_Plants_PlantId",
|
||||||
|
column: x => x.PlantId,
|
||||||
|
principalTable: "Plants",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PlantCareScheduleAssignments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PlantCareScheduleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PlantId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PlantCareScheduleAssignments", x => new { x.PlantCareScheduleId, x.PlantId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PlantCareScheduleAssignments_PlantCareSchedules_PlantCareScheduleId",
|
||||||
|
column: x => x.PlantCareScheduleId,
|
||||||
|
principalTable: "PlantCareSchedules",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PlantCareScheduleAssignments_Plants_PlantId",
|
||||||
column: x => x.PlantId,
|
column: x => x.PlantId,
|
||||||
principalTable: "Plants",
|
principalTable: "Plants",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
@@ -459,6 +504,22 @@ namespace plant_manager.Data.Migrations
|
|||||||
columns: new[] { "CareActivityId", "SortOrder" },
|
columns: new[] { "CareActivityId", "SortOrder" },
|
||||||
unique: true);
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CareDismissals_CareActivityId",
|
||||||
|
table: "CareDismissals",
|
||||||
|
column: "CareActivityId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CareDismissals_PlantId_CareActivityId_DismissedOn",
|
||||||
|
table: "CareDismissals",
|
||||||
|
columns: new[] { "PlantId", "CareActivityId", "DismissedOn" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PlantCareScheduleAssignments_PlantId_PlantCareScheduleId",
|
||||||
|
table: "PlantCareScheduleAssignments",
|
||||||
|
columns: new[] { "PlantId", "PlantCareScheduleId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_PlantCareSchedules_CareActionId",
|
name: "IX_PlantCareSchedules_CareActionId",
|
||||||
table: "PlantCareSchedules",
|
table: "PlantCareSchedules",
|
||||||
@@ -469,17 +530,6 @@ namespace plant_manager.Data.Migrations
|
|||||||
table: "PlantCareSchedules",
|
table: "PlantCareSchedules",
|
||||||
column: "CareActivityId");
|
column: "CareActivityId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_PlantCareSchedules_PlantId_CareActionId",
|
|
||||||
table: "PlantCareSchedules",
|
|
||||||
columns: new[] { "PlantId", "CareActionId" });
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_PlantCareSchedules_PlantId_CareActivityId",
|
|
||||||
table: "PlantCareSchedules",
|
|
||||||
columns: new[] { "PlantId", "CareActivityId" },
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_PlantFlagDefinitions_Name",
|
name: "IX_PlantFlagDefinitions_Name",
|
||||||
table: "PlantFlagDefinitions",
|
table: "PlantFlagDefinitions",
|
||||||
@@ -526,7 +576,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
||||||
table: "PlantTaxa",
|
table: "PlantTaxa",
|
||||||
columns: new[] { "ExternalSource", "ExternalId" });
|
columns: new[] { "ExternalSource", "ExternalId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_RecipeComponents_ActionResourceId",
|
name: "IX_RecipeComponents_ActionResourceId",
|
||||||
@@ -562,7 +613,10 @@ namespace plant_manager.Data.Migrations
|
|||||||
name: "CareActivityActionResources");
|
name: "CareActivityActionResources");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "PlantCareSchedules");
|
name: "CareDismissals");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PlantCareScheduleAssignments");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "PlantFlags");
|
name: "PlantFlags");
|
||||||
@@ -579,6 +633,9 @@ namespace plant_manager.Data.Migrations
|
|||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "CareActivityActions");
|
name: "CareActivityActions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PlantCareSchedules");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "PlantFlagDefinitions");
|
name: "PlantFlagDefinitions");
|
||||||
|
|
||||||
@@ -198,6 +198,34 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.ToTable("CareActivityActionResources");
|
b.ToTable("CareActivityActionResources");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CareActivityId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("DismissedOn")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("PlantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CareActivityId");
|
||||||
|
|
||||||
|
b.HasIndex("PlantId", "CareActivityId", "DismissedOn");
|
||||||
|
|
||||||
|
b.ToTable("CareDismissals");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -253,9 +281,6 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.Property<int>("EveryDays")
|
b.Property<int>("EveryDays")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("PlantId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<string>("RecurrenceMode")
|
b.Property<string>("RecurrenceMode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
@@ -282,12 +307,23 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CareActivityId");
|
b.HasIndex("CareActivityId");
|
||||||
|
|
||||||
b.HasIndex("PlantId", "CareActionId");
|
b.ToTable("PlantCareSchedules");
|
||||||
|
});
|
||||||
|
|
||||||
b.HasIndex("PlantId", "CareActivityId")
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("PlantCareScheduleId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PlantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("PlantCareScheduleId", "PlantId");
|
||||||
|
|
||||||
|
b.HasIndex("PlantId", "PlantCareScheduleId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("PlantCareSchedules");
|
b.ToTable("PlantCareScheduleAssignments");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||||
@@ -425,10 +461,12 @@ namespace plant_manager.Data.Migrations
|
|||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ExternalId")
|
b.Property<string>("ExternalId")
|
||||||
|
.IsRequired()
|
||||||
.HasMaxLength(80)
|
.HasMaxLength(80)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("ExternalSource")
|
b.Property<string>("ExternalSource")
|
||||||
|
.IsRequired()
|
||||||
.HasMaxLength(40)
|
.HasMaxLength(40)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -457,7 +495,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("ExternalSource", "ExternalId");
|
b.HasIndex("ExternalSource", "ExternalId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("PlantTaxa");
|
b.ToTable("PlantTaxa");
|
||||||
});
|
});
|
||||||
@@ -613,6 +652,25 @@ namespace plant_manager.Data.Migrations
|
|||||||
b.Navigation("CareActivityAction");
|
b.Navigation("CareActivityAction");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.CareDismissal", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||||
|
.WithMany("CareDismissals")
|
||||||
|
.HasForeignKey("CareActivityId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||||
|
.WithMany("CareDismissals")
|
||||||
|
.HasForeignKey("PlantId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CareActivity");
|
||||||
|
|
||||||
|
b.Navigation("Plant");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||||
@@ -644,17 +702,28 @@ namespace plant_manager.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CareAction");
|
||||||
|
|
||||||
|
b.Navigation("CareActivity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareScheduleAssignment", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("plant_manager.Data.Models.PlantCareSchedule", "PlantCareSchedule")
|
||||||
|
.WithMany("Assignments")
|
||||||
|
.HasForeignKey("PlantCareScheduleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||||
.WithMany("CareSchedules")
|
.WithMany("CareScheduleAssignments")
|
||||||
.HasForeignKey("PlantId")
|
.HasForeignKey("PlantId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("CareAction");
|
|
||||||
|
|
||||||
b.Navigation("CareActivity");
|
|
||||||
|
|
||||||
b.Navigation("Plant");
|
b.Navigation("Plant");
|
||||||
|
|
||||||
|
b.Navigation("PlantCareSchedule");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||||
@@ -755,6 +824,8 @@ namespace plant_manager.Data.Migrations
|
|||||||
|
|
||||||
b.Navigation("Actions");
|
b.Navigation("Actions");
|
||||||
|
|
||||||
|
b.Navigation("CareDismissals");
|
||||||
|
|
||||||
b.Navigation("PlantCareSchedules");
|
b.Navigation("PlantCareSchedules");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -767,13 +838,20 @@ namespace plant_manager.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("ActionLogs");
|
b.Navigation("ActionLogs");
|
||||||
|
|
||||||
b.Navigation("CareSchedules");
|
b.Navigation("CareDismissals");
|
||||||
|
|
||||||
|
b.Navigation("CareScheduleAssignments");
|
||||||
|
|
||||||
b.Navigation("Flags");
|
b.Navigation("Flags");
|
||||||
|
|
||||||
b.Navigation("GroupMemberships");
|
b.Navigation("GroupMemberships");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assignments");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("PlantFlags");
|
b.Navigation("PlantFlags");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace plant_manager.Data.Models
|
|||||||
|
|
||||||
public List<CareActivityAction> Actions { get; set; } = [];
|
public List<CareActivityAction> Actions { get; set; } = [];
|
||||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||||
|
public List<CareDismissal> CareDismissals { get; set; } = [];
|
||||||
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
|
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace plant_manager.Data.Models
|
||||||
|
{
|
||||||
|
public class CareDismissal
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int PlantId { get; set; }
|
||||||
|
public int CareActivityId { get; set; }
|
||||||
|
public DateOnly DismissedOn { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public Plant Plant { get; set; } = null!;
|
||||||
|
public CareActivity CareActivity { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@ namespace plant_manager.Data.Models
|
|||||||
public PlantTaxon? Taxon { get; set; }
|
public PlantTaxon? Taxon { get; set; }
|
||||||
public PlantLocation? Location { get; set; }
|
public PlantLocation? Location { get; set; }
|
||||||
public List<ActionLog> ActionLogs { get; set; } = [];
|
public List<ActionLog> ActionLogs { get; set; } = [];
|
||||||
public List<PlantCareSchedule> CareSchedules { get; set; } = [];
|
public List<CareDismissal> CareDismissals { get; set; } = [];
|
||||||
|
public List<PlantCareScheduleAssignment> CareScheduleAssignments { get; set; } = [];
|
||||||
public List<PlantFlag> Flags { get; set; } = [];
|
public List<PlantFlag> Flags { get; set; } = [];
|
||||||
public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
|
public List<PlantGroupMembership> GroupMemberships { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ namespace plant_manager.Data.Models
|
|||||||
public class PlantCareSchedule
|
public class PlantCareSchedule
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int PlantId { get; set; }
|
|
||||||
public int CareActionId { get; set; }
|
public int CareActionId { get; set; }
|
||||||
public int CareActivityId { get; set; }
|
public int CareActivityId { get; set; }
|
||||||
public int EveryDays { get; set; } = 7;
|
public int EveryDays { get; set; } = 7;
|
||||||
@@ -16,8 +15,8 @@ namespace plant_manager.Data.Models
|
|||||||
public DateOnly? EndsOn { get; set; }
|
public DateOnly? EndsOn { get; set; }
|
||||||
public int? EndsAfterOccurrences { get; set; }
|
public int? EndsAfterOccurrences { get; set; }
|
||||||
|
|
||||||
public Plant Plant { get; set; } = null!;
|
|
||||||
public CareAction CareAction { get; set; } = null!;
|
public CareAction CareAction { get; set; } = null!;
|
||||||
public CareActivity CareActivity { get; set; } = null!;
|
public CareActivity CareActivity { get; set; } = null!;
|
||||||
|
public List<PlantCareScheduleAssignment> Assignments { get; set; } = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace plant_manager.Data.Models
|
||||||
|
{
|
||||||
|
public class PlantCareScheduleAssignment
|
||||||
|
{
|
||||||
|
public int PlantCareScheduleId { get; set; }
|
||||||
|
public int PlantId { get; set; }
|
||||||
|
|
||||||
|
public PlantCareSchedule PlantCareSchedule { get; set; } = null!;
|
||||||
|
public Plant Plant { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace plant_manager.Data.Models
|
|||||||
public string? Authority { get; set; }
|
public string? Authority { get; set; }
|
||||||
public string? Family { get; set; }
|
public string? Family { get; set; }
|
||||||
public string? CommonName { get; set; }
|
public string? CommonName { get; set; }
|
||||||
public string? ExternalSource { get; set; }
|
public string ExternalSource { get; set; } = string.Empty;
|
||||||
public string? ExternalId { get; set; }
|
public string ExternalId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using plant_manager.Data.Models;
|
||||||
|
|
||||||
|
namespace plant_manager.Data
|
||||||
|
{
|
||||||
|
public static class SeedData
|
||||||
|
{
|
||||||
|
public static async Task SeedDevelopmentDataAsync(this ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
if (await db.Plants.AnyAsync())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
|
var taxa = new
|
||||||
|
{
|
||||||
|
Monstera = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Swiss Cheese Plant",
|
||||||
|
Genus = "Monstera",
|
||||||
|
Species = "deliciosa",
|
||||||
|
Family = "Araceae",
|
||||||
|
CommonName = "Swiss Cheese Plant",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "2871984"
|
||||||
|
},
|
||||||
|
Ficus = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Fiddle-leaf Fig",
|
||||||
|
Genus = "Ficus",
|
||||||
|
Species = "lyrata",
|
||||||
|
Family = "Moraceae",
|
||||||
|
CommonName = "Fiddle-leaf Fig",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "5361909"
|
||||||
|
},
|
||||||
|
Alocasia = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Kris Plant",
|
||||||
|
Genus = "Alocasia",
|
||||||
|
Species = "sanderiana",
|
||||||
|
Family = "Araceae",
|
||||||
|
CommonName = "Kris Plant",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "2879681"
|
||||||
|
},
|
||||||
|
Peperomia = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Marble Peperomia",
|
||||||
|
Genus = "Peperomia",
|
||||||
|
Species = "obtusifolia",
|
||||||
|
Cultivar = "Marble",
|
||||||
|
Family = "Piperaceae",
|
||||||
|
CommonName = "Marble Peperomia",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "4189935"
|
||||||
|
},
|
||||||
|
Pothos = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Pothos",
|
||||||
|
Genus = "Epipremnum",
|
||||||
|
Species = "aureum",
|
||||||
|
Family = "Araceae",
|
||||||
|
CommonName = "Pothos",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "2868275"
|
||||||
|
},
|
||||||
|
Orchid = new PlantTaxon
|
||||||
|
{
|
||||||
|
Name = "Moth Orchid",
|
||||||
|
Genus = "Phalaenopsis",
|
||||||
|
Species = "hybrid",
|
||||||
|
Family = "Orchidaceae",
|
||||||
|
CommonName = "Moth Orchid",
|
||||||
|
ExternalSource = "gbif",
|
||||||
|
ExternalId = "2879487"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var locations = new
|
||||||
|
{
|
||||||
|
LivingRoom = new PlantLocation { Name = "Living Room", Notes = "Bright indirect light near the east window." },
|
||||||
|
Office = new PlantLocation { Name = "Office", Notes = "Grow light shelf." },
|
||||||
|
Kitchen = new PlantLocation { Name = "Kitchen", Notes = "Higher humidity and morning light." },
|
||||||
|
Quarantine = new PlantLocation { Name = "Quarantine Shelf", Notes = "Temporary isolation and observation." }
|
||||||
|
};
|
||||||
|
|
||||||
|
var actions = new
|
||||||
|
{
|
||||||
|
Water = new CareAction { Name = "Water", Description = "Apply water or prepared solution." },
|
||||||
|
Fertilize = new CareAction { Name = "Fertilize", Description = "Apply nutrients at the selected dilution." },
|
||||||
|
Inspect = new CareAction { Name = "Inspect", Description = "Check foliage, roots, and pest pressure." },
|
||||||
|
Repot = new CareAction { Name = "Repot", Description = "Refresh substrate or move to a larger pot." },
|
||||||
|
Prune = new CareAction { Name = "Prune", Description = "Trim damaged or overgrown foliage." }
|
||||||
|
};
|
||||||
|
|
||||||
|
var resources = new
|
||||||
|
{
|
||||||
|
Water = new ActionResource { Name = "Water", Notes = "Room-temperature filtered water." },
|
||||||
|
Fertilizer = new ActionResource { Name = "Liquid Fertilizer", Notes = "Balanced houseplant concentrate." },
|
||||||
|
PottingMix = new ActionResource { Name = "Potting Mix", Notes = "General indoor plant substrate." },
|
||||||
|
OrchidBark = new ActionResource { Name = "Orchid Bark", Notes = "Chunky aeration component." },
|
||||||
|
Perlite = new ActionResource { Name = "Perlite", Notes = "Lightweight aeration component." },
|
||||||
|
NeemOil = new ActionResource { Name = "Neem Oil", Notes = "Pest treatment concentrate." },
|
||||||
|
Shears = new ActionResource { Name = "Clean Shears", Notes = "Sterilized cutting tool." }
|
||||||
|
};
|
||||||
|
|
||||||
|
var nutrientMix = new Recipe
|
||||||
|
{
|
||||||
|
Name = "Gentle Nutrient Mix",
|
||||||
|
MeasurementMode = "bakers_percent",
|
||||||
|
OutputResource = new ActionResource
|
||||||
|
{
|
||||||
|
Name = "Gentle Nutrient Mix",
|
||||||
|
Notes = "Seed recipe output used by fertilizing activities."
|
||||||
|
},
|
||||||
|
Notes = "Water is the 100% base; fertilizer is measured against that base.",
|
||||||
|
Components =
|
||||||
|
[
|
||||||
|
new RecipeComponent
|
||||||
|
{
|
||||||
|
ActionResource = resources.Water,
|
||||||
|
Quantity = 100,
|
||||||
|
Unit = "%",
|
||||||
|
Notes = "Base",
|
||||||
|
SortOrder = 0
|
||||||
|
},
|
||||||
|
new RecipeComponent
|
||||||
|
{
|
||||||
|
ActionResource = resources.Fertilizer,
|
||||||
|
Quantity = 5,
|
||||||
|
Unit = "%",
|
||||||
|
Notes = "Light feeding strength",
|
||||||
|
SortOrder = 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var waterActivity = new CareActivity
|
||||||
|
{
|
||||||
|
Name = "Water",
|
||||||
|
Notes = "Routine watering based on substrate dryness.",
|
||||||
|
Actions =
|
||||||
|
[
|
||||||
|
new CareActivityAction
|
||||||
|
{
|
||||||
|
CareAction = actions.Water,
|
||||||
|
SortOrder = 0,
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new CareActivityActionResource
|
||||||
|
{
|
||||||
|
ActionResource = resources.Water,
|
||||||
|
Quantity = 500,
|
||||||
|
Unit = "ml",
|
||||||
|
Notes = "Adjust by pot size."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var fertilizeActivity = new CareActivity
|
||||||
|
{
|
||||||
|
Name = "Fertilize",
|
||||||
|
Notes = "Light feeding during active growth.",
|
||||||
|
Actions =
|
||||||
|
[
|
||||||
|
new CareActivityAction
|
||||||
|
{
|
||||||
|
CareAction = actions.Fertilize,
|
||||||
|
SortOrder = 0,
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new CareActivityActionResource
|
||||||
|
{
|
||||||
|
ActionResource = nutrientMix.OutputResource,
|
||||||
|
Quantity = 250,
|
||||||
|
Unit = "ml",
|
||||||
|
Notes = "Use after watering if soil is very dry."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var inspectActivity = new CareActivity
|
||||||
|
{
|
||||||
|
Name = "Pest Check",
|
||||||
|
Notes = "Inspect leaves, stems, and soil surface.",
|
||||||
|
Actions =
|
||||||
|
[
|
||||||
|
new CareActivityAction
|
||||||
|
{
|
||||||
|
CareAction = actions.Inspect,
|
||||||
|
SortOrder = 0,
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new CareActivityActionResource
|
||||||
|
{
|
||||||
|
ActionResource = resources.NeemOil,
|
||||||
|
Quantity = 0,
|
||||||
|
Unit = "ml",
|
||||||
|
Notes = "Only use if pests are found."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var repotActivity = new CareActivity
|
||||||
|
{
|
||||||
|
Name = "Repot",
|
||||||
|
Notes = "Refresh substrate and inspect roots.",
|
||||||
|
Actions =
|
||||||
|
[
|
||||||
|
new CareActivityAction
|
||||||
|
{
|
||||||
|
CareAction = actions.Repot,
|
||||||
|
SortOrder = 0,
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new CareActivityActionResource { ActionResource = resources.PottingMix, Quantity = 1, Unit = "L" },
|
||||||
|
new CareActivityActionResource { ActionResource = resources.OrchidBark, Quantity = 0.5m, Unit = "L" },
|
||||||
|
new CareActivityActionResource { ActionResource = resources.Perlite, Quantity = 0.5m, Unit = "L" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var pruneActivity = new CareActivity
|
||||||
|
{
|
||||||
|
Name = "Prune",
|
||||||
|
Notes = "Remove damaged foliage and shape growth.",
|
||||||
|
Actions =
|
||||||
|
[
|
||||||
|
new CareActivityAction
|
||||||
|
{
|
||||||
|
CareAction = actions.Prune,
|
||||||
|
SortOrder = 0,
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new CareActivityActionResource { ActionResource = resources.Shears, Quantity = 1, Unit = "tool" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var flags = new
|
||||||
|
{
|
||||||
|
Attention = new PlantFlagDefinition { Name = "Attention", Color = "#FFAB00" },
|
||||||
|
Recovering = new PlantFlagDefinition { Name = "Recovering", Color = "#36B37E" },
|
||||||
|
Quarantine = new PlantFlagDefinition { Name = "Quarantine", Color = "#FF5630" },
|
||||||
|
Wishlist = new PlantFlagDefinition { Name = "Wishlist", Color = "#6554C0" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var plants = new
|
||||||
|
{
|
||||||
|
Monstera = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Elara",
|
||||||
|
Birthday = today.AddDays(-420),
|
||||||
|
Taxon = taxa.Monstera,
|
||||||
|
Location = locations.LivingRoom
|
||||||
|
},
|
||||||
|
Ficus = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Darrow",
|
||||||
|
Birthday = today.AddDays(-260),
|
||||||
|
Taxon = taxa.Ficus,
|
||||||
|
Location = locations.LivingRoom
|
||||||
|
},
|
||||||
|
Kris = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Tamsin",
|
||||||
|
Birthday = today.AddDays(-120),
|
||||||
|
Taxon = taxa.Alocasia,
|
||||||
|
Location = locations.Quarantine
|
||||||
|
},
|
||||||
|
Peperomia = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Rowan",
|
||||||
|
Birthday = today.AddDays(-95),
|
||||||
|
Taxon = taxa.Peperomia,
|
||||||
|
Location = locations.Office
|
||||||
|
},
|
||||||
|
Pothos = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Juno",
|
||||||
|
Birthday = today.AddDays(-700),
|
||||||
|
Taxon = taxa.Pothos,
|
||||||
|
Location = locations.Kitchen
|
||||||
|
},
|
||||||
|
Orchid = new Plant
|
||||||
|
{
|
||||||
|
Nickname = "Lyric",
|
||||||
|
Birthday = today.AddDays(-35),
|
||||||
|
Taxon = taxa.Orchid,
|
||||||
|
Location = locations.Office
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var groups = new
|
||||||
|
{
|
||||||
|
LivingRoom = new PlantGroup
|
||||||
|
{
|
||||||
|
Name = "Living Room Group",
|
||||||
|
Notes = "Larger statement plants.",
|
||||||
|
Memberships =
|
||||||
|
[
|
||||||
|
new PlantGroupMembership { Plant = plants.Monstera },
|
||||||
|
new PlantGroupMembership { Plant = plants.Ficus }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
Humidity = new PlantGroup
|
||||||
|
{
|
||||||
|
Name = "Humidity Lovers",
|
||||||
|
Notes = "Plants that prefer steadier humidity.",
|
||||||
|
Memberships =
|
||||||
|
[
|
||||||
|
new PlantGroupMembership { Plant = plants.Kris },
|
||||||
|
new PlantGroupMembership { Plant = plants.Orchid },
|
||||||
|
new PlantGroupMembership { Plant = plants.Peperomia }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
EasyCare = new PlantGroup
|
||||||
|
{
|
||||||
|
Name = "Easy Care",
|
||||||
|
Notes = "Reliable low-maintenance plants.",
|
||||||
|
Memberships =
|
||||||
|
[
|
||||||
|
new PlantGroupMembership { Plant = plants.Pothos },
|
||||||
|
new PlantGroupMembership { Plant = plants.Peperomia }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
plants.Ficus.Flags.Add(new PlantFlag
|
||||||
|
{
|
||||||
|
Definition = flags.Attention,
|
||||||
|
StartedOn = today.AddDays(-3),
|
||||||
|
Notes = "Watch for leaf drop after relocation."
|
||||||
|
});
|
||||||
|
plants.Kris.Flags.Add(new PlantFlag
|
||||||
|
{
|
||||||
|
Definition = flags.Quarantine,
|
||||||
|
StartedOn = today.AddDays(-8),
|
||||||
|
Notes = "New arrival. Inspect before moving near other plants."
|
||||||
|
});
|
||||||
|
plants.Orchid.Flags.Add(new PlantFlag
|
||||||
|
{
|
||||||
|
Definition = flags.Recovering,
|
||||||
|
StartedOn = today.AddDays(-15),
|
||||||
|
ResolvedOn = today.AddDays(-2),
|
||||||
|
Notes = "Recovered after bloom spike trim."
|
||||||
|
});
|
||||||
|
|
||||||
|
waterActivity.PlantCareSchedules =
|
||||||
|
[
|
||||||
|
new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareAction = actions.Water,
|
||||||
|
EveryDays = 7,
|
||||||
|
ScheduledFor = today.AddDays(-7),
|
||||||
|
RecurrenceMode = "weekly",
|
||||||
|
RepeatEvery = 1,
|
||||||
|
RepeatUnit = "week",
|
||||||
|
EndsMode = "never",
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Monstera },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Ficus },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Pothos }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareAction = actions.Water,
|
||||||
|
EveryDays = 4,
|
||||||
|
ScheduledFor = today.AddDays(-4),
|
||||||
|
RecurrenceMode = "custom",
|
||||||
|
RepeatEvery = 4,
|
||||||
|
RepeatUnit = "day",
|
||||||
|
EndsMode = "never",
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Kris },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Peperomia },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Orchid }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
fertilizeActivity.PlantCareSchedules =
|
||||||
|
[
|
||||||
|
new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareAction = actions.Fertilize,
|
||||||
|
EveryDays = 30,
|
||||||
|
ScheduledFor = today.AddDays(5),
|
||||||
|
RecurrenceMode = "monthly",
|
||||||
|
RepeatEvery = 1,
|
||||||
|
RepeatUnit = "month",
|
||||||
|
EndsMode = "never",
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Monstera },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Pothos },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Peperomia }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
inspectActivity.PlantCareSchedules =
|
||||||
|
[
|
||||||
|
new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareAction = actions.Inspect,
|
||||||
|
EveryDays = 14,
|
||||||
|
ScheduledFor = today.AddDays(-2),
|
||||||
|
RecurrenceMode = "weekly",
|
||||||
|
RepeatEvery = 1,
|
||||||
|
RepeatUnit = "week",
|
||||||
|
EndsMode = "after",
|
||||||
|
EndsAfterOccurrences = 8,
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Kris },
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Ficus }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
repotActivity.PlantCareSchedules =
|
||||||
|
[
|
||||||
|
new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareAction = actions.Repot,
|
||||||
|
EveryDays = 365,
|
||||||
|
ScheduledFor = today.AddDays(45),
|
||||||
|
RecurrenceMode = "none",
|
||||||
|
RepeatEvery = 1,
|
||||||
|
RepeatUnit = "week",
|
||||||
|
EndsMode = "after",
|
||||||
|
EndsAfterOccurrences = 1,
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment { Plant = plants.Ficus }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
plants.Monstera.ActionLogs.Add(new ActionLog
|
||||||
|
{
|
||||||
|
CareAction = actions.Water,
|
||||||
|
CareActivity = waterActivity,
|
||||||
|
ActionNameSnapshot = waterActivity.Name,
|
||||||
|
PerformedOn = today.AddDays(-7),
|
||||||
|
Notes = "Thorough soak; pot drained well.",
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new ActionLogResource { ActionResource = resources.Water, Quantity = 650, Unit = "ml" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
plants.Pothos.ActionLogs.Add(new ActionLog
|
||||||
|
{
|
||||||
|
CareAction = actions.Water,
|
||||||
|
CareActivity = waterActivity,
|
||||||
|
ActionNameSnapshot = waterActivity.Name,
|
||||||
|
PerformedOn = today.AddDays(-10),
|
||||||
|
Notes = "Let dry longer next round.",
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new ActionLogResource { ActionResource = resources.Water, Quantity = 400, Unit = "ml" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
plants.Peperomia.ActionLogs.Add(new ActionLog
|
||||||
|
{
|
||||||
|
CareAction = actions.Inspect,
|
||||||
|
CareActivity = inspectActivity,
|
||||||
|
ActionNameSnapshot = inspectActivity.Name,
|
||||||
|
PerformedOn = today.AddDays(-1),
|
||||||
|
Notes = "No pests observed.",
|
||||||
|
Resources = []
|
||||||
|
});
|
||||||
|
plants.Monstera.ActionLogs.Add(new ActionLog
|
||||||
|
{
|
||||||
|
CareAction = actions.Fertilize,
|
||||||
|
CareActivity = fertilizeActivity,
|
||||||
|
ActionNameSnapshot = fertilizeActivity.Name,
|
||||||
|
PerformedOn = today.AddDays(-28),
|
||||||
|
Notes = "Light feed during new leaf growth.",
|
||||||
|
Resources =
|
||||||
|
[
|
||||||
|
new ActionLogResource { ActionResource = nutrientMix.OutputResource, Quantity = 250, Unit = "ml" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
db.PlantTaxa.AddRange(taxa.Monstera, taxa.Ficus, taxa.Alocasia, taxa.Peperomia, taxa.Pothos, taxa.Orchid);
|
||||||
|
db.PlantLocations.AddRange(locations.LivingRoom, locations.Office, locations.Kitchen, locations.Quarantine);
|
||||||
|
db.CareActions.AddRange(actions.Water, actions.Fertilize, actions.Inspect, actions.Repot, actions.Prune);
|
||||||
|
db.ActionResources.AddRange(resources.Water, resources.Fertilizer, resources.PottingMix, resources.OrchidBark, resources.Perlite, resources.NeemOil, resources.Shears);
|
||||||
|
db.Recipes.Add(nutrientMix);
|
||||||
|
db.CareActivities.AddRange(waterActivity, fertilizeActivity, inspectActivity, repotActivity, pruneActivity);
|
||||||
|
db.PlantFlagDefinitions.AddRange(flags.Attention, flags.Recovering, flags.Quarantine, flags.Wishlist);
|
||||||
|
db.Plants.AddRange(plants.Monstera, plants.Ficus, plants.Kris, plants.Peperomia, plants.Pothos, plants.Orchid);
|
||||||
|
db.PlantGroups.AddRange(groups.LivingRoom, groups.Humidity, groups.EasyCare);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,80 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log));
|
return Results.Created($"/api/action-logs/{log.Id}", ActionLogDto.FromActionLog(log));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/action-logs/bulk", async (BulkCompleteCareTasksRequest request, ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
var plantIds = request.PlantIds
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (plantIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var plants = await db.Plants
|
||||||
|
.Where(plant => plantIds.Contains(plant.Id))
|
||||||
|
.OrderBy(plant => plant.Nickname)
|
||||||
|
.ToListAsync();
|
||||||
|
if (plants.Count != plantIds.Count)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "One or more plants were not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await db.CareActivities
|
||||||
|
.Include(item => item.Actions)
|
||||||
|
.ThenInclude(action => action.CareAction)
|
||||||
|
.Include(item => item.Actions)
|
||||||
|
.ThenInclude(action => action.Resources)
|
||||||
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Care activity was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryAction = activity.PrimaryAction();
|
||||||
|
if (primaryAction is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Care activity has no configured actions." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||||
|
var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db);
|
||||||
|
if (resourceError is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = resourceError });
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs = plants
|
||||||
|
.Select(plant => new ActionLog
|
||||||
|
{
|
||||||
|
PlantId = plant.Id,
|
||||||
|
CareActionId = primaryAction.Id,
|
||||||
|
CareActivityId = activity.Id,
|
||||||
|
CareAction = primaryAction,
|
||||||
|
CareActivity = activity,
|
||||||
|
ActionNameSnapshot = activity.Name,
|
||||||
|
Notes = notes,
|
||||||
|
PerformedOn = performedOn,
|
||||||
|
Resources = resources
|
||||||
|
.Select(resource => new ActionLogResource
|
||||||
|
{
|
||||||
|
ActionResourceId = resource.ActionResourceId,
|
||||||
|
Quantity = resource.Quantity,
|
||||||
|
Unit = resource.Unit
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
db.ActionLogs.AddRange(logs);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { completed = logs.Count });
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) =>
|
app.MapPut("/api/action-logs/{id:int}", async (int id, UpdateActionLogRequest request, ApplicationDbContext db) =>
|
||||||
{
|
{
|
||||||
var log = await db.ActionLogs
|
var log = await db.ActionLogs
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ namespace plant_manager.Endpoints
|
|||||||
.Include(activity => activity.Actions)
|
.Include(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.ThenInclude(resource => resource.ProducedByRecipe)
|
||||||
|
.ThenInclude(recipe => recipe!.Components)
|
||||||
|
.ThenInclude(component => component.ActionResource)
|
||||||
.OrderBy(activity => activity.Name)
|
.OrderBy(activity => activity.Name)
|
||||||
.Select(activity => CareActivityDto.FromCareActivity(activity))
|
.Select(activity => CareActivityDto.FromCareActivity(activity))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -59,6 +62,9 @@ namespace plant_manager.Endpoints
|
|||||||
.Include(item => item.Actions)
|
.Include(item => item.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.ThenInclude(resource => resource.ProducedByRecipe)
|
||||||
|
.ThenInclude(recipe => recipe!.Components)
|
||||||
|
.ThenInclude(component => component.ActionResource)
|
||||||
.FirstOrDefaultAsync(item => item.Id == id);
|
.FirstOrDefaultAsync(item => item.Id == id);
|
||||||
if (activity is null)
|
if (activity is null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,41 +11,32 @@ namespace plant_manager.Endpoints
|
|||||||
async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db)
|
async Task<IResult> GetUpcomingCareTasks(ApplicationDbContext db)
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
var schedules = await db.PlantCareSchedules
|
var assignments = await db.PlantCareScheduleAssignments
|
||||||
.Include(schedule => schedule.Plant)
|
.Include(assignment => assignment.Plant)
|
||||||
.Include(schedule => schedule.CareAction)
|
.ThenInclude(plant => plant.CareDismissals)
|
||||||
.Include(schedule => schedule.CareActivity)
|
.Include(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.ActionLogs)
|
||||||
|
.Include(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.CareAction)
|
||||||
|
.Include(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.CareAction)
|
.ThenInclude(action => action.CareAction)
|
||||||
.Include(schedule => schedule.CareActivity)
|
.Include(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
.ThenBy(schedule => schedule.CareActivity.Name)
|
.ThenBy(assignment => assignment.PlantCareSchedule.CareActivity.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var latestLogs = await db.ActionLogs
|
var (latestLogLookup, completedLookup) = await GetCareProgress(db);
|
||||||
.GroupBy(log => new { log.PlantId, log.CareActivityId })
|
|
||||||
.Select(group => new
|
|
||||||
{
|
|
||||||
group.Key.PlantId,
|
|
||||||
group.Key.CareActivityId,
|
|
||||||
LastPerformedOn = group.Max(log => log.PerformedOn),
|
|
||||||
CompletedOccurrences = group.Count()
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
var latestLogLookup = latestLogs.ToDictionary(
|
|
||||||
log => (log.PlantId, log.CareActivityId),
|
|
||||||
log => (DateOnly?)log.LastPerformedOn);
|
|
||||||
var completedLookup = latestLogs.ToDictionary(
|
|
||||||
log => (log.PlantId, log.CareActivityId),
|
|
||||||
log => log.CompletedOccurrences);
|
|
||||||
|
|
||||||
var tasks = schedules
|
var tasks = assignments
|
||||||
.Select(schedule => CareTaskDto.FromSchedule(
|
.Select(assignment => CareTaskDto.FromAssignment(
|
||||||
schedule,
|
assignment,
|
||||||
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
latestLogLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
|
||||||
completedLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
|
completedLookup.GetValueOrDefault((assignment.PlantId, assignment.PlantCareSchedule.CareActivityId)),
|
||||||
today))
|
today))
|
||||||
.Where(task => task.Status is "due" or "soon")
|
.Where(task => task.Status is "due" or "soon")
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -88,14 +79,15 @@ namespace plant_manager.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
var schedules = await db.PlantCareSchedules
|
var assignments = await db.PlantCareScheduleAssignments
|
||||||
.Include(schedule => schedule.Plant)
|
.Include(assignment => assignment.Plant)
|
||||||
.Where(schedule =>
|
.Include(assignment => assignment.PlantCareSchedule)
|
||||||
schedule.CareActivityId == activity.Id
|
.Where(assignment =>
|
||||||
&& plantIds.Contains(schedule.PlantId))
|
assignment.PlantCareSchedule.CareActivityId == activity.Id
|
||||||
|
&& plantIds.Contains(assignment.PlantId))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var schedulePlantIds = schedules
|
var schedulePlantIds = assignments
|
||||||
.Select(schedule => schedule.PlantId)
|
.Select(assignment => assignment.PlantId)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
if (schedulePlantIds.Count != plantIds.Count)
|
if (schedulePlantIds.Count != plantIds.Count)
|
||||||
{
|
{
|
||||||
@@ -118,15 +110,33 @@ namespace plant_manager.Endpoints
|
|||||||
var completedLookup = latestLogs.ToDictionary(
|
var completedLookup = latestLogs.ToDictionary(
|
||||||
log => log.PlantId,
|
log => log.PlantId,
|
||||||
log => log.CompletedOccurrences);
|
log => log.CompletedOccurrences);
|
||||||
var duePlantIds = schedules
|
var dismissals = await db.CareDismissals
|
||||||
.Where(schedule =>
|
.Where(dismissal => dismissal.CareActivityId == activity.Id && plantIds.Contains(dismissal.PlantId))
|
||||||
|
.GroupBy(dismissal => dismissal.PlantId)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
PlantId = group.Key,
|
||||||
|
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
|
||||||
|
DismissedOccurrences = group.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var dismissal in dismissals)
|
||||||
|
{
|
||||||
|
latestLogLookup[dismissal.PlantId] = MaxDate(
|
||||||
|
latestLogLookup.GetValueOrDefault(dismissal.PlantId),
|
||||||
|
dismissal.LastDismissedOn);
|
||||||
|
completedLookup[dismissal.PlantId] = completedLookup.GetValueOrDefault(dismissal.PlantId)
|
||||||
|
+ dismissal.DismissedOccurrences;
|
||||||
|
}
|
||||||
|
var duePlantIds = assignments
|
||||||
|
.Where(assignment =>
|
||||||
PlantCareFormatter.GetStatus(
|
PlantCareFormatter.GetStatus(
|
||||||
PlantCareFormatter.GetNextCareDate(
|
PlantCareFormatter.GetNextCareDate(
|
||||||
schedule,
|
assignment.PlantCareSchedule,
|
||||||
latestLogLookup.GetValueOrDefault(schedule.PlantId),
|
latestLogLookup.GetValueOrDefault(assignment.PlantId),
|
||||||
completedLookup.GetValueOrDefault(schedule.PlantId)),
|
completedLookup.GetValueOrDefault(assignment.PlantId)),
|
||||||
today) == "due")
|
today) == "due")
|
||||||
.Select(schedule => schedule.PlantId)
|
.Select(assignment => assignment.PlantId)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
if (duePlantIds.Count != plantIds.Count)
|
if (duePlantIds.Count != plantIds.Count)
|
||||||
@@ -166,11 +176,11 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
var performedOn = request.PerformedOn ?? today;
|
var performedOn = request.PerformedOn ?? today;
|
||||||
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||||
var logs = schedules
|
var logs = assignments
|
||||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
.Select(schedule => new ActionLog
|
.Select(assignment => new ActionLog
|
||||||
{
|
{
|
||||||
PlantId = schedule.PlantId,
|
PlantId = assignment.PlantId,
|
||||||
CareActionId = primaryAction.Id,
|
CareActionId = primaryAction.Id,
|
||||||
CareActivityId = activity.Id,
|
CareActivityId = activity.Id,
|
||||||
ActionNameSnapshot = activity.Name,
|
ActionNameSnapshot = activity.Name,
|
||||||
@@ -192,8 +202,139 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
return Results.Ok(new { completed = logs.Count });
|
return Results.Ok(new { completed = logs.Count });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/care-tasks/dismiss-bulk", async (
|
||||||
|
DismissCareTasksRequest request,
|
||||||
|
ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
var plantIds = request.PlantIds
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (plantIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "At least one plant is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await db.CareActivities.FindAsync(request.CareActivityId);
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Care activity was not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var assignments = await db.PlantCareScheduleAssignments
|
||||||
|
.Include(assignment => assignment.Plant)
|
||||||
|
.Include(assignment => assignment.PlantCareSchedule)
|
||||||
|
.Where(assignment =>
|
||||||
|
assignment.PlantCareSchedule.CareActivityId == activity.Id
|
||||||
|
&& plantIds.Contains(assignment.PlantId))
|
||||||
|
.ToListAsync();
|
||||||
|
var schedulePlantIds = assignments
|
||||||
|
.Select(assignment => assignment.PlantId)
|
||||||
|
.ToHashSet();
|
||||||
|
if (schedulePlantIds.Count != plantIds.Count)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "One or more plants do not have this schedule." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var (latestLookup, completedLookup) = await GetCareProgress(db, activity.Id, plantIds);
|
||||||
|
var duePlantIds = assignments
|
||||||
|
.Where(assignment =>
|
||||||
|
PlantCareFormatter.GetStatus(
|
||||||
|
PlantCareFormatter.GetNextCareDate(
|
||||||
|
assignment.PlantCareSchedule,
|
||||||
|
latestLookup.GetValueOrDefault((assignment.PlantId, activity.Id)),
|
||||||
|
completedLookup.GetValueOrDefault((assignment.PlantId, activity.Id))),
|
||||||
|
today) == "due")
|
||||||
|
.Select(assignment => assignment.PlantId)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (duePlantIds.Count != plantIds.Count)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Only due care tasks can be dismissed." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var dismissedOn = request.DismissedOn ?? today;
|
||||||
|
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
|
||||||
|
var dismissals = assignments
|
||||||
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
|
.Select(assignment => new CareDismissal
|
||||||
|
{
|
||||||
|
PlantId = assignment.PlantId,
|
||||||
|
CareActivityId = activity.Id,
|
||||||
|
DismissedOn = dismissedOn,
|
||||||
|
Notes = notes
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
db.CareDismissals.AddRange(dismissals);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { dismissed = dismissals.Count });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<(
|
||||||
|
Dictionary<(int PlantId, int CareActivityId), DateOnly?> LatestLookup,
|
||||||
|
Dictionary<(int PlantId, int CareActivityId), int> CompletedLookup)> GetCareProgress(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
int? careActivityId = null,
|
||||||
|
IReadOnlyList<int>? plantIds = null)
|
||||||
|
{
|
||||||
|
var logsQuery = db.ActionLogs.AsQueryable();
|
||||||
|
var dismissalsQuery = db.CareDismissals.AsQueryable();
|
||||||
|
if (careActivityId is not null)
|
||||||
|
{
|
||||||
|
logsQuery = logsQuery.Where(log => log.CareActivityId == careActivityId);
|
||||||
|
dismissalsQuery = dismissalsQuery.Where(dismissal => dismissal.CareActivityId == careActivityId);
|
||||||
|
}
|
||||||
|
if (plantIds is not null)
|
||||||
|
{
|
||||||
|
logsQuery = logsQuery.Where(log => plantIds.Contains(log.PlantId));
|
||||||
|
dismissalsQuery = dismissalsQuery.Where(dismissal => plantIds.Contains(dismissal.PlantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestLogs = await logsQuery
|
||||||
|
.GroupBy(log => new { log.PlantId, log.CareActivityId })
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
group.Key.PlantId,
|
||||||
|
group.Key.CareActivityId,
|
||||||
|
LastPerformedOn = group.Max(log => log.PerformedOn),
|
||||||
|
CompletedOccurrences = group.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
var latestLookup = latestLogs.ToDictionary(
|
||||||
|
log => (log.PlantId, log.CareActivityId),
|
||||||
|
log => (DateOnly?)log.LastPerformedOn);
|
||||||
|
var completedLookup = latestLogs.ToDictionary(
|
||||||
|
log => (log.PlantId, log.CareActivityId),
|
||||||
|
log => log.CompletedOccurrences);
|
||||||
|
|
||||||
|
var latestDismissals = await dismissalsQuery
|
||||||
|
.GroupBy(dismissal => new { dismissal.PlantId, dismissal.CareActivityId })
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
group.Key.PlantId,
|
||||||
|
group.Key.CareActivityId,
|
||||||
|
LastDismissedOn = group.Max(dismissal => dismissal.DismissedOn),
|
||||||
|
DismissedOccurrences = group.Count()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var dismissal in latestDismissals)
|
||||||
|
{
|
||||||
|
var key = (dismissal.PlantId, dismissal.CareActivityId);
|
||||||
|
latestLookup[key] = MaxDate(latestLookup.GetValueOrDefault(key), dismissal.LastDismissedOn);
|
||||||
|
completedLookup[key] = completedLookup.GetValueOrDefault(key) + dismissal.DismissedOccurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (latestLookup, completedLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly MaxDate(DateOnly? left, DateOnly right) =>
|
||||||
|
left is null || right > left ? right : left.Value;
|
||||||
|
|
||||||
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
|
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
|
||||||
activity.Actions
|
activity.Actions
|
||||||
.SelectMany(action => action.Resources)
|
.SelectMany(action => action.Resources)
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ namespace plant_manager.Endpoints
|
|||||||
.ThenInclude(flag => flag.Definition)
|
.ThenInclude(flag => flag.Definition)
|
||||||
.Include(plant => plant.GroupMemberships)
|
.Include(plant => plant.GroupMemberships)
|
||||||
.ThenInclude(membership => membership.PlantGroup)
|
.ThenInclude(membership => membership.PlantGroup)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.Include(plant => plant.ActionLogs)
|
.Include(plant => plant.ActionLogs)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
@@ -65,7 +66,7 @@ namespace plant_manager.Endpoints
|
|||||||
.Where(flag => flag.ResolvedOn == null)
|
.Where(flag => flag.ResolvedOn == null)
|
||||||
.OrderBy(flag => flag.Definition.Name)
|
.OrderBy(flag => flag.Definition.Name)
|
||||||
.Select(flag => flag.Definition.Name)),
|
.Select(flag => flag.Definition.Name)),
|
||||||
plant.CareSchedules.Count,
|
plant.CareScheduleAssignments.Count,
|
||||||
plant.ActionLogs.Count
|
plant.ActionLogs.Count
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -87,18 +88,23 @@ namespace plant_manager.Endpoints
|
|||||||
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
private static async Task AddCareSchedulesSheet(XLWorkbook workbook, ApplicationDbContext db)
|
||||||
{
|
{
|
||||||
var schedules = await db.PlantCareSchedules
|
var schedules = await db.PlantCareSchedules
|
||||||
.Include(schedule => schedule.Plant)
|
|
||||||
.Include(schedule => schedule.CareActivity)
|
.Include(schedule => schedule.CareActivity)
|
||||||
.Include(schedule => schedule.CareAction)
|
.Include(schedule => schedule.CareAction)
|
||||||
.OrderBy(schedule => schedule.Plant.Nickname)
|
.Include(schedule => schedule.Assignments)
|
||||||
.ThenBy(schedule => schedule.CareActivity.Name)
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.OrderBy(schedule => schedule.CareActivity.Name)
|
||||||
|
.ThenBy(schedule => schedule.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var rows = schedules
|
var rows = schedules
|
||||||
.Select(schedule => new object?[]
|
.Select(schedule => new object?[]
|
||||||
{
|
{
|
||||||
schedule.Id,
|
schedule.Id,
|
||||||
schedule.PlantId,
|
string.Join(", ", schedule.Assignments
|
||||||
schedule.Plant.Nickname,
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
|
.Select(assignment => assignment.PlantId)),
|
||||||
|
string.Join(", ", schedule.Assignments
|
||||||
|
.OrderBy(assignment => assignment.Plant.Nickname)
|
||||||
|
.Select(assignment => assignment.Plant.Nickname)),
|
||||||
schedule.CareActivity.Name,
|
schedule.CareActivity.Name,
|
||||||
schedule.CareAction.Name,
|
schedule.CareAction.Name,
|
||||||
schedule.EveryDays,
|
schedule.EveryDays,
|
||||||
@@ -115,8 +121,8 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
AddSheet(workbook, "Care Schedules", [
|
AddSheet(workbook, "Care Schedules", [
|
||||||
"ID",
|
"ID",
|
||||||
"Plant ID",
|
"Plant IDs",
|
||||||
"Plant",
|
"Plants",
|
||||||
"Activity",
|
"Activity",
|
||||||
"Primary Action",
|
"Primary Action",
|
||||||
"Every Days",
|
"Every Days",
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ namespace plant_manager.Endpoints
|
|||||||
{
|
{
|
||||||
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
|
await ImportFlags(workbook, db, context, apply, FlagsSheet, "Flag");
|
||||||
}
|
}
|
||||||
await ImportTaxa(workbook, db, context, apply);
|
ImportTaxa(workbook, context);
|
||||||
|
|
||||||
if (context.Sheets.Count == 0)
|
if (context.Sheets.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -343,87 +343,27 @@ namespace plant_manager.Endpoints
|
|||||||
AddSummary(context, summary);
|
AddSummary(context, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ImportTaxa(
|
private static void ImportTaxa(
|
||||||
XLWorkbook workbook,
|
XLWorkbook workbook,
|
||||||
ApplicationDbContext db,
|
ImportContext context)
|
||||||
ImportContext context,
|
|
||||||
bool apply)
|
|
||||||
{
|
{
|
||||||
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
|
if (!TryGetWorksheet(workbook, TaxaSheet, out var worksheet))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: ["Name", "Genus", "Species"]);
|
var rows = ReadRows(worksheet, TaxaSheet, context, requiredHeaders: []);
|
||||||
var existing = await db.PlantTaxa.ToListAsync();
|
|
||||||
var byName = existing.ToDictionary(taxon => Key(taxon.Name), StringComparer.OrdinalIgnoreCase);
|
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
var summary = new MutableSummary(TaxaSheet);
|
var summary = new MutableSummary(TaxaSheet);
|
||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
var name = Required(row, "Name", context);
|
context.Issues.Add(new CatalogImportIssue(
|
||||||
var genus = Required(row, "Genus", context);
|
TaxaSheet,
|
||||||
var species = Required(row, "Species", context);
|
row.Row.RowNumber(),
|
||||||
if (name is null || genus is null || species is null)
|
"Taxa",
|
||||||
{
|
"Taxa must be imported from GBIF search results, not spreadsheet rows.",
|
||||||
summary.Skips++;
|
"error"));
|
||||||
continue;
|
summary.Skips++;
|
||||||
}
|
|
||||||
|
|
||||||
var key = Key(name);
|
|
||||||
if (!seen.Add(key))
|
|
||||||
{
|
|
||||||
Duplicate(row, "Name", context);
|
|
||||||
summary.Skips++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var cultivar = Optional(row, "Cultivar");
|
|
||||||
var variety = Optional(row, "Variety");
|
|
||||||
var authority = Optional(row, "Authority");
|
|
||||||
var family = Optional(row, "Family");
|
|
||||||
var commonName = Optional(row, "Common Name", "CommonName");
|
|
||||||
var externalSource = Optional(row, "External Source", "Source");
|
|
||||||
var externalId = Optional(row, "External ID", "External Id", "GBIF ID");
|
|
||||||
|
|
||||||
if (byName.TryGetValue(key, out var taxon))
|
|
||||||
{
|
|
||||||
summary.Updates++;
|
|
||||||
if (apply)
|
|
||||||
{
|
|
||||||
taxon.Name = name;
|
|
||||||
taxon.Genus = genus;
|
|
||||||
taxon.Species = species;
|
|
||||||
taxon.Cultivar = cultivar;
|
|
||||||
taxon.Variety = variety;
|
|
||||||
taxon.Authority = authority;
|
|
||||||
taxon.Family = family;
|
|
||||||
taxon.CommonName = commonName;
|
|
||||||
taxon.ExternalSource = externalSource;
|
|
||||||
taxon.ExternalId = externalId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
summary.Creates++;
|
|
||||||
if (apply)
|
|
||||||
{
|
|
||||||
db.PlantTaxa.Add(new PlantTaxon
|
|
||||||
{
|
|
||||||
Name = name,
|
|
||||||
Genus = genus,
|
|
||||||
Species = species,
|
|
||||||
Cultivar = cultivar,
|
|
||||||
Variety = variety,
|
|
||||||
Authority = authority,
|
|
||||||
Family = family,
|
|
||||||
CommonName = commonName,
|
|
||||||
ExternalSource = externalSource,
|
|
||||||
ExternalId = externalId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AddSummary(context, summary);
|
AddSummary(context, summary);
|
||||||
|
|||||||
@@ -8,107 +8,170 @@ namespace plant_manager.Endpoints
|
|||||||
{
|
{
|
||||||
public static void MapPlantCareScheduleEndpoints(this WebApplication app)
|
public static void MapPlantCareScheduleEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/plant-care-schedules/bulk", async (
|
app.MapGet("/api/plant-care-schedules", async (ApplicationDbContext db) =>
|
||||||
BulkSavePlantCareScheduleRequest request,
|
|
||||||
ApplicationDbContext db) =>
|
|
||||||
{
|
{
|
||||||
var plantIds = request.PlantIds
|
|
||||||
.Where(id => id > 0)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
if (plantIds.Count == 0)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var activity = await db.CareActivities
|
|
||||||
.Include(item => item.Actions)
|
|
||||||
.ThenInclude(action => action.CareAction)
|
|
||||||
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
|
|
||||||
if (activity is null)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "Care activity was not found." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var primaryAction = activity.PrimaryAction();
|
|
||||||
if (primaryAction is null)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "Care activity has no configured actions." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var plants = await db.Plants
|
|
||||||
.Include(plant => plant.CareSchedules)
|
|
||||||
.Where(plant => plantIds.Contains(plant.Id))
|
|
||||||
.ToListAsync();
|
|
||||||
if (plants.Count != plantIds.Count)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "One or more plants were not found." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var recurrence = NormalizeRecurrence(
|
|
||||||
request.RecurrenceMode,
|
|
||||||
request.RepeatEvery,
|
|
||||||
request.RepeatUnit,
|
|
||||||
request.RepeatOnDays,
|
|
||||||
request.EndsMode,
|
|
||||||
request.EndsOn,
|
|
||||||
request.EndsAfterOccurrences,
|
|
||||||
request.ScheduledFor,
|
|
||||||
request.EveryDays);
|
|
||||||
foreach (var plant in plants)
|
|
||||||
{
|
|
||||||
var schedule = plant.CareSchedules
|
|
||||||
.FirstOrDefault(item => item.CareActivityId == activity.Id);
|
|
||||||
if (schedule is null)
|
|
||||||
{
|
|
||||||
schedule = new PlantCareSchedule
|
|
||||||
{
|
|
||||||
PlantId = plant.Id,
|
|
||||||
CareActionId = primaryAction.Id,
|
|
||||||
CareActivityId = activity.Id
|
|
||||||
};
|
|
||||||
plant.CareSchedules.Add(schedule);
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.CareActionId = primaryAction.Id;
|
|
||||||
schedule.CareActivityId = activity.Id;
|
|
||||||
ApplyRecurrence(schedule, recurrence);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Results.Ok(new { updated = plants.Count });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/api/plant-care-schedules/bulk-remove", async (
|
|
||||||
BulkSavePlantCareScheduleRequest request,
|
|
||||||
ApplicationDbContext db) =>
|
|
||||||
{
|
|
||||||
var plantIds = request.PlantIds
|
|
||||||
.Where(id => id > 0)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
if (plantIds.Count == 0)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "At least one plant is required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.CareActivityId <= 0)
|
|
||||||
{
|
|
||||||
return Results.BadRequest(new { error = "Care activity is required." });
|
|
||||||
}
|
|
||||||
|
|
||||||
var schedules = await db.PlantCareSchedules
|
var schedules = await db.PlantCareSchedules
|
||||||
.Where(schedule =>
|
.Include(schedule => schedule.CareActivity)
|
||||||
schedule.CareActivityId == request.CareActivityId
|
.Include(schedule => schedule.CareAction)
|
||||||
&& plantIds.Contains(schedule.PlantId))
|
.Include(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.OrderBy(schedule => schedule.CareActivity.Name)
|
||||||
|
.ThenBy(schedule => schedule.Id)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
db.PlantCareSchedules.RemoveRange(schedules);
|
return Results.Ok(schedules.Select(PlantCareScheduleRuleDto.FromSchedule));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/plant-care-schedules", async (
|
||||||
|
BulkSavePlantCareScheduleRequest request,
|
||||||
|
ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
var context = await ValidateScheduleRequest(request, db);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = context.Error });
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule = new PlantCareSchedule
|
||||||
|
{
|
||||||
|
CareActivityId = context.Activity!.Id,
|
||||||
|
CareActionId = context.PrimaryAction!.Id,
|
||||||
|
CareActivity = context.Activity,
|
||||||
|
CareAction = context.PrimaryAction
|
||||||
|
};
|
||||||
|
ApplyRecurrence(schedule, context.Recurrence!);
|
||||||
|
schedule.Assignments = context.PlantIds
|
||||||
|
.Select(plantId => new PlantCareScheduleAssignment { PlantId = plantId })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
db.PlantCareSchedules.Add(schedule);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new { removed = schedules.Count });
|
var saved = await LoadSchedule(schedule.Id, db);
|
||||||
|
return Results.Created($"/api/plant-care-schedules/{schedule.Id}", PlantCareScheduleRuleDto.FromSchedule(saved!));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.MapPut("/api/plant-care-schedules/{id:int}", async (
|
||||||
|
int id,
|
||||||
|
BulkSavePlantCareScheduleRequest request,
|
||||||
|
ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
var schedule = await db.PlantCareSchedules
|
||||||
|
.Include(item => item.Assignments)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == id);
|
||||||
|
if (schedule is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = await ValidateScheduleRequest(request, db);
|
||||||
|
if (context.Error is not null)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = context.Error });
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule.CareActivityId = context.Activity!.Id;
|
||||||
|
schedule.CareActionId = context.PrimaryAction!.Id;
|
||||||
|
ApplyRecurrence(schedule, context.Recurrence!);
|
||||||
|
|
||||||
|
var nextPlantIds = context.PlantIds.ToHashSet();
|
||||||
|
var assignmentsToRemove = schedule.Assignments
|
||||||
|
.Where(assignment => !nextPlantIds.Contains(assignment.PlantId))
|
||||||
|
.ToList();
|
||||||
|
db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
|
||||||
|
|
||||||
|
var existingPlantIds = schedule.Assignments
|
||||||
|
.Select(assignment => assignment.PlantId)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var plantId in context.PlantIds.Where(plantId => !existingPlantIds.Contains(plantId)))
|
||||||
|
{
|
||||||
|
schedule.Assignments.Add(new PlantCareScheduleAssignment
|
||||||
|
{
|
||||||
|
PlantCareScheduleId = schedule.Id,
|
||||||
|
PlantId = plantId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var saved = await LoadSchedule(schedule.Id, db);
|
||||||
|
return Results.Ok(PlantCareScheduleRuleDto.FromSchedule(saved!));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapDelete("/api/plant-care-schedules/{id:int}", async (int id, ApplicationDbContext db) =>
|
||||||
|
{
|
||||||
|
var schedule = await db.PlantCareSchedules.FindAsync(id);
|
||||||
|
if (schedule is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PlantCareSchedules.Remove(schedule);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PlantCareSchedule?> LoadSchedule(int id, ApplicationDbContext db) =>
|
||||||
|
await db.PlantCareSchedules
|
||||||
|
.Include(schedule => schedule.CareActivity)
|
||||||
|
.Include(schedule => schedule.CareAction)
|
||||||
|
.Include(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.FirstOrDefaultAsync(schedule => schedule.Id == id);
|
||||||
|
|
||||||
|
private static async Task<ValidatedScheduleRequest> ValidateScheduleRequest(
|
||||||
|
BulkSavePlantCareScheduleRequest request,
|
||||||
|
ApplicationDbContext db)
|
||||||
|
{
|
||||||
|
var plantIds = request.PlantIds
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (plantIds.Count == 0)
|
||||||
|
{
|
||||||
|
return ValidatedScheduleRequest.Invalid("At least one plant is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity = await db.CareActivities
|
||||||
|
.Include(item => item.Actions)
|
||||||
|
.ThenInclude(action => action.CareAction)
|
||||||
|
.FirstOrDefaultAsync(item => item.Id == request.CareActivityId);
|
||||||
|
if (activity is null)
|
||||||
|
{
|
||||||
|
return ValidatedScheduleRequest.Invalid("Care activity was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var primaryAction = activity.PrimaryAction();
|
||||||
|
if (primaryAction is null)
|
||||||
|
{
|
||||||
|
return ValidatedScheduleRequest.Invalid("Care activity has no configured actions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPlantIds = await db.Plants
|
||||||
|
.Where(plant => plantIds.Contains(plant.Id))
|
||||||
|
.Select(plant => plant.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (existingPlantIds.Count != plantIds.Count)
|
||||||
|
{
|
||||||
|
return ValidatedScheduleRequest.Invalid("One or more plants were not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recurrence = NormalizeRecurrence(
|
||||||
|
request.RecurrenceMode,
|
||||||
|
request.RepeatEvery,
|
||||||
|
request.RepeatUnit,
|
||||||
|
request.RepeatOnDays,
|
||||||
|
request.EndsMode,
|
||||||
|
request.EndsOn,
|
||||||
|
request.EndsAfterOccurrences,
|
||||||
|
request.ScheduledFor,
|
||||||
|
request.EveryDays);
|
||||||
|
|
||||||
|
return ValidatedScheduleRequest.Valid(plantIds, activity, primaryAction, recurrence);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ScheduleRecurrence NormalizeRecurrence(
|
internal static ScheduleRecurrence NormalizeRecurrence(
|
||||||
@@ -139,7 +202,7 @@ namespace plant_manager.Endpoints
|
|||||||
};
|
};
|
||||||
var normalizedEndsMode = mode == "none"
|
var normalizedEndsMode = mode == "none"
|
||||||
? "after"
|
? "after"
|
||||||
: NormalizeOption(endsMode, new HashSet<string> { "on", "after" }, "after");
|
: NormalizeOption(endsMode, new HashSet<string> { "never", "on", "after" }, "never");
|
||||||
var normalizedEveryDays = mode switch
|
var normalizedEveryDays = mode switch
|
||||||
{
|
{
|
||||||
"none" => Math.Clamp(everyDays ?? 7, 1, 365),
|
"none" => Math.Clamp(everyDays ?? 7, 1, 365),
|
||||||
@@ -199,5 +262,23 @@ namespace plant_manager.Endpoints
|
|||||||
string EndsMode,
|
string EndsMode,
|
||||||
DateOnly? EndsOn,
|
DateOnly? EndsOn,
|
||||||
int? EndsAfterOccurrences);
|
int? EndsAfterOccurrences);
|
||||||
|
|
||||||
|
private sealed record ValidatedScheduleRequest(
|
||||||
|
IReadOnlyList<int> PlantIds,
|
||||||
|
CareActivity? Activity,
|
||||||
|
CareAction? PrimaryAction,
|
||||||
|
ScheduleRecurrence? Recurrence,
|
||||||
|
string? Error)
|
||||||
|
{
|
||||||
|
public static ValidatedScheduleRequest Invalid(string error) =>
|
||||||
|
new([], null, null, null, error);
|
||||||
|
|
||||||
|
public static ValidatedScheduleRequest Valid(
|
||||||
|
IReadOnlyList<int> plantIds,
|
||||||
|
CareActivity activity,
|
||||||
|
CareAction primaryAction,
|
||||||
|
ScheduleRecurrence recurrence) =>
|
||||||
|
new(plantIds, activity, primaryAction, recurrence, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,34 @@ namespace plant_manager.Endpoints
|
|||||||
var plants = await db.Plants
|
var plants = await db.Plants
|
||||||
.Include(plant => plant.Taxon)
|
.Include(plant => plant.Taxon)
|
||||||
.Include(plant => plant.Location)
|
.Include(plant => plant.Location)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareAction)
|
.ThenInclude(schedule => schedule.CareAction)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.CareAction)
|
.ThenInclude(action => action.CareAction)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.ActionLogs)
|
||||||
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.CareDismissals)
|
||||||
.Include(plant => plant.ActionLogs)
|
.Include(plant => plant.ActionLogs)
|
||||||
.ThenInclude(log => log.Resources)
|
.ThenInclude(log => log.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(plant => plant.CareDismissals)
|
||||||
.Include(plant => plant.Flags)
|
.Include(plant => plant.Flags)
|
||||||
.ThenInclude(flag => flag.Definition)
|
.ThenInclude(flag => flag.Definition)
|
||||||
.Include(plant => plant.GroupMemberships)
|
.Include(plant => plant.GroupMemberships)
|
||||||
@@ -42,20 +56,34 @@ namespace plant_manager.Endpoints
|
|||||||
var plant = await db.Plants
|
var plant = await db.Plants
|
||||||
.Include(item => item.Taxon)
|
.Include(item => item.Taxon)
|
||||||
.Include(item => item.Location)
|
.Include(item => item.Location)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareAction)
|
.ThenInclude(schedule => schedule.CareAction)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.CareAction)
|
.ThenInclude(action => action.CareAction)
|
||||||
.Include(plant => plant.CareSchedules)
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.ActionLogs)
|
||||||
|
.Include(plant => plant.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.CareDismissals)
|
||||||
.Include(plant => plant.ActionLogs)
|
.Include(plant => plant.ActionLogs)
|
||||||
.ThenInclude(log => log.Resources)
|
.ThenInclude(log => log.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(plant => plant.CareDismissals)
|
||||||
.Include(plant => plant.Flags)
|
.Include(plant => plant.Flags)
|
||||||
.ThenInclude(flag => flag.Definition)
|
.ThenInclude(flag => flag.Definition)
|
||||||
.Include(plant => plant.GroupMemberships)
|
.Include(plant => plant.GroupMemberships)
|
||||||
@@ -79,6 +107,10 @@ namespace plant_manager.Endpoints
|
|||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Taxon was not found." });
|
return Results.BadRequest(new { error = "Taxon was not found." });
|
||||||
}
|
}
|
||||||
|
if (taxon is not null && !IsGbifTaxon(taxon))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
|
||||||
|
}
|
||||||
|
|
||||||
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
||||||
if (request.LocationId is not null && location is null)
|
if (request.LocationId is not null && location is null)
|
||||||
@@ -122,20 +154,34 @@ namespace plant_manager.Endpoints
|
|||||||
var plant = await db.Plants
|
var plant = await db.Plants
|
||||||
.Include(item => item.Taxon)
|
.Include(item => item.Taxon)
|
||||||
.Include(item => item.Location)
|
.Include(item => item.Location)
|
||||||
.Include(item => item.CareSchedules)
|
.Include(item => item.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareAction)
|
.ThenInclude(schedule => schedule.CareAction)
|
||||||
.Include(item => item.CareSchedules)
|
.Include(item => item.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.CareAction)
|
.ThenInclude(action => action.CareAction)
|
||||||
.Include(item => item.CareSchedules)
|
.Include(item => item.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
.ThenInclude(schedule => schedule.CareActivity)
|
.ThenInclude(schedule => schedule.CareActivity)
|
||||||
.ThenInclude(activity => activity.Actions)
|
.ThenInclude(activity => activity.Actions)
|
||||||
.ThenInclude(action => action.Resources)
|
.ThenInclude(action => action.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(item => item.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.ActionLogs)
|
||||||
|
.Include(item => item.CareScheduleAssignments)
|
||||||
|
.ThenInclude(assignment => assignment.PlantCareSchedule)
|
||||||
|
.ThenInclude(schedule => schedule.Assignments)
|
||||||
|
.ThenInclude(assignment => assignment.Plant)
|
||||||
|
.ThenInclude(plant => plant.CareDismissals)
|
||||||
.Include(item => item.ActionLogs)
|
.Include(item => item.ActionLogs)
|
||||||
.ThenInclude(log => log.Resources)
|
.ThenInclude(log => log.Resources)
|
||||||
.ThenInclude(resource => resource.ActionResource)
|
.ThenInclude(resource => resource.ActionResource)
|
||||||
|
.Include(item => item.CareDismissals)
|
||||||
.Include(item => item.Flags)
|
.Include(item => item.Flags)
|
||||||
.ThenInclude(flag => flag.Definition)
|
.ThenInclude(flag => flag.Definition)
|
||||||
.Include(item => item.GroupMemberships)
|
.Include(item => item.GroupMemberships)
|
||||||
@@ -152,6 +198,10 @@ namespace plant_manager.Endpoints
|
|||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "Taxon was not found." });
|
return Results.BadRequest(new { error = "Taxon was not found." });
|
||||||
}
|
}
|
||||||
|
if (taxon is not null && !IsGbifTaxon(taxon))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { error = "Taxon must be imported from GBIF." });
|
||||||
|
}
|
||||||
|
|
||||||
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
|
||||||
if (request.LocationId is not null && location is null)
|
if (request.LocationId is not null && location is null)
|
||||||
@@ -212,6 +262,7 @@ namespace plant_manager.Endpoints
|
|||||||
[
|
[
|
||||||
new SavePlantCareScheduleRequest(
|
new SavePlantCareScheduleRequest(
|
||||||
waterActivity.Id,
|
waterActivity.Id,
|
||||||
|
null,
|
||||||
7,
|
7,
|
||||||
null,
|
null,
|
||||||
"weekly",
|
"weekly",
|
||||||
@@ -253,28 +304,40 @@ namespace plant_manager.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var requestedActivityIds = activityIds.ToHashSet();
|
var requestedActivityIds = activityIds.ToHashSet();
|
||||||
var schedulesToRemove = plant.CareSchedules
|
var assignmentsToRemove = plant.CareScheduleAssignments
|
||||||
.Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId))
|
.Where(assignment => !requestedActivityIds.Contains(assignment.PlantCareSchedule.CareActivityId))
|
||||||
.ToList();
|
.ToList();
|
||||||
db.PlantCareSchedules.RemoveRange(schedulesToRemove);
|
db.PlantCareScheduleAssignments.RemoveRange(assignmentsToRemove);
|
||||||
|
|
||||||
foreach (var requestedSchedule in normalizedSchedules)
|
foreach (var requestedSchedule in normalizedSchedules)
|
||||||
{
|
{
|
||||||
var activity = activitiesById[requestedSchedule.CareActivityId];
|
var activity = activitiesById[requestedSchedule.CareActivityId];
|
||||||
var primaryAction = activity.PrimaryAction()!;
|
var primaryAction = activity.PrimaryAction()!;
|
||||||
var schedule = plant.CareSchedules
|
var assignment = plant.CareScheduleAssignments
|
||||||
.FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId);
|
.FirstOrDefault(item => item.PlantCareSchedule.CareActivityId == requestedSchedule.CareActivityId);
|
||||||
if (schedule is null)
|
var schedule = assignment?.PlantCareSchedule;
|
||||||
|
if (assignment is null)
|
||||||
{
|
{
|
||||||
schedule = new PlantCareSchedule
|
schedule = new PlantCareSchedule
|
||||||
{
|
{
|
||||||
PlantId = plant.Id,
|
|
||||||
CareActionId = primaryAction.Id,
|
CareActionId = primaryAction.Id,
|
||||||
CareActivityId = activity.Id,
|
CareActivityId = activity.Id,
|
||||||
CareAction = primaryAction,
|
CareAction = primaryAction,
|
||||||
CareActivity = activity
|
CareActivity = activity,
|
||||||
|
Assignments =
|
||||||
|
[
|
||||||
|
new PlantCareScheduleAssignment
|
||||||
|
{
|
||||||
|
PlantId = plant.Id,
|
||||||
|
Plant = plant
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
plant.CareSchedules.Add(schedule);
|
db.PlantCareSchedules.Add(schedule);
|
||||||
|
}
|
||||||
|
if (schedule is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
schedule.CareActionId = primaryAction.Id;
|
schedule.CareActionId = primaryAction.Id;
|
||||||
@@ -295,5 +358,9 @@ namespace plant_manager.Endpoints
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsGbifTaxon(PlantTaxon taxon) =>
|
||||||
|
string.Equals(taxon.ExternalSource, "gbif", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(taxon.ExternalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,64 +18,6 @@ namespace plant_manager.Endpoints
|
|||||||
return Results.Ok(taxa);
|
return Results.Ok(taxa);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapPost("/api/plant-taxa", async (SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
|
||||||
{
|
|
||||||
var validation = ValidateTaxonRequest(request);
|
|
||||||
if (validation.Error is not null)
|
|
||||||
{
|
|
||||||
return EndpointHelpers.BadRequest(validation.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
var taxon = new PlantTaxon
|
|
||||||
{
|
|
||||||
Name = validation.Name,
|
|
||||||
Genus = validation.Genus,
|
|
||||||
Species = validation.Species,
|
|
||||||
Cultivar = validation.Cultivar,
|
|
||||||
Variety = validation.Variety,
|
|
||||||
Authority = validation.Authority,
|
|
||||||
Family = validation.Family,
|
|
||||||
CommonName = validation.CommonName,
|
|
||||||
ExternalSource = validation.ExternalSource,
|
|
||||||
ExternalId = validation.ExternalId
|
|
||||||
};
|
|
||||||
|
|
||||||
db.PlantTaxa.Add(taxon);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Results.Created($"/api/plant-taxa/{taxon.Id}", PlantTaxonDto.FromTaxon(taxon));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPut("/api/plant-taxa/{id:int}", async (int id, SavePlantTaxonRequest request, ApplicationDbContext db) =>
|
|
||||||
{
|
|
||||||
var validation = ValidateTaxonRequest(request);
|
|
||||||
if (validation.Error is not null)
|
|
||||||
{
|
|
||||||
return EndpointHelpers.BadRequest(validation.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
var taxon = await db.PlantTaxa.FindAsync(id);
|
|
||||||
if (taxon is null)
|
|
||||||
{
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
taxon.Name = validation.Name;
|
|
||||||
taxon.Genus = validation.Genus;
|
|
||||||
taxon.Species = validation.Species;
|
|
||||||
taxon.Cultivar = validation.Cultivar;
|
|
||||||
taxon.Variety = validation.Variety;
|
|
||||||
taxon.Authority = validation.Authority;
|
|
||||||
taxon.Family = validation.Family;
|
|
||||||
taxon.CommonName = validation.CommonName;
|
|
||||||
taxon.ExternalSource = validation.ExternalSource;
|
|
||||||
taxon.ExternalId = validation.ExternalId;
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Results.Ok(PlantTaxonDto.FromTaxon(taxon));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) =>
|
app.MapPost("/api/plant-taxa/import", async (ImportPlantTaxonRequest request, ApplicationDbContext db) =>
|
||||||
{
|
{
|
||||||
if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(request.Source, "gbif", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -151,40 +93,6 @@ namespace plant_manager.Endpoints
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (
|
|
||||||
string Name,
|
|
||||||
string Genus,
|
|
||||||
string Species,
|
|
||||||
string? Cultivar,
|
|
||||||
string? Variety,
|
|
||||||
string? Authority,
|
|
||||||
string? Family,
|
|
||||||
string? CommonName,
|
|
||||||
string? ExternalSource,
|
|
||||||
string? ExternalId,
|
|
||||||
string? Error) ValidateTaxonRequest(SavePlantTaxonRequest request)
|
|
||||||
{
|
|
||||||
if (!EndpointHelpers.TryNormalizeRequired(request.Name, "Name, genus, and species are required.", out var name, out _)
|
|
||||||
|| !EndpointHelpers.TryNormalizeRequired(request.Genus, "Name, genus, and species are required.", out var genus, out _)
|
|
||||||
|| !EndpointHelpers.TryNormalizeRequired(request.Species, "Name, genus, and species are required.", out var species, out _))
|
|
||||||
{
|
|
||||||
return (string.Empty, string.Empty, string.Empty, null, null, null, null, null, null, null, "Name, genus, and species are required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
name,
|
|
||||||
genus,
|
|
||||||
species,
|
|
||||||
EndpointHelpers.NormalizeOptional(request.Cultivar),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.Variety),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.Authority),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.Family),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.CommonName),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.ExternalSource),
|
|
||||||
EndpointHelpers.NormalizeOptional(request.ExternalId),
|
|
||||||
null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? FirstScientificNamePart(string scientificName) =>
|
private static string? FirstScientificNamePart(string scientificName) =>
|
||||||
scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
scientificName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
await db.SeedDevelopmentDataAsync();
|
||||||
|
|
||||||
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
|
var plantInfoDb = scope.ServiceProvider.GetRequiredService<PlantInfoDbContext>();
|
||||||
await plantInfoDb.EnsureSearchSchemaAsync();
|
await plantInfoDb.EnsureSearchSchemaAsync();
|
||||||
|
|||||||
Reference in New Issue
Block a user