refactor plant concepts and care scheduling

This commit is contained in:
2026-05-20 19:06:40 +00:00
parent 94d91b08e6
commit eb5471ca27
43 changed files with 4131 additions and 1387 deletions
+107 -45
View File
@@ -1,41 +1,110 @@
# Plant-Man Minimal Schema
# Plant-Man Schema
This is the current working schema for the fresh React + Minimal API baseline.
Current working schema for the React + Minimal API app.
## `PlantTaxon`
## `Plant`
Reference data for a plant taxon. This may represent a species, cultivar, variety, hybrid, or other useful identification level.
A plant object tracked by the user.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | Common name |
| `taxon_id` | `int` | No | Optional foreign key to `PlantTaxon` |
| `location_id` | `int` | No | Optional foreign key to `PlantLocation` |
| `nickname` | `string` | Yes | User-facing plant name |
## `PlantTaxon`
Reference data for plant identity.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | Common or display name |
| `genus` | `string` | Yes | Botanical genus |
| `species` | `string` | Yes | Botanical species |
| `cultivar` | `string` | No | Optional cultivar |
| `variety` | `string` | No | Optional variety |
| `authority` | `string` | No | Optional authority |
## `Plant`
## `PlantLocation`
A plant owned or tracked by the user.
Reference data for where plants live.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `taxon_id` | `int` | Yes | Foreign key to `PlantTaxon` |
| `nickname` | `string` | Yes | User-facing plant name |
| `location` | `string` | Yes | Where the plant lives |
| `name` | `string` | Yes | User-facing location name |
| `notes` | `string` | No | Optional details |
| `is_enabled` | `bool` | Yes | Whether the location is available for new assignments |
## `CareAction`
A reusable care verb, such as water, prune, fertilize, inspect, or repot.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | User-facing action name |
| `description` | `string` | No | Optional explanation |
| `is_enabled` | `bool` | Yes | Whether the action is available for use |
## `ActionResource`
A resource used while performing care.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | Resource name |
| `category` | `string` | No | Optional grouping, such as `Fertilizer`, `Medium`, `Treatment`, `Equipment`, or `Container` |
| `notes` | `string` | No | Optional details |
| `is_enabled` | `bool` | Yes | Whether the resource is available for use |
## `CareActivity`
A configurable care activity made from one or more care actions.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | User-facing activity name |
| `notes` | `string` | No | Optional details |
| `is_enabled` | `bool` | Yes | Whether the activity is available for schedules and logs |
## `CareActivityAction`
Join data connecting a care activity to each included care action.
| Field | Type | Required | Notes |
| - | - | - | - |
| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` |
| `care_action_id` | `int` | Yes | Foreign key to `CareAction` |
| `sort_order` | `int` | Yes | Stable display order for the activity builder |
## `CareActivityActionResource`
Join data connecting an activity action to the resources it uses.
| Field | Type | Required | Notes |
| - | - | - | - |
| `care_activity_id` | `int` | Yes | Part of the foreign key to `CareActivityAction` |
| `care_action_id` | `int` | Yes | Part of the foreign key to `CareActivityAction` |
| `action_resource_id` | `int` | Yes | Foreign key to `ActionResource` |
| `quantity` | `decimal` | No | Optional amount |
| `unit` | `string` | No | Optional unit |
| `notes` | `string` | No | Optional resource-specific instructions |
## `PlantCareSchedule`
A per-plant recurring schedule for one care action.
A per-plant recurring schedule for one care activity. Scheduler is the UI owner for these assignments.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `plant_id` | `int` | Yes | Foreign key to `Plant` |
| `care_action_id` | `int` | Yes | Foreign key to `CareAction` |
| `care_action_id` | `int` | Yes | Snapshot/compatibility foreign key to the primary care action |
| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` |
| `every_days` | `int` | Yes | Recurrence interval |
| `is_enabled` | `bool` | Yes | Whether this schedule contributes to care tasks |
@@ -47,50 +116,43 @@ A record of care performed for a plant.
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `plant_id` | `int` | Yes | Foreign key to `Plant` |
| `care_action_id` | `int` | Yes | Foreign key to `CareAction` |
| `action_name_snapshot` | `string` | Yes | Historical display name, such as `Water`, preserved if the action is renamed |
| `care_action_id` | `int` | Yes | Snapshot/compatibility foreign key to the primary care action |
| `care_activity_id` | `int` | Yes | Foreign key to `CareActivity` |
| `action_name_snapshot` | `string` | Yes | Historical display name |
| `notes` | `string` | No | Optional observation |
| `performed_on` | `date` | Yes | Date care was completed |
## `CareAction`
A configurable type of care the user can log for a plant, such as watering, repotting, fertilizing, pruning, or inspection.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | User-facing action name |
| `description` | `string` | No | Optional explanation of the action |
| `is_enabled` | `bool` | Yes | Whether the action is available for use |
## `ActionResource`
A resource used while performing an action. This can be a consumable, material, product, piece of equipment, container, light, or other item relevant to the action.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | Resource name |
| `category` | `string` | No | Optional grouping, such as `Fertilizer`, `Medium`, `Treatment`, `Equipment`, or `Container` |
| `notes` | `string` | No | Optional details |
| `is_enabled` | `bool` | Yes | Whether the resource is available for use |
## `ActionLogResource`
Join data connecting an action log to the resources used during that action.
Join data connecting an action log to the resources used.
| Field | Type | Required | Notes |
| - | - | - | - |
| `action_log_id` | `int` | Yes | Foreign key to `ActionLog` |
| `action_resource_id` | `int` | Yes | Foreign key to `ActionResource` |
| `quantity` | `decimal` | No | Optional amount used |
| `unit` | `string` | No | Optional unit, such as `ml`, `tbsp`, or `g` |
| `unit` | `string` | No | Optional unit |
## Deferred Ideas
## `PlantFlagDefinition`
These concepts are still promising, but they are intentionally out of the first working baseline:
A reusable plant flag.
- user accounts
- groups or rooms
- grow media and blends
- richer taxon profiles
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `name` | `string` | Yes | Flag name |
| `color` | `string` | Yes | Display color |
| `is_enabled` | `bool` | Yes | Whether the flag can be assigned |
## `PlantFlag`
An assigned flag on a plant.
| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | `int` | Yes | Primary key |
| `plant_id` | `int` | Yes | Foreign key to `Plant` |
| `plant_flag_definition_id` | `int` | Yes | Foreign key to `PlantFlagDefinition` |
| `started_on` | `date` | Yes | Date the flag became active |
| `resolved_on` | `date` | No | Date the flag was resolved |
| `notes` | `string` | No | Optional details |
File diff suppressed because it is too large Load Diff
+60 -21
View File
@@ -1,17 +1,21 @@
import type {
ActionResource,
ActionResourcePayload,
ActionLog,
CareActivity,
CareActivityPayload,
CareAction,
CareActionPayload,
CareLogPayload,
BulkCompleteCareTasksPayload,
CareTask,
BulkPlantCareSchedulePayload,
Plant,
AssignPlantFlagPayload,
PlantPayload,
PlantFlag,
PlantFlagDefinition,
PlantFlagDefinitionPayload,
PlantLocation,
PlantLocationPayload,
PlantTaxon,
PlantTaxonPayload,
} from './domain';
@@ -46,6 +50,30 @@ export async function getPlantTaxa() {
return request<PlantTaxon[]>('/api/plant-taxa');
}
export async function getPlantLocations() {
return request<PlantLocation[]>('/api/plant-locations');
}
export async function createPlantLocation(payload: PlantLocationPayload) {
return request<PlantLocation>('/api/plant-locations', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updatePlantLocation(id: number, payload: PlantLocationPayload) {
return request<PlantLocation>(`/api/plant-locations/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deletePlantLocation(id: number) {
return request<void>(`/api/plant-locations/${id}`, {
method: 'DELETE',
});
}
export async function createPlantTaxon(payload: PlantTaxonPayload) {
return request<PlantTaxon>('/api/plant-taxa', {
method: 'POST',
@@ -114,6 +142,30 @@ export async function deleteActionResource(id: number) {
});
}
export async function getCareActivities() {
return request<CareActivity[]>('/api/care-activities');
}
export async function createCareActivity(payload: CareActivityPayload) {
return request<CareActivity>('/api/care-activities', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateCareActivity(id: number, payload: CareActivityPayload) {
return request<CareActivity>(`/api/care-activities/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteCareActivity(id: number) {
return request<void>(`/api/care-activities/${id}`, {
method: 'DELETE',
});
}
export async function createPlant(payload: PlantPayload) {
return request<Plant>('/api/plants', {
method: 'POST',
@@ -181,29 +233,16 @@ export async function getCareTasks() {
return request<CareTask[]>('/api/care-tasks/upcoming');
}
export async function getActionLogs() {
return request<ActionLog[]>('/api/action-logs');
}
export async function logCare(payload: CareLogPayload) {
return request('/api/action-logs', {
export async function savePlantCareSchedulesBulk(payload: BulkPlantCareSchedulePayload) {
return request<{ updated: number }>('/api/plant-care-schedules/bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateActionLog(id: number, payload: CareLogPayload) {
return request<ActionLog>(`/api/action-logs/${id}`, {
method: 'PUT',
body: JSON.stringify({
...payload,
performedOn: payload.performedOn ?? new Date().toISOString().slice(0, 10),
}),
});
}
export async function deleteActionLog(id: number) {
return request<void>(`/api/action-logs/${id}`, {
method: 'DELETE',
export async function completeCareTasksBulk(payload: BulkCompleteCareTasksPayload) {
return request<{ completed: number }>('/api/care-tasks/complete-bulk', {
method: 'POST',
body: JSON.stringify(payload),
});
}
@@ -1,4 +1,4 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { CareAction } from '../domain';
import type { ActionFormState } from '../form-state';
@@ -10,11 +10,14 @@ type ActionsViewProps = {
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
selectedAction?: CareAction;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (action: CareAction) => void;
onEdit: (action: CareAction) => void;
onFieldChange: (field: keyof ActionFormState, value: string | boolean) => void;
onNew: () => void;
onOpenDetail: (action: CareAction) => void;
onSave: () => void;
};
@@ -26,11 +29,14 @@ export function ActionsView({
isEditorOpen,
isLoading,
isSaving,
selectedAction,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: ActionsViewProps) {
const enabledCount = actions.filter((action) => action.isEnabled).length;
@@ -51,6 +57,35 @@ export function ActionsView({
</button>
</section>
{selectedAction && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="action-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Action detail</p>
<h2 id="action-detail-heading">{selectedAction.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedAction.name}`} onClick={() => onEdit(selectedAction)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close action detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Description</span>
<strong>{selectedAction.description ?? 'No description'}</strong>
</div>
<div>
<span>Status</span>
<strong>{selectedAction.isEnabled ? 'Enabled' : 'Disabled'}</strong>
</div>
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="action-editor-heading">
<div className="section-heading">
@@ -121,6 +156,9 @@ export function ActionsView({
</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${action.name}`} onClick={() => onOpenDetail(action)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${action.name}`} onClick={() => onEdit(action)}>
<Edit3 size={17} />
</button>
@@ -0,0 +1,428 @@
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { ActionResource, CareAction, CareActivity } from '../domain';
import type { ActivityFormState } from '../form-state';
type ActivitiesViewProps = {
actions: CareAction[];
activeActivityName?: string;
activities: CareActivity[];
error: string | null;
form: ActivityFormState;
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
selectedActivity?: CareActivity;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (activity: CareActivity) => void;
onEdit: (activity: CareActivity) => void;
onFieldChange: <Field extends keyof ActivityFormState>(
field: Field,
value: ActivityFormState[Field],
) => void;
onNew: () => void;
onOpenDetail: (activity: CareActivity) => void;
onSave: () => void;
resources: ActionResource[];
};
export function ActivitiesView({
actions,
activeActivityName,
activities,
error,
form,
isEditorOpen,
isLoading,
isSaving,
selectedActivity,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
resources,
}: ActivitiesViewProps) {
const enabledCount = activities.filter((activity) => activity.isEnabled).length;
const enabledActions = actions.filter((action) => action.isEnabled);
const enabledResources = resources.filter((resource) => resource.isEnabled);
function updateAction(index: number, nextAction: ActivityFormState['actions'][number]) {
onFieldChange(
'actions',
form.actions.map((action, actionIndex) => actionIndex === index ? nextAction : action),
);
}
function addAction() {
onFieldChange('actions', [...form.actions, { careActionId: '', resources: [] }]);
}
function removeAction(index: number) {
onFieldChange('actions', form.actions.filter((_, actionIndex) => actionIndex !== index));
}
function hasIncompleteActions() {
return form.actions.some((action) =>
!action.careActionId || action.resources.some((resource) => !resource.actionResourceId)
);
}
return (
<>
<section className="summary-panel" aria-labelledby="activities-summary-heading">
<div>
<p className="eyebrow">Care activities</p>
<h2 id="activities-summary-heading">
{isLoading ? 'Loading activities' : `${enabledCount} activities enabled`}
</h2>
<p>{error ?? 'Configure reusable care bundles with actions and per-action resources.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New activity
</button>
</section>
{selectedActivity && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="activity-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Activity detail</p>
<h2 id="activity-detail-heading">{selectedActivity.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedActivity.name}`} onClick={() => onEdit(selectedActivity)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close activity detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Notes</span>
<strong>{selectedActivity.notes ?? 'No notes'}</strong>
</div>
<div>
<span>Status</span>
<strong>{selectedActivity.isEnabled ? 'Enabled' : 'Disabled'}</strong>
</div>
</div>
<div className="detail-list">
{selectedActivity.actions.length === 0 ? (
<p className="empty-state">No actions configured.</p>
) : selectedActivity.actions.map((activityAction) => (
<div className="detail-row" key={activityAction.careActionId}>
<div>
<h4>{activityAction.name}</h4>
<p>{activityAction.description ?? 'No description'}</p>
{activityAction.resources.length > 0 ? (
<p>
{activityAction.resources.map((resource) => {
const amount = resource.quantity === null
? ''
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
return `${resource.name}${amount}`;
}).join(', ')}
</p>
) : (
<p>No resources</p>
)}
</div>
</div>
))}
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="activity-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activeActivityName ? 'Editing' : 'New activity'}</p>
<h2 id="activity-editor-heading">{activeActivityName ?? 'Activity details'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="plant-form">
<label>
Name
<input
value={form.name}
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<fieldset className="resource-picker schedule-picker">
<legend>Actions and resources</legend>
<div className="activity-action-list">
{form.actions.length === 0 ? (
<p className="empty-state">No actions configured.</p>
) : (
<div className="activity-builder-header" aria-hidden="true">
<span>Action</span>
<span>Resources</span>
<span />
</div>
)}
{form.actions.map((selectedAction, actionIndex) => {
const selectedActionIds = new Set(
form.actions
.filter((_, index) => index !== actionIndex)
.map((action) => action.careActionId),
);
return (
<div className="activity-config-row" key={`${selectedAction.careActionId}-${actionIndex}`}>
<div className="activity-action-cell">
<select
aria-label="Action"
value={selectedAction.careActionId}
onChange={(event) => updateAction(
actionIndex,
{ ...selectedAction, careActionId: event.target.value },
)}
>
<option value="">Select an action</option>
{enabledActions
.filter((action) => !selectedActionIds.has(String(action.id)))
.map((action) => (
<option key={action.id} value={action.id}>
{action.name}
</option>
))}
</select>
</div>
<div className="activity-resource-list">
{selectedAction.resources.length === 0 ? (
<p className="empty-state">No resources configured for this action.</p>
) : (
<div className="activity-resource-header" aria-hidden="true">
<span>Resource</span>
<span>Qty</span>
<span>Unit</span>
<span>Notes</span>
<span />
</div>
)}
{selectedAction.resources.map((selectedResource, resourceIndex) => {
const selectedResourceIds = new Set(
selectedAction.resources
.filter((_, index) => index !== resourceIndex)
.map((resource) => resource.actionResourceId),
);
return (
<div className="activity-resource-row" key={`${selectedResource.actionResourceId}-${resourceIndex}`}>
<select
aria-label="Resource"
value={selectedResource.actionResourceId}
onChange={(event) => updateAction(
actionIndex,
{
...selectedAction,
resources: selectedAction.resources.map((resource, index) => index === resourceIndex
? { ...resource, actionResourceId: event.target.value }
: resource),
},
)}
>
<option value="">Select a resource</option>
{enabledResources
.filter((resource) => !selectedResourceIds.has(String(resource.id)))
.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
<input
aria-label="Quantity"
min="0"
step="0.01"
type="number"
value={selectedResource.quantity}
onChange={(event) => updateAction(
actionIndex,
{
...selectedAction,
resources: selectedAction.resources.map((resource, index) => index === resourceIndex
? { ...resource, quantity: event.target.value }
: resource),
},
)}
/>
<input
aria-label="Unit"
value={selectedResource.unit}
onChange={(event) => updateAction(
actionIndex,
{
...selectedAction,
resources: selectedAction.resources.map((resource, index) => index === resourceIndex
? { ...resource, unit: event.target.value }
: resource),
},
)}
/>
<input
aria-label="Resource notes"
value={selectedResource.notes}
onChange={(event) => updateAction(
actionIndex,
{
...selectedAction,
resources: selectedAction.resources.map((resource, index) => index === resourceIndex
? { ...resource, notes: event.target.value }
: resource),
},
)}
/>
<button
className="icon-button compact danger"
type="button"
aria-label="Remove resource"
onClick={() => updateAction(
actionIndex,
{
...selectedAction,
resources: selectedAction.resources.filter((_, index) => index !== resourceIndex),
},
)}
>
<Trash2 size={17} />
</button>
</div>
);
})}
<button
className="small-action"
type="button"
disabled={enabledResources.length === 0}
onClick={() => updateAction(
actionIndex,
{
...selectedAction,
resources: [
...selectedAction.resources,
{ actionResourceId: '', quantity: '', unit: '', notes: '' },
],
},
)}
>
<Plus size={16} />
Add resource
</button>
</div>
<button
className="icon-button compact danger"
type="button"
aria-label="Remove action"
onClick={() => removeAction(actionIndex)}
>
<Trash2 size={17} />
</button>
</div>
);
})}
</div>
<button
className="small-action"
type="button"
disabled={enabledActions.length === 0}
onClick={addAction}
>
<Plus size={16} />
Add action
</button>
</fieldset>
<label>
Notes
<input
value={form.notes}
onChange={(event) => onFieldChange('notes', event.target.value)}
/>
</label>
<label className="toggle-field">
<input
checked={form.isEnabled}
type="checkbox"
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
/>
Enabled
</label>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || form.actions.length === 0 || hasIncompleteActions()} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save activity'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="activities-list-heading">
<div className="section-heading">
<h2 id="activities-list-heading">All Activities</h2>
</div>
<div className="plant-list">
{!isLoading && activities.length === 0 ? (
<p className="empty-state">No activities yet.</p>
) : null}
{activities.map((activity) => (
<article className="plant-row" key={activity.id}>
<div>
<h3>{activity.name}</h3>
<p>{formatActivitySummary(activity)} - {activity.isEnabled ? 'Enabled' : 'Disabled'}</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${activity.name}`} onClick={() => onOpenDetail(activity)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${activity.name}`} onClick={() => onEdit(activity)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${activity.name}`} onClick={() => onDelete(activity)}>
<Trash2 size={17} />
</button>
</div>
</article>
))}
</div>
</section>
</>
);
}
function formatActivitySummary(activity: CareActivity) {
if (activity.actions.length === 0) {
return activity.action;
}
return activity.actions
.map((action) => {
const resourceNames = action.resources.map((resource) => resource.name);
return resourceNames.length === 0
? `${action.name}: no resources`
: `${action.name}: ${resourceNames.join(', ')}`;
})
.join(' | ');
}
+45 -20
View File
@@ -1,4 +1,4 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { PlantFlagDefinition } from '../domain';
import type { FlagDefinitionFormState } from '../form-state';
@@ -10,11 +10,14 @@ type FlagsViewProps = {
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
selectedFlag?: PlantFlagDefinition;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (flag: PlantFlagDefinition) => void;
onEdit: (flag: PlantFlagDefinition) => void;
onFieldChange: (field: keyof FlagDefinitionFormState, value: string | boolean) => void;
onNew: () => void;
onOpenDetail: (flag: PlantFlagDefinition) => void;
onSave: () => void;
};
@@ -26,11 +29,14 @@ export function FlagsView({
isEditorOpen,
isLoading,
isSaving,
selectedFlag,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: FlagsViewProps) {
const enabledCount = flags.filter((flag) => flag.isEnabled).length;
@@ -43,7 +49,7 @@ export function FlagsView({
<h2 id="flags-summary-heading">
{isLoading ? 'Loading flags' : `${enabledCount} flags enabled`}
</h2>
<p>{error ?? 'Configure reusable pest, condition, and workflow flags for plants.'}</p>
<p>{error ?? 'Configure reusable flags for plants.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
@@ -51,6 +57,39 @@ export function FlagsView({
</button>
</section>
{selectedFlag && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="flag-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Flag detail</p>
<h2 id="flag-detail-heading">{selectedFlag.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedFlag.name}`} onClick={() => onEdit(selectedFlag)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close flag detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Color</span>
<strong>
<span className="flag-chip" style={{ backgroundColor: selectedFlag.color }}>
{selectedFlag.name}
</span>
</strong>
</div>
<div>
<span>Status</span>
<strong>{selectedFlag.isEnabled ? 'Enabled' : 'Disabled'}</strong>
</div>
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="flag-editor-heading">
<div className="section-heading">
@@ -71,19 +110,6 @@ export function FlagsView({
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<label>
Category
<select
value={form.category}
onChange={(event) => onFieldChange('category', event.target.value)}
>
<option value="Pest">Pest</option>
<option value="Disease">Disease</option>
<option value="Condition">Condition</option>
<option value="Workflow">Workflow</option>
<option value="General">General</option>
</select>
</label>
<label>
Color
<input
@@ -128,16 +154,15 @@ export function FlagsView({
<article className="plant-row" key={flag.id}>
<div>
<h3>{flag.name}</h3>
<p>
{flag.category}
{' - '}
{flag.isEnabled ? 'Enabled' : 'Disabled'}
</p>
<p>{flag.isEnabled ? 'Enabled' : 'Disabled'}</p>
</div>
<div className="row-actions">
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
<button className="icon-button compact" type="button" aria-label={`View ${flag.name}`} onClick={() => onOpenDetail(flag)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${flag.name}`} onClick={() => onEdit(flag)}>
<Edit3 size={17} />
</button>
+56 -4
View File
@@ -10,6 +10,7 @@ type HomeViewProps = {
isPlantSearchActive: boolean;
plants: Plant[];
totalPlantCount: number;
onCompleteBulkTasks: (tasks: CareTask[]) => void;
onCompleteTask: (task: CareTask) => void;
onNewPlant: () => void;
onOpenPlant: (plant: Plant) => void;
@@ -23,10 +24,13 @@ export function HomeView({
isPlantSearchActive,
plants,
totalPlantCount,
onCompleteBulkTasks,
onCompleteTask,
onNewPlant,
onOpenPlant,
}: HomeViewProps) {
const dueTaskGroups = groupDueTasks(careTasks);
return (
<>
<section className="summary-panel" aria-labelledby="summary-heading">
@@ -37,10 +41,6 @@ export function HomeView({
</h2>
<p>{error ?? 'Start with the plants that need attention now.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNewPlant}>
<Plus size={18} />
Add plant
</button>
</section>
<section className="section" aria-labelledby="care-heading">
@@ -56,6 +56,26 @@ export function HomeView({
</p>
) : null}
{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)}
>
<CalendarCheck size={16} />
Log all due
</button>
</article>
))}
{careTasks.map((task) => (
<article className="task-row" key={task.id}>
<span className={`status-dot ${task.status}`} />
@@ -101,3 +121,35 @@ export function HomeView({
</>
);
}
function groupDueTasks(tasks: CareTask[]) {
const groups = new Map<number, { careActivityId: number; action: string; tasks: CareTask[] }>();
for (const task of tasks) {
if (task.status !== 'due') {
continue;
}
const group = groups.get(task.careActivityId);
if (group) {
group.tasks.push(task);
continue;
}
groups.set(task.careActivityId, {
careActivityId: task.careActivityId,
action: task.action,
tasks: [task],
});
}
return [...groups.values()].sort((left, right) => left.action.localeCompare(right.action));
}
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`;
}
@@ -0,0 +1,175 @@
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { PlantLocation } from '../domain';
import type { LocationFormState } from '../form-state';
type LocationsViewProps = {
activeLocationName?: string;
error: string | null;
form: LocationFormState;
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
locations: PlantLocation[];
selectedLocation?: PlantLocation;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (location: PlantLocation) => void;
onEdit: (location: PlantLocation) => void;
onFieldChange: (field: keyof LocationFormState, value: string | boolean) => void;
onNew: () => void;
onOpenDetail: (location: PlantLocation) => void;
onSave: () => void;
};
export function LocationsView({
activeLocationName,
error,
form,
isEditorOpen,
isLoading,
isSaving,
locations,
selectedLocation,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: LocationsViewProps) {
const enabledCount = locations.filter((location) => location.isEnabled).length;
return (
<>
<section className="summary-panel" aria-labelledby="locations-summary-heading">
<div>
<p className="eyebrow">Location library</p>
<h2 id="locations-summary-heading">
{isLoading ? 'Loading locations' : `${enabledCount} locations enabled`}
</h2>
<p>{error ?? 'Create and maintain the places where plants live.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New location
</button>
</section>
{selectedLocation && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="location-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Location detail</p>
<h2 id="location-detail-heading">{selectedLocation.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedLocation.name}`} onClick={() => onEdit(selectedLocation)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close location detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Notes</span>
<strong>{selectedLocation.notes ?? 'No notes'}</strong>
</div>
<div>
<span>Status</span>
<strong>{selectedLocation.isEnabled ? 'Enabled' : 'Disabled'}</strong>
</div>
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="location-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activeLocationName ? 'Editing' : 'New location'}</p>
<h2 id="location-editor-heading">{activeLocationName ?? 'Location details'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="plant-form">
<label>
Name
<input
value={form.name}
onChange={(event) => onFieldChange('name', event.target.value)}
/>
</label>
<label>
Notes
<input
value={form.notes}
onChange={(event) => onFieldChange('notes', event.target.value)}
/>
</label>
<label className="toggle-field">
<input
checked={form.isEnabled}
type="checkbox"
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
/>
Enabled
</label>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save location'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="locations-list-heading">
<div className="section-heading">
<h2 id="locations-list-heading">All Locations</h2>
</div>
<div className="plant-list">
{!isLoading && locations.length === 0 ? (
<p className="empty-state">No locations yet.</p>
) : null}
{locations.map((location) => (
<article className="plant-row" key={location.id}>
<div>
<h3>{location.name}</h3>
<p>
{location.notes ?? 'No notes'}
{' - '}
{location.isEnabled ? 'Enabled' : 'Disabled'}
</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${location.name}`} onClick={() => onOpenDetail(location)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${location.name}`} onClick={() => onEdit(location)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${location.name}`} onClick={() => onDelete(location)}>
<Trash2 size={17} />
</button>
</div>
</article>
))}
</div>
</section>
</>
);
}
@@ -0,0 +1,251 @@
import { Check, MapPin, Save, Tags, Trash2 } from 'lucide-react';
import type {
Plant,
PlantFlag,
PlantFlagDefinition,
PlantLocation,
PlantTaxon,
} from '../domain';
import type { PlantFlagFormState } from '../form-state';
import { formatTaxon } from '../form-state';
type PlantManagementViewProps = {
error: string | null;
isLoading: boolean;
isSaving: boolean;
plantFlagDefinitions: PlantFlagDefinition[];
plantFlagForm: PlantFlagFormState;
plantLocations: PlantLocation[];
plantTaxa: PlantTaxon[];
plants: Plant[];
selectedPlant?: Plant;
selectedPlantId: number | null;
onAssignFlag: () => void;
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
onRemoveFlag: (flag: PlantFlag) => void;
onResolveFlag: (flag: PlantFlag) => void;
onSelectPlant: (plantId: string) => void;
onSetLocation: (locationId: string) => void;
onSetTaxon: (taxonId: string) => void;
};
export function PlantManagementView({
error,
isLoading,
isSaving,
plantFlagDefinitions,
plantFlagForm,
plantLocations,
plantTaxa,
plants,
selectedPlant,
selectedPlantId,
onAssignFlag,
onFieldChange,
onRemoveFlag,
onResolveFlag,
onSelectPlant,
onSetLocation,
onSetTaxon,
}: PlantManagementViewProps) {
const enabledFlags = plantFlagDefinitions.filter((flag) => flag.isEnabled);
const enabledLocations = plantLocations.filter((location) =>
location.isEnabled || location.id === selectedPlant?.locationId);
const activeFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? [];
const resolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? [];
return (
<>
<section className="summary-panel" aria-labelledby="plant-management-summary-heading">
<div>
<p className="eyebrow">Relationship surface</p>
<h2 id="plant-management-summary-heading">
{isLoading ? 'Loading plants' : 'Plant Management'}
</h2>
<p>{error ?? 'Attach building blocks to plant objects without changing the building block libraries.'}</p>
</div>
</section>
<section className="editor-panel" aria-labelledby="plant-management-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Selected plant</p>
<h2 id="plant-management-heading">{selectedPlant?.nickname ?? 'Choose a plant'}</h2>
</div>
</div>
<div className="plant-form">
<label>
Plant
<select
value={selectedPlantId ?? ''}
onChange={(event) => onSelectPlant(event.target.value)}
>
<option value="">Select a plant</option>
{plants.map((plant) => (
<option key={plant.id} value={plant.id}>
{plant.nickname}
</option>
))}
</select>
</label>
</div>
</section>
{selectedPlant ? (
<div className="plant-detail-grid">
<section className="detail-section" aria-labelledby="plant-management-identity">
<div className="detail-section-heading">
<Tags size={17} />
<h3 id="plant-management-identity">Identity</h3>
</div>
<div className="plant-form">
<label>
Taxon
<select
disabled={isSaving}
value={selectedPlant.taxonId ?? ''}
onChange={(event) => onSetTaxon(event.target.value)}
>
<option value="">No taxon</option>
{plantTaxa.map((taxon) => (
<option key={taxon.id} value={taxon.id}>
{formatTaxon(taxon)}
</option>
))}
</select>
</label>
</div>
</section>
<section className="detail-section" aria-labelledby="plant-management-placement">
<div className="detail-section-heading">
<MapPin size={17} />
<h3 id="plant-management-placement">Placement</h3>
</div>
<div className="plant-form">
<label>
Location
<select
disabled={isSaving}
value={selectedPlant.locationId ?? ''}
onChange={(event) => onSetLocation(event.target.value)}
>
<option value="">No location</option>
{enabledLocations.map((location) => (
<option key={location.id} value={location.id}>
{location.isEnabled ? location.name : `${location.name} (disabled)`}
</option>
))}
</select>
</label>
</div>
</section>
<section className="detail-section detail-section-wide" aria-labelledby="plant-management-flags">
<div className="detail-section-heading">
<Check size={17} />
<h3 id="plant-management-flags">Flags</h3>
</div>
<div className="flag-assignment-form">
<label>
Flag
<select
value={plantFlagForm.plantFlagDefinitionId}
onChange={(event) => onFieldChange('plantFlagDefinitionId', event.target.value)}
>
<option value="">Select a flag</option>
{enabledFlags.map((flag) => (
<option key={flag.id} value={flag.id}>
{flag.name}
</option>
))}
</select>
</label>
<label>
Started
<input
type="date"
value={plantFlagForm.startedOn}
onChange={(event) => onFieldChange('startedOn', event.target.value)}
/>
</label>
<label>
Notes
<input
value={plantFlagForm.notes}
onChange={(event) => onFieldChange('notes', event.target.value)}
/>
</label>
<button
className="small-action"
type="button"
disabled={isSaving || !plantFlagForm.plantFlagDefinitionId}
onClick={onAssignFlag}
>
<Save size={16} />
Attach flag
</button>
</div>
{activeFlags.length === 0 ? (
<p className="empty-state">No active flags.</p>
) : (
<div className="detail-list">
{activeFlags.map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
</h4>
<p>
Started {formatDate(flag.startedOn)}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
<div className="row-actions">
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
Resolve
</button>
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
<Trash2 size={17} />
</button>
</div>
</div>
))}
</div>
)}
{resolvedFlags.length > 0 ? (
<div className="detail-list resolved-flags">
{resolvedFlags.slice(0, 4).map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>{flag.name}</h4>
<p>
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
</div>
))}
</div>
) : null}
</section>
</div>
) : null}
</>
);
}
function formatDate(date: string) {
const [year, month, day] = date.split('-');
if (!year || !month || !day) {
return date;
}
return `${month}/${day}/${year}`;
}
+32 -659
View File
@@ -1,135 +1,40 @@
import {
CalendarClock,
ClipboardCheck,
Edit3,
Eye,
Plus,
Save,
Trash2,
X,
} from 'lucide-react';
import type {
ActionResource,
CareAction,
CareStatus,
CareTask,
Plant,
PlantFlag,
PlantFlagDefinition,
PlantTaxon,
} from '../domain';
import type {
CareLogFormState,
CareLogResourceFormState,
PlantCareScheduleFormState,
PlantFlagFormState,
PlantFormState,
TaxonFormState,
} from '../form-state';
import { formatTaxon } from '../form-state';
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import type { Plant } from '../domain';
import type { PlantFormState } from '../form-state';
type PlantsViewProps = {
activePlantName?: string;
actionResources: ActionResource[];
careActions: CareAction[];
careTasks: CareTask[];
careLogForm: CareLogFormState;
editingActionLogId: number | null;
error: string | null;
form: PlantFormState;
isCareLogEditorOpen: boolean;
isLoading: boolean;
isPlantEditorOpen: boolean;
isSaving: boolean;
plantFlagDefinitions: PlantFlagDefinition[];
plantFlagForm: PlantFlagFormState;
plantTaxa: PlantTaxon[];
plants: Plant[];
selectedPlant?: Plant;
visiblePlants: Plant[];
onCancel: () => void;
onCancelCareLog: () => void;
onClosePlantDetail: () => void;
onCompleteTask: (task: CareTask) => void;
onDelete: (plant: Plant) => void;
onEdit: (plant: Plant) => void;
onFieldChange: (field: keyof PlantFormState, value: string | PlantCareScheduleFormState[]) => void;
onLogCare: () => void;
onLogCareFieldChange: (
field: keyof CareLogFormState,
value: string | CareLogResourceFormState[],
) => void;
onFieldChange: (field: keyof PlantFormState, value: string) => void;
onNew: () => void;
onOpenDetail: (plant: Plant) => void;
onQuickTaxonFieldChange: (field: keyof TaxonFormState, value: string) => void;
onPlantFlagFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
onAssignFlag: () => void;
onResolveFlag: (flag: PlantFlag) => void;
onRemoveFlag: (flag: PlantFlag) => void;
onSave: () => void;
onSaveQuickTaxon: () => void;
onSelectTaxon: (taxon: PlantTaxon) => void;
onStartLogCare: (plant?: Plant) => void;
quickTaxonForm: TaxonFormState;
isCreatingTaxon: boolean;
};
export function PlantsView({
activePlantName,
actionResources,
careActions,
careTasks,
careLogForm,
editingActionLogId,
error,
form,
isCareLogEditorOpen,
isLoading,
isPlantEditorOpen,
isSaving,
plantFlagDefinitions,
plantFlagForm,
plantTaxa,
plants,
selectedPlant,
visiblePlants,
onCancel,
onCancelCareLog,
onClosePlantDetail,
onCompleteTask,
onDelete,
onEdit,
onFieldChange,
onLogCare,
onLogCareFieldChange,
onNew,
onOpenDetail,
onQuickTaxonFieldChange,
onPlantFlagFieldChange,
onAssignFlag,
onResolveFlag,
onRemoveFlag,
onSave,
onSaveQuickTaxon,
onSelectTaxon,
onStartLogCare,
quickTaxonForm,
isCreatingTaxon,
}: PlantsViewProps) {
const enabledCareActions = careActions.filter((action) => action.isEnabled);
const enabledActionResources = actionResources.filter((resource) => resource.isEnabled);
const selectedPlantTasks = selectedPlant
? careTasks.filter((task) => task.plantId === selectedPlant.id)
: [];
const selectedPlantTaxon = selectedPlant
? plantTaxa.find((taxon) => taxon.id === selectedPlant.taxonId)
: undefined;
const selectedPlantSchedules =
selectedPlant?.careSchedules.filter((schedule) => schedule.isEnabled) ?? [];
const enabledFlagDefinitions = plantFlagDefinitions.filter((flag) => flag.isEnabled);
const selectedPlantActiveFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? [];
const selectedPlantResolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? [];
return (
<>
<section className="summary-panel" aria-labelledby="plants-summary-heading">
@@ -138,544 +43,46 @@ export function PlantsView({
<h2 id="plants-summary-heading">
{isLoading ? 'Loading plants' : `${plants.length} plants tracked`}
</h2>
<p>{error ?? 'Add, update, or remove plants from your collection.'}</p>
</div>
<div className="row-actions">
<button className="small-action" type="button" onClick={() => onStartLogCare()}>
<ClipboardCheck size={16} />
Log care
</button>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New plant
</button>
<p>{error ?? 'Create and maintain plant objects.'}</p>
</div>
<button className="primary-action" type="button" onClick={onNew}>
<Plus size={18} />
New plant
</button>
</section>
{selectedPlant ? (
<section className="editor-panel plant-detail-panel" aria-labelledby="plant-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Plant focus</p>
<h2 id="plant-detail-heading">{selectedPlant.nickname}</h2>
</div>
<div className="row-actions">
<button
className="icon-button compact"
type="button"
aria-label={`Log care for ${selectedPlant.nickname}`}
onClick={() => onStartLogCare(selectedPlant)}
>
<ClipboardCheck size={17} />
</button>
<button
className="icon-button compact"
type="button"
aria-label={`Edit ${selectedPlant.nickname}`}
onClick={() => onEdit(selectedPlant)}
>
<Edit3 size={17} />
</button>
<button
className="icon-button compact"
type="button"
aria-label="Close plant detail"
onClick={onClosePlantDetail}
>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Taxon</span>
<strong>{selectedPlantTaxon ? formatTaxon(selectedPlantTaxon) : selectedPlant.taxon}</strong>
</div>
<div>
<span>Location</span>
<strong>{selectedPlant.location || 'No location'}</strong>
</div>
<div>
<span>Next care</span>
<strong>{selectedPlant.nextCare}</strong>
</div>
<div>
<span>Active flags</span>
{selectedPlantActiveFlags.length === 0 ? (
<strong>None</strong>
) : (
<div className="flag-list compact">
{selectedPlantActiveFlags.map((flag) => (
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
))}
</div>
)}
</div>
</div>
<div className="plant-detail-grid">
<section className="detail-section" aria-labelledby="plant-detail-schedules">
<div className="detail-section-heading">
<CalendarClock size={17} />
<h3 id="plant-detail-schedules">Schedules</h3>
</div>
{selectedPlantSchedules.length === 0 ? (
<p className="empty-state">No schedules enabled.</p>
) : (
<div className="detail-list">
{selectedPlantSchedules.map((schedule) => (
<div className="detail-row" key={schedule.id}>
<div>
<h4>{schedule.action}</h4>
<p>
Every {schedule.everyDays} days - Last {schedule.lastPerformed}
</p>
</div>
<span className={`status-pill ${schedule.status}`}>
{statusLabel[schedule.status]}
</span>
</div>
))}
</div>
)}
</section>
<section className="detail-section" aria-labelledby="plant-detail-tasks">
<div className="detail-section-heading">
<ClipboardCheck size={17} />
<h3 id="plant-detail-tasks">Next Tasks</h3>
</div>
{selectedPlantTasks.length === 0 ? (
<p className="empty-state">No upcoming tasks.</p>
) : (
<div className="detail-list">
{selectedPlantTasks.map((task) => (
<div className="detail-row" key={task.id}>
<div>
<h4>{task.action}</h4>
<p>{task.due}</p>
</div>
<div className="row-actions">
<span className={`status-pill ${task.status}`}>
{statusLabel[task.status]}
</span>
<button
className="small-action"
type="button"
onClick={() => onCompleteTask(task)}
>
<ClipboardCheck size={16} />
Log
</button>
</div>
</div>
))}
</div>
)}
</section>
<section className="detail-section detail-section-wide" aria-labelledby="plant-detail-flags">
<div className="detail-section-heading">
<ClipboardCheck size={17} />
<h3 id="plant-detail-flags">Plant Flags</h3>
</div>
<div className="flag-assignment-form">
<label>
Flag
<select
value={plantFlagForm.plantFlagDefinitionId}
onChange={(event) => onPlantFlagFieldChange('plantFlagDefinitionId', event.target.value)}
>
<option value="">Select a flag</option>
{enabledFlagDefinitions.map((flag) => (
<option key={flag.id} value={flag.id}>
{flag.name}
</option>
))}
</select>
</label>
<label>
Severity
<select
value={plantFlagForm.severity}
onChange={(event) => onPlantFlagFieldChange('severity', event.target.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<label>
Started
<input
type="date"
value={plantFlagForm.startedOn}
onChange={(event) => onPlantFlagFieldChange('startedOn', event.target.value)}
/>
</label>
<label>
Notes
<input
value={plantFlagForm.notes}
onChange={(event) => onPlantFlagFieldChange('notes', event.target.value)}
/>
</label>
<button
className="small-action"
type="button"
disabled={isSaving || enabledFlagDefinitions.length === 0}
onClick={onAssignFlag}
>
<Plus size={16} />
Add flag
</button>
</div>
{selectedPlantActiveFlags.length === 0 ? (
<p className="empty-state">No active flags.</p>
) : (
<div className="detail-list">
{selectedPlantActiveFlags.map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
</h4>
<p>
{flag.category} - {flag.severity} - Started {formatDate(flag.startedOn)}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
<div className="row-actions">
<button className="small-action" type="button" disabled={isSaving} onClick={() => onResolveFlag(flag)}>
Resolve
</button>
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
<Trash2 size={17} />
</button>
</div>
</div>
))}
</div>
)}
{selectedPlantResolvedFlags.length > 0 ? (
<div className="detail-list resolved-flags">
{selectedPlantResolvedFlags.slice(0, 4).map((flag) => (
<div className="detail-row" key={flag.id}>
<div>
<h4>{flag.name}</h4>
<p>
Resolved {flag.resolvedOn ? formatDate(flag.resolvedOn) : ''}
{flag.notes ? ` - ${flag.notes}` : ''}
</p>
</div>
</div>
))}
</div>
) : null}
</section>
</div>
</section>
) : null}
{isCareLogEditorOpen ? (
<section className="editor-panel" aria-labelledby="care-log-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Manual entry</p>
<h2 id="care-log-heading">{editingActionLogId === null ? 'Log care' : 'Edit care log'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Clear care log" onClick={onCancelCareLog}>
<X size={18} />
</button>
</div>
<div className="plant-form">
<label>
Plant
<select
value={careLogForm.plantId}
onChange={(event) => onLogCareFieldChange('plantId', event.target.value)}
>
<option value="">Select a plant</option>
{plants.map((plant) => (
<option key={plant.id} value={plant.id}>
{plant.nickname}
</option>
))}
</select>
</label>
<label>
Action
<select
value={careLogForm.careActionId}
onChange={(event) => onLogCareFieldChange('careActionId', event.target.value)}
>
<option value="">Select an action</option>
{enabledCareActions.length === 0 ? (
<option value="">No enabled actions</option>
) : null}
{enabledCareActions.map((action) => (
<option key={action.id} value={action.id}>
{action.name}
</option>
))}
</select>
</label>
<label>
Date
<input
type="date"
value={careLogForm.performedOn}
onChange={(event) => onLogCareFieldChange('performedOn', event.target.value)}
/>
</label>
<label>
Notes
<input
value={careLogForm.notes}
onChange={(event) => onLogCareFieldChange('notes', event.target.value)}
/>
</label>
<fieldset className="resource-picker">
<legend>Resources</legend>
{enabledActionResources.length === 0 ? (
<p className="empty-state">No enabled resources.</p>
) : null}
{enabledActionResources.map((resource) => {
const resourceId = String(resource.id);
const selectedResource = careLogForm.resources.find(
(item) => item.actionResourceId === resourceId,
);
return (
<div className="resource-entry" key={resource.id}>
<label className="check-option">
<input
checked={Boolean(selectedResource)}
type="checkbox"
onChange={(event) => {
const nextResources = event.target.checked
? [
...careLogForm.resources,
{ actionResourceId: resourceId, quantity: '', unit: '' },
]
: careLogForm.resources.filter((item) => item.actionResourceId !== resourceId);
onLogCareFieldChange('resources', nextResources);
}}
/>
<span>{resource.name}</span>
</label>
{selectedResource ? (
<div className="resource-amount">
<label>
Qty
<input
min="0"
step="0.01"
type="number"
value={selectedResource.quantity}
onChange={(event) => onLogCareFieldChange(
'resources',
careLogForm.resources.map((item) => item.actionResourceId === resourceId
? { ...item, quantity: event.target.value }
: item),
)}
/>
</label>
<label>
Unit
<input
value={selectedResource.unit}
onChange={(event) => onLogCareFieldChange(
'resources',
careLogForm.resources.map((item) => item.actionResourceId === resourceId
? { ...item, unit: event.target.value }
: item),
)}
/>
</label>
</div>
) : null}
</div>
);
})}
</fieldset>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || enabledCareActions.length === 0} onClick={onLogCare}>
<ClipboardCheck size={18} />
{isSaving ? 'Saving' : editingActionLogId === null ? 'Log care' : 'Update log'}
</button>
{editingActionLogId === null ? null : (
<button className="text-button" type="button" onClick={onCancelCareLog}>
Cancel
</button>
)}
</div>
</section>
) : null}
{isPlantEditorOpen ? (
<section className="editor-panel" aria-labelledby="plant-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activePlantName ? 'Editing' : 'New plant'}</p>
<h2 id="plant-editor-heading">{activePlantName ?? 'Plant details'}</h2>
<section className="editor-panel" aria-labelledby="plant-editor-heading">
<div className="section-heading">
<div>
<p className="eyebrow">{activePlantName ? 'Editing' : 'New plant'}</p>
<h2 id="plant-editor-heading">{activePlantName ?? 'Plant details'}</h2>
</div>
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
<X size={18} />
</button>
</div>
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
<X size={18} />
</button>
</div>
<div className="plant-form">
<label>
Nickname
<input
value={form.nickname}
onChange={(event) => onFieldChange('nickname', event.target.value)}
/>
</label>
<div className="taxon-picker">
<div className="plant-form">
<label>
Taxon
<select
value={form.taxonId}
onChange={(event) => {
const taxon = plantTaxa.find((item) => String(item.id) === event.target.value);
if (taxon) {
onSelectTaxon(taxon);
return;
}
onFieldChange('taxonId', '');
}}
>
<option value="">Select a taxon</option>
{plantTaxa.map((taxon) => (
<option key={taxon.id} value={taxon.id}>
{formatTaxon(taxon)}
</option>
))}
</select>
</label>
</div>
<label>
Location
<input
value={form.location}
onChange={(event) => onFieldChange('location', event.target.value)}
/>
</label>
<fieldset className="resource-picker schedule-picker">
<legend>Care schedules</legend>
{enabledCareActions.length === 0 ? (
<p className="empty-state">No enabled actions.</p>
) : null}
{enabledCareActions.map((action) => {
const actionId = String(action.id);
const selectedSchedule = form.careSchedules.find(
(schedule) => schedule.careActionId === actionId,
);
return (
<div className="resource-entry" key={action.id}>
<label className="check-option">
<input
checked={Boolean(selectedSchedule?.isEnabled)}
type="checkbox"
onChange={(event) => {
const existingSchedules = form.careSchedules.filter(
(schedule) => schedule.careActionId !== actionId,
);
const nextSchedule = {
careActionId: actionId,
everyDays: selectedSchedule?.everyDays || '7',
isEnabled: event.target.checked,
};
onFieldChange('careSchedules', [...existingSchedules, nextSchedule]);
}}
/>
<span>{action.name}</span>
</label>
{selectedSchedule?.isEnabled ? (
<div className="resource-amount schedule-interval">
<label>
Every days
<input
min="1"
max="365"
type="number"
value={selectedSchedule.everyDays}
onChange={(event) => onFieldChange(
'careSchedules',
form.careSchedules.map((schedule) => schedule.careActionId === actionId
? { ...schedule, everyDays: event.target.value }
: schedule),
)}
/>
</label>
</div>
) : null}
</div>
);
})}
</fieldset>
</div>
<div className="quick-taxon" aria-labelledby="quick-taxon-heading">
<div>
<p className="eyebrow">Missing from the list?</p>
<h3 id="quick-taxon-heading">Create taxon</h3>
</div>
<div className="quick-taxon-fields">
<label>
Common name
Name
<input
value={quickTaxonForm.name}
onChange={(event) => onQuickTaxonFieldChange('name', event.target.value)}
/>
</label>
<label>
Genus
<input
value={quickTaxonForm.genus}
onChange={(event) => onQuickTaxonFieldChange('genus', event.target.value)}
/>
</label>
<label>
Species
<input
value={quickTaxonForm.species}
onChange={(event) => onQuickTaxonFieldChange('species', event.target.value)}
value={form.nickname}
onChange={(event) => onFieldChange('nickname', event.target.value)}
/>
</label>
</div>
<button className="small-action" type="button" disabled={isCreatingTaxon} onClick={onSaveQuickTaxon}>
<Plus size={16} />
{isCreatingTaxon ? 'Adding' : 'Add and select'}
</button>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save plant'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : 'Save plant'}
</button>
<button className="text-button" type="button" onClick={onCancel}>
Cancel
</button>
</div>
</section>
) : null}
<section className="section" aria-labelledby="plants-list-heading">
@@ -696,29 +103,11 @@ export function PlantsView({
<article className="plant-row" key={plant.id}>
<div>
<h3>{plant.nickname}</h3>
<p>{plant.taxon} - {plant.location}</p>
{plant.flags.filter((flag) => flag.resolvedOn === null).length > 0 ? (
<div className="flag-list">
{plant.flags
.filter((flag) => flag.resolvedOn === null)
.map((flag) => (
<span className="flag-chip" key={flag.id} style={{ backgroundColor: flag.color }}>
{flag.name}
</span>
))}
</div>
) : null}
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${plant.nickname}`} onClick={() => onOpenDetail(plant)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Log care for ${plant.nickname}`} onClick={() => onStartLogCare(plant)}>
<ClipboardCheck size={17} />
</button>
<button className="icon-button compact danger" type="button" aria-label={`Delete ${plant.nickname}`} onClick={() => onDelete(plant)}>
<Trash2 size={17} />
</button>
@@ -730,19 +119,3 @@ export function PlantsView({
</>
);
}
const statusLabel: Record<CareStatus, string> = {
due: 'Due',
soon: 'Soon',
ok: 'Ok',
unscheduled: 'Unscheduled',
};
function formatDate(date: string) {
const [year, month, day] = date.split('-');
if (!year || !month || !day) {
return date;
}
return `${month}/${day}/${year}`;
}
@@ -1,4 +1,4 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { ActionResource } from '../domain';
import type { ResourceFormState } from '../form-state';
@@ -9,11 +9,14 @@ type ResourcesViewProps = {
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
selectedResource?: ActionResource;
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (resource: ActionResource) => void;
onEdit: (resource: ActionResource) => void;
onFieldChange: (field: keyof ResourceFormState, value: string | boolean) => void;
onNew: () => void;
onOpenDetail: (resource: ActionResource) => void;
onSave: () => void;
resources: ActionResource[];
};
@@ -25,11 +28,14 @@ export function ResourcesView({
isEditorOpen,
isLoading,
isSaving,
selectedResource,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
resources,
}: ResourcesViewProps) {
@@ -51,6 +57,39 @@ export function ResourcesView({
</button>
</section>
{selectedResource && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="resource-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Resource detail</p>
<h2 id="resource-detail-heading">{selectedResource.name}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedResource.name}`} onClick={() => onEdit(selectedResource)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close resource detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Category</span>
<strong>{selectedResource.category ?? 'Uncategorized'}</strong>
</div>
<div>
<span>Notes</span>
<strong>{selectedResource.notes ?? 'No notes'}</strong>
</div>
<div>
<span>Status</span>
<strong>{selectedResource.isEnabled ? 'Enabled' : 'Disabled'}</strong>
</div>
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="resource-editor-heading">
<div className="section-heading">
@@ -130,6 +169,9 @@ export function ResourcesView({
</p>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${resource.name}`} onClick={() => onOpenDetail(resource)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${resource.name}`} onClick={() => onEdit(resource)}>
<Edit3 size={17} />
</button>
@@ -0,0 +1,138 @@
import { Save } from 'lucide-react';
import type { CareActivity, Plant } from '../domain';
import type { BulkScheduleFormState } from '../form-state';
type SchedulesViewProps = {
activities: CareActivity[];
error: string | null;
form: BulkScheduleFormState;
isLoading: boolean;
isSaving: boolean;
plants: Plant[];
onFieldChange: (field: keyof BulkScheduleFormState, value: string | boolean | string[]) => void;
onSave: () => void;
};
export function SchedulesView({
activities,
error,
form,
isLoading,
isSaving,
plants,
onFieldChange,
onSave,
}: SchedulesViewProps) {
const enabledActivities = activities.filter((activity) => activity.isEnabled);
const selectedPlantIds = new Set(form.plantIds);
const allVisibleSelected = plants.length > 0 && plants.every((plant) => selectedPlantIds.has(String(plant.id)));
return (
<>
<section className="summary-panel" aria-labelledby="schedules-summary-heading">
<div>
<p className="eyebrow">Care schedules</p>
<h2 id="schedules-summary-heading">
{isLoading ? 'Loading schedules' : 'Bulk schedule assignment'}
</h2>
<p>{error ?? 'Apply one care interval to several plants at once.'}</p>
</div>
</section>
<section className="editor-panel" aria-labelledby="bulk-schedule-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Bulk edit</p>
<h2 id="bulk-schedule-heading">Apply schedule</h2>
</div>
</div>
<div className="plant-form">
<label>
Activity
<select
value={form.careActivityId}
onChange={(event) => onFieldChange('careActivityId', event.target.value)}
>
<option value="">Select an activity</option>
{enabledActivities.map((activity) => (
<option key={activity.id} value={activity.id}>
{activity.name}
</option>
))}
</select>
</label>
<label>
Every days
<input
min="1"
max="365"
type="number"
value={form.everyDays}
onChange={(event) => onFieldChange('everyDays', event.target.value)}
/>
</label>
<label className="toggle-field">
<input
checked={form.isEnabled}
type="checkbox"
onChange={(event) => onFieldChange('isEnabled', event.target.checked)}
/>
Enabled
</label>
</div>
<div className="section" aria-labelledby="bulk-schedule-plants-heading">
<div className="section-heading">
<h2 id="bulk-schedule-plants-heading">Plants</h2>
<button
className="text-button"
type="button"
onClick={() => onFieldChange(
'plantIds',
allVisibleSelected ? [] : plants.map((plant) => String(plant.id)),
)}
>
{allVisibleSelected ? 'Clear all' : 'Select all'}
</button>
</div>
<div className="plant-list">
{!isLoading && plants.length === 0 ? (
<p className="empty-state">No plants available.</p>
) : null}
{plants.map((plant) => (
<label className="plant-row check-row" key={plant.id}>
<span>
<input
checked={selectedPlantIds.has(String(plant.id))}
type="checkbox"
onChange={(event) => {
const plantId = String(plant.id);
const nextPlantIds = event.target.checked
? [...form.plantIds, plantId]
: form.plantIds.filter((id) => id !== plantId);
onFieldChange('plantIds', nextPlantIds);
}}
/>
<span>
<strong>{plant.nickname}</strong>
<small>{plant.taxon} - {plant.location}</small>
</span>
</span>
</label>
))}
</div>
</div>
<div className="form-actions">
<button className="primary-action" type="button" disabled={isSaving || form.plantIds.length === 0 || !form.careActivityId} onClick={onSave}>
<Save size={18} />
{isSaving ? 'Saving' : `Apply to ${form.plantIds.length} plants`}
</button>
</div>
</section>
</>
);
}
+56 -3
View File
@@ -1,4 +1,4 @@
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
import type { PlantTaxon } from '../domain';
import type { TaxonFormState } from '../form-state';
import { formatTaxon } from '../form-state';
@@ -10,12 +10,15 @@ type TaxaViewProps = {
isEditorOpen: boolean;
isLoading: boolean;
isSaving: boolean;
selectedTaxon?: PlantTaxon;
taxa: PlantTaxon[];
onCancel: () => void;
onCloseDetail: () => void;
onDelete: (taxon: PlantTaxon) => void;
onEdit: (taxon: PlantTaxon) => void;
onFieldChange: (field: keyof TaxonFormState, value: string) => void;
onNew: () => void;
onOpenDetail: (taxon: PlantTaxon) => void;
onSave: () => void;
};
@@ -26,12 +29,15 @@ export function TaxaView({
isEditorOpen,
isLoading,
isSaving,
selectedTaxon,
taxa,
onCancel,
onCloseDetail,
onDelete,
onEdit,
onFieldChange,
onNew,
onOpenDetail,
onSave,
}: TaxaViewProps) {
return (
@@ -50,6 +56,51 @@ export function TaxaView({
</button>
</section>
{selectedTaxon && !isEditorOpen ? (
<section className="editor-panel" aria-labelledby="taxon-detail-heading">
<div className="section-heading">
<div>
<p className="eyebrow">Taxon detail</p>
<h2 id="taxon-detail-heading">{formatTaxon(selectedTaxon)}</h2>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedTaxon.name}`} onClick={() => onEdit(selectedTaxon)}>
<Edit3 size={17} />
</button>
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
<X size={18} />
</button>
</div>
</div>
<div className="plant-detail-meta">
<div>
<span>Common name</span>
<strong>{selectedTaxon.name}</strong>
</div>
<div>
<span>Genus</span>
<strong>{selectedTaxon.genus}</strong>
</div>
<div>
<span>Species</span>
<strong>{selectedTaxon.species}</strong>
</div>
<div>
<span>Cultivar</span>
<strong>{selectedTaxon.cultivar ?? 'None'}</strong>
</div>
<div>
<span>Variety</span>
<strong>{selectedTaxon.variety ?? 'None'}</strong>
</div>
<div>
<span>Authority</span>
<strong>{selectedTaxon.authority ?? 'None'}</strong>
</div>
</div>
</section>
) : null}
{isEditorOpen ? (
<section className="editor-panel" aria-labelledby="taxon-editor-heading">
<div className="section-heading">
@@ -132,10 +183,12 @@ export function TaxaView({
{taxa.map((taxon) => (
<article className="plant-row" key={taxon.id}>
<div>
<h3>{taxon.name}</h3>
<p>{formatTaxon(taxon)}</p>
<h3>{formatTaxon(taxon)}</h3>
</div>
<div className="row-actions">
<button className="icon-button compact" type="button" aria-label={`View ${taxon.name}`} onClick={() => onOpenDetail(taxon)}>
<Eye size={17} />
</button>
<button className="icon-button compact" type="button" aria-label={`Edit ${taxon.name}`} onClick={() => onEdit(taxon)}>
<Edit3 size={17} />
</button>
+77 -32
View File
@@ -3,8 +3,9 @@ export type CareStatus = 'due' | 'soon' | 'ok' | 'unscheduled';
export type Plant = {
id: number;
nickname: string;
taxonId: number;
taxonId: number | null;
taxon: string;
locationId: number | null;
location: string;
nextCare: string;
status: CareStatus;
@@ -14,6 +15,7 @@ export type Plant = {
export type PlantCareSchedule = {
id: number;
careActivityId: number;
careActionId: number;
action: string;
everyDays: number;
@@ -34,6 +36,13 @@ export type PlantTaxon = {
authority: string | null;
};
export type PlantLocation = {
id: number;
name: string;
notes: string | null;
isEnabled: boolean;
};
export type CareAction = {
id: number;
name: string;
@@ -49,10 +58,36 @@ export type ActionResource = {
isEnabled: boolean;
};
export type CareActivity = {
id: number;
name: string;
careActionId: number;
action: string;
actions: CareActivityAction[];
notes: string | null;
isEnabled: boolean;
};
export type CareActivityAction = {
careActionId: number;
name: string;
description: string | null;
sortOrder: number;
resources: CareActivityActionResource[];
};
export type CareActivityActionResource = {
actionResourceId: number;
name: string;
category: string | null;
quantity: number | null;
unit: string | null;
notes: string | null;
};
export type PlantFlagDefinition = {
id: number;
name: string;
category: string;
color: string;
isEnabled: boolean;
};
@@ -61,31 +96,28 @@ export type PlantFlag = {
id: number;
plantFlagDefinitionId: number;
name: string;
category: string;
color: string;
severity: 'low' | 'medium' | 'high';
startedOn: string;
resolvedOn: string | null;
notes: string | null;
};
export type ActionLogResource = {
actionResourceId: number;
name: string;
category: string | null;
quantity: number | null;
unit: string | null;
};
export type PlantPayload = {
nickname: string;
taxonId: number;
location: string;
careSchedules: PlantCareSchedulePayload[];
taxonId: number | null;
locationId: number | null;
careSchedules: PlantCareSchedulePayload[] | null;
};
export type PlantCareSchedulePayload = {
careActionId: number;
careActivityId: number;
everyDays: number;
isEnabled: boolean;
};
export type BulkPlantCareSchedulePayload = {
plantIds: number[];
careActivityId: number;
everyDays: number;
isEnabled: boolean;
};
@@ -99,6 +131,12 @@ export type PlantTaxonPayload = {
authority: string | null;
};
export type PlantLocationPayload = {
name: string;
notes: string | null;
isEnabled: boolean;
};
export type CareActionPayload = {
name: string;
description: string | null;
@@ -112,23 +150,40 @@ export type ActionResourcePayload = {
isEnabled: boolean;
};
export type CareActivityPayload = {
name: string;
actions: CareActivityActionPayload[];
notes: string | null;
isEnabled: boolean;
};
export type CareActivityActionPayload = {
careActionId: number;
resources: CareActivityActionResourcePayload[];
};
export type CareActivityActionResourcePayload = {
actionResourceId: number;
quantity: number | null;
unit: string | null;
notes: string | null;
};
export type PlantFlagDefinitionPayload = {
name: string;
category: string | null;
color: string | null;
isEnabled: boolean;
};
export type AssignPlantFlagPayload = {
plantFlagDefinitionId: number;
severity: string | null;
startedOn: string | null;
notes: string | null;
};
export type CareLogPayload = {
plantId: number;
careActionId: number;
export type BulkCompleteCareTasksPayload = {
careActivityId: number;
plantIds: number[];
notes: string | null;
performedOn: string | null;
resources: CareLogResourcePayload[];
@@ -140,21 +195,11 @@ export type CareLogResourcePayload = {
unit: string | null;
};
export type ActionLog = {
id: number;
plantId: number;
plantName: string;
careActionId: number;
action: string;
notes: string | null;
performedOn: string;
resources: ActionLogResource[];
};
export type CareTask = {
id: number;
plantId: number;
plantName: string;
careActivityId: number;
careActionId: number;
action: string;
due: string;
+101 -42
View File
@@ -1,28 +1,24 @@
import type {
ActionResource,
ActionResourcePayload,
CareActivity,
CareActivityPayload,
CareAction,
CareActionPayload,
BulkPlantCareSchedulePayload,
Plant,
AssignPlantFlagPayload,
PlantPayload,
PlantFlagDefinition,
PlantFlagDefinitionPayload,
PlantLocation,
PlantLocationPayload,
PlantTaxon,
PlantTaxonPayload,
} from './domain';
export const emptyPlantForm = {
nickname: '',
taxonId: '',
location: '',
careSchedules: [] as PlantCareScheduleFormState[],
};
export type PlantCareScheduleFormState = {
careActionId: string;
everyDays: string;
isEnabled: boolean;
};
export const emptyTaxonForm = {
@@ -34,6 +30,12 @@ export const emptyTaxonForm = {
authority: '',
};
export const emptyLocationForm = {
name: '',
notes: '',
isEnabled: true,
};
export const emptyActionForm = {
name: '',
description: '',
@@ -47,66 +49,83 @@ export const emptyResourceForm = {
isEnabled: true,
};
export const emptyActivityForm = {
name: '',
actions: [] as CareActivityActionFormState[],
notes: '',
isEnabled: true,
};
export type CareActivityActionResourceFormState = {
actionResourceId: string;
quantity: string;
unit: string;
notes: string;
};
export type CareActivityActionFormState = {
careActionId: string;
resources: CareActivityActionResourceFormState[];
};
export const emptyFlagDefinitionForm = {
name: '',
category: 'Pest',
color: '#f2f2f2',
isEnabled: true,
};
export const emptyPlantFlagForm = {
plantFlagDefinitionId: '',
severity: 'medium',
startedOn: '',
notes: '',
};
export type CareLogResourceFormState = {
actionResourceId: string;
quantity: string;
unit: string;
};
export const emptyCareLogForm = {
plantId: '',
careActionId: '',
performedOn: '',
notes: '',
resources: [] as CareLogResourceFormState[],
export const emptyBulkScheduleForm = {
careActivityId: '',
everyDays: '7',
isEnabled: true,
plantIds: [] as string[],
};
export type PlantFormState = typeof emptyPlantForm;
export type TaxonFormState = typeof emptyTaxonForm;
export type LocationFormState = typeof emptyLocationForm;
export type ActionFormState = typeof emptyActionForm;
export type ResourceFormState = typeof emptyResourceForm;
export type ActivityFormState = typeof emptyActivityForm;
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
export type PlantFlagFormState = typeof emptyPlantFlagForm;
export type CareLogFormState = typeof emptyCareLogForm;
export type View = 'home' | 'plants' | 'taxa' | 'actions' | 'resources' | 'flags';
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'actions' | 'resources' | 'activities' | 'flags';
export function toPlantForm(plant: Plant): PlantFormState {
return {
nickname: plant.nickname,
taxonId: String(plant.taxonId),
location: plant.location,
careSchedules: plant.careSchedules.map((schedule) => ({
careActionId: String(schedule.careActionId),
everyDays: String(schedule.everyDays),
isEnabled: schedule.isEnabled,
})),
};
}
export function toPlantPayload(form: PlantFormState): PlantPayload {
return {
nickname: form.nickname.trim(),
taxonId: Number(form.taxonId),
location: form.location.trim(),
careSchedules: form.careSchedules.map((schedule) => ({
careActionId: Number(schedule.careActionId),
everyDays: Number(schedule.everyDays),
isEnabled: schedule.isEnabled,
})),
taxonId: null,
locationId: null,
careSchedules: null,
};
}
export function toLocationForm(location: PlantLocation): LocationFormState {
return {
name: location.name,
notes: location.notes ?? '',
isEnabled: location.isEnabled,
};
}
export function toLocationPayload(form: LocationFormState): PlantLocationPayload {
return {
name: form.name.trim(),
notes: form.notes.trim() || null,
isEnabled: form.isEnabled,
};
}
@@ -166,10 +185,43 @@ export function toResourcePayload(form: ResourceFormState): ActionResourcePayloa
};
}
export function toActivityForm(activity: CareActivity): ActivityFormState {
return {
name: activity.name,
actions: activity.actions.map((action) => ({
careActionId: String(action.careActionId),
resources: action.resources.map((resource) => ({
actionResourceId: String(resource.actionResourceId),
quantity: resource.quantity === null ? '' : String(resource.quantity),
unit: resource.unit ?? '',
notes: resource.notes ?? '',
})),
})),
notes: activity.notes ?? '',
isEnabled: activity.isEnabled,
};
}
export function toActivityPayload(form: ActivityFormState): CareActivityPayload {
return {
name: form.name.trim(),
actions: form.actions.map((action) => ({
careActionId: Number(action.careActionId),
resources: action.resources.map((resource) => ({
actionResourceId: Number(resource.actionResourceId),
quantity: resource.quantity.trim() ? Number(resource.quantity) : null,
unit: resource.unit.trim() || null,
notes: resource.notes.trim() || null,
})),
})),
notes: form.notes.trim() || null,
isEnabled: form.isEnabled,
};
}
export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionFormState {
return {
name: flag.name,
category: flag.category,
color: flag.color,
isEnabled: flag.isEnabled,
};
@@ -178,7 +230,6 @@ export function toFlagDefinitionForm(flag: PlantFlagDefinition): FlagDefinitionF
export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFlagDefinitionPayload {
return {
name: form.name.trim(),
category: form.category.trim() || null,
color: form.color.trim() || null,
isEnabled: form.isEnabled,
};
@@ -187,12 +238,20 @@ export function toFlagDefinitionPayload(form: FlagDefinitionFormState): PlantFla
export function toPlantFlagPayload(form: PlantFlagFormState): AssignPlantFlagPayload {
return {
plantFlagDefinitionId: Number(form.plantFlagDefinitionId),
severity: form.severity || null,
startedOn: form.startedOn || null,
notes: form.notes.trim() || null,
};
}
export function toBulkSchedulePayload(form: BulkScheduleFormState): BulkPlantCareSchedulePayload {
return {
plantIds: form.plantIds.map((id) => Number(id)),
careActivityId: Number(form.careActivityId),
everyDays: Number(form.everyDays),
isEnabled: form.isEnabled,
};
}
export function formatTaxon(taxon: PlantTaxon) {
const botanical = `${taxon.genus} ${taxon.species}`.trim();
return taxon.name === botanical ? taxon.name : `${taxon.name} (${botanical})`;
+269 -98
View File
@@ -1,21 +1,24 @@
:root {
color: #111;
background: #fff;
font-family: Arial, Helvetica, sans-serif;
color: #161616;
background: #f4f4f4;
font-family: 'IBM Plex Sans', Arial, Helvetica, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--border: #a7a7a7;
--rule: #8f8f8f;
--muted: #555;
--border: #e0e0e0;
--rule: #c6c6c6;
--muted: #6f6f6f;
--panel: #fff;
--side: #f7f7f7;
--row: #f7f7f7;
--hover: #f1f5fb;
--link: #0645ad;
--side: #f4f4f4;
--row: #f4f4f4;
--hover: #e8f0fe;
--link: #0f62fe;
--focus: #0f62fe;
--field: #f4f4f4;
--layer: #fff;
--warning: #8a5a00;
--danger: #b00000;
--ok: #126126;
--danger: #da1e28;
--ok: #198038;
}
* {
@@ -27,7 +30,7 @@ body {
min-width: 320px;
min-height: 100vh;
overflow-x: hidden;
background: #fff;
background: #f4f4f4;
font-size: 13px;
}
@@ -54,8 +57,8 @@ button:disabled {
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid #111;
outline-offset: 1px;
outline: 2px solid var(--focus);
outline-offset: -2px;
}
h1,
@@ -112,11 +115,12 @@ p {
grid-template-columns: minmax(190px, 260px) minmax(260px, 680px) minmax(120px, 1fr);
align-items: center;
gap: 18px;
min-height: 74px;
min-height: 64px;
padding: 10px 22px;
border-bottom: 1px solid var(--rule);
background: #fff;
box-shadow: 0 1px 0 #e8e8e8 inset, 0 1px 3px rgb(0 0 0 / 8%);
border-bottom: 0;
background: #161616;
color: #f4f4f4;
box-shadow: none;
}
.catalog-brand {
@@ -125,23 +129,23 @@ p {
}
.catalog-logo {
color: #111;
color: #fff;
font-size: 1.65rem;
font-weight: 800;
font-weight: 600;
line-height: 1;
}
.catalog-subtitle,
.catalog-contact span {
color: #555;
color: #c6c6c6;
font-size: 0.78rem;
font-weight: 700;
font-weight: 500;
}
.catalog-contact {
display: grid;
justify-items: end;
color: #111;
color: #fff;
white-space: nowrap;
}
@@ -159,9 +163,10 @@ p {
max-width: 680px;
min-height: 36px;
padding: 0 12px;
border: 1px solid #777;
background: #fff;
color: var(--link);
border: 0;
border-bottom: 1px solid #8d8d8d;
background: #393939;
color: #78a9ff;
}
.search-field input {
@@ -170,12 +175,12 @@ p {
border: 0;
outline: 0;
background: transparent;
color: #111;
color: #fff;
font-size: 0.95rem;
}
.search-field input::placeholder {
color: var(--muted);
color: #c6c6c6;
}
.catalog-layout {
@@ -191,28 +196,56 @@ p {
.catalog-sidebar {
position: sticky;
top: 74px;
top: 64px;
min-width: 0;
min-height: calc(100vh - 74px);
min-height: calc(100vh - 64px);
padding: 16px 16px 24px 22px;
border-right: 1px solid var(--rule);
border-right: 1px solid var(--border);
background: var(--side);
}
.catalog-sidebar h2,
.catalog-help h3 {
margin: 0 0 8px;
color: #111;
color: #161616;
font-size: 0.95rem;
}
.catalog-nav {
display: grid;
min-width: 0;
gap: 2px;
gap: 14px;
}
.nav-group {
display: grid;
gap: 0;
}
.nav-group h3 {
margin: 0 0 3px;
padding: 0 12px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
}
.catalog-nav button {
position: relative;
min-width: 0;
min-height: 36px;
padding: 0 12px;
border: 0;
background: transparent;
color: #525252;
font-size: 0.9rem;
font-weight: 400;
text-align: left;
text-decoration: none;
overflow-wrap: anywhere;
}
.catalog-nav button,
.text-button {
min-width: 0;
min-height: 0;
@@ -227,15 +260,29 @@ p {
overflow-wrap: anywhere;
}
.catalog-nav button:hover,
.catalog-nav button:hover {
background: #e8e8e8;
color: #161616;
}
.text-button:hover {
color: #002f7a;
color: #0043ce;
}
.catalog-nav button[aria-current='page'] {
color: #111;
font-weight: 700;
text-decoration: none;
background: #e0e0e0;
color: #161616;
font-weight: 500;
}
.catalog-nav button[aria-current='page']::before {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: var(--link);
content: '';
}
.catalog-help {
@@ -262,12 +309,12 @@ p {
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
border-bottom: 2px solid #111;
border-bottom: 1px solid var(--border);
}
.catalog-page-title h1 {
margin: 0 0 6px;
font-weight: 700;
font-weight: 500;
}
.content {
@@ -280,9 +327,9 @@ p {
.eyebrow {
margin: 0 0 3px;
color: #555;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
}
@@ -303,7 +350,7 @@ p {
align-items: center;
gap: 10px;
padding: 10px 12px;
border-top: 3px solid var(--rule);
border-top: 0;
}
.summary-panel h2 {
@@ -335,7 +382,7 @@ p {
gap: 10px;
min-height: 28px;
padding-bottom: 3px;
border-bottom: 1px solid #111;
border-bottom: 1px solid var(--border);
}
.primary-action,
@@ -346,17 +393,19 @@ p {
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid #777;
border: 0;
border-radius: 0;
background: #f2f2f2;
color: #111;
box-shadow: inset 0 1px 0 #fff;
font-weight: 700;
background: #e0e0e0;
color: #161616;
box-shadow: none;
font-weight: 500;
}
.primary-action {
min-height: 34px;
padding: 0 12px;
background: var(--link);
color: #fff;
}
.small-action {
@@ -381,10 +430,13 @@ p {
color: var(--danger);
}
.primary-action:hover:not(:disabled),
.primary-action:hover:not(:disabled) {
background: #0353e9;
}
.small-action:hover:not(:disabled),
.icon-button:hover:not(:disabled) {
background: #e5e5e5;
background: #d0d0d0;
}
.plant-mark svg,
@@ -438,6 +490,11 @@ p {
padding: 7px 8px;
}
.task-row-group {
border-top: 1px solid var(--rule);
background: #f4f4f4;
}
.plant-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -446,6 +503,29 @@ p {
padding: 7px 8px;
}
.check-row {
cursor: pointer;
}
.check-row > span {
display: flex;
align-items: center;
gap: 8px;
}
.check-row input {
width: 15px;
height: 15px;
accent-color: var(--link);
}
.check-row small {
display: block;
color: #444;
font-size: 0.86rem;
line-height: 1.25;
}
.plant-card {
display: grid;
grid-template-columns:
@@ -499,16 +579,16 @@ p {
}
dt {
color: #555;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
line-height: 1;
}
dd {
margin: 3px 0 0;
font-size: 0.84rem;
font-weight: 700;
font-weight: 500;
line-height: 1.15;
overflow-wrap: anywhere;
}
@@ -524,7 +604,7 @@ dd {
.empty-state {
padding: 8px;
border-bottom: 1px solid var(--border);
background: #fafafa;
background: #f4f4f4;
}
.status-dot {
@@ -554,11 +634,11 @@ dd {
border-radius: 0;
background: #fff;
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
}
.status-pill.due {
background: #fff0f0;
background: #fff1f1;
color: var(--danger);
}
@@ -568,17 +648,16 @@ dd {
}
.status-pill.ok {
background: #eef9ee;
background: #defbe6;
color: var(--ok);
}
.status-pill.unscheduled {
background: #eee;
color: #555;
background: #e0e0e0;
color: var(--muted);
}
.plant-form,
.quick-taxon-fields {
.plant-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: start;
@@ -586,28 +665,27 @@ dd {
}
.plant-form label,
.quick-taxon label,
.flag-assignment-form label {
display: grid;
gap: 4px;
color: #333;
color: #525252;
font-size: 0.78rem;
font-weight: 800;
font-weight: 500;
}
.plant-form input,
.plant-form select,
.quick-taxon input,
.flag-assignment-form input,
.flag-assignment-form select {
width: 100%;
min-height: 30px;
padding: 0 7px;
border: 1px solid #888;
min-height: 34px;
padding: 0 8px;
border: 0;
border-bottom: 1px solid #8d8d8d;
border-radius: 0;
background: #fff;
color: #111;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 10%);
background: var(--field);
color: #161616;
box-shadow: none;
}
.plant-form input[type='color'] {
@@ -625,15 +703,15 @@ dd {
.plant-form .toggle-field {
align-self: end;
padding: 5px 7px;
border: 1px solid #888;
background: #f7f7f7;
border: 1px solid var(--border);
background: #f4f4f4;
}
.plant-form .toggle-field input,
.check-option input {
width: 15px;
min-height: 15px;
accent-color: #111;
accent-color: var(--link);
}
.taxon-picker {
@@ -644,19 +722,19 @@ dd {
.resource-picker {
display: grid;
grid-column: 1 / -1;
gap: 6px;
gap: 10px;
min-width: 0;
margin: 0;
padding: 8px;
padding: 12px;
border: 1px solid var(--border);
background: #fff;
}
.resource-picker legend {
padding: 0 4px;
color: #111;
color: #161616;
font-size: 0.82rem;
font-weight: 700;
font-weight: 500;
}
.resource-entry {
@@ -665,7 +743,7 @@ dd {
align-items: center;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--border);
}
.resource-entry:last-child {
@@ -678,16 +756,96 @@ dd {
gap: 8px;
}
.schedule-interval {
grid-template-columns: minmax(90px, 120px);
.activity-action-list,
.activity-resource-list {
display: grid;
gap: 6px;
min-width: 0;
}
.quick-taxon {
.activity-builder-header {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr) 28px;
gap: 12px;
padding-top: 10px;
border-top: 1px solid var(--border);
background: #fafafa;
padding: 0 12px 4px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 500;
text-transform: uppercase;
}
.activity-config-row {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
padding: 0;
border: 0;
border-bottom: 1px solid var(--border);
background: #fff;
}
.activity-resource-header,
.activity-resource-row,
.log-resource-header,
.log-resource-row {
display: grid;
align-items: end;
gap: 8px;
}
.activity-config-row > .icon-button {
min-width: 24px;
min-height: 24px;
margin-top: 8px;
margin-right: 12px;
}
.activity-resource-header,
.activity-resource-row {
grid-template-columns: minmax(160px, 1.2fr) minmax(70px, 90px) minmax(80px, 110px) minmax(140px, 1fr) auto;
}
.log-resource-header,
.activity-resource-header {
padding: 8px 0 0;
color: var(--muted);
font-size: 0.78rem;
font-weight: 500;
text-transform: uppercase;
}
.activity-resource-row,
.log-resource-row {
min-height: 32px;
padding: 0;
}
.log-resource-header,
.log-resource-row {
grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto;
}
.log-resource-row .icon-button,
.activity-resource-row .icon-button {
min-width: 24px;
min-height: 24px;
}
.activity-resource-list {
padding-bottom: 8px;
}
.activity-resource-list > .small-action {
justify-self: start;
}
.activity-action-cell {
padding: 8px 0 8px 12px;
}
.schedule-interval {
grid-template-columns: minmax(90px, 120px);
}
.plant-detail-meta,
@@ -715,13 +873,13 @@ dd {
}
.plant-detail-meta span {
color: #555;
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
}
.plant-detail-meta strong {
color: #111;
color: #161616;
font-size: 0.9rem;
line-height: 1.2;
}
@@ -745,7 +903,7 @@ dd {
gap: 6px;
min-height: 28px;
padding-bottom: 3px;
border-bottom: 1px solid #111;
border-bottom: 1px solid var(--border);
}
.detail-section-heading h3 {
@@ -782,16 +940,16 @@ dd {
align-items: center;
padding: 0 7px;
border: 1px solid #999;
color: #111;
color: #161616;
font-size: 0.74rem;
font-weight: 700;
font-weight: 500;
line-height: 1;
}
.flag-assignment-form {
padding: 8px;
border-bottom: 1px solid var(--border);
background: #fafafa;
background: #f4f4f4;
}
.flag-assignment-form label {
@@ -855,15 +1013,26 @@ dd {
display: flex;
width: 100%;
max-width: 100%;
gap: 6px;
gap: 12px;
overflow-x: auto;
overscroll-behavior-x: contain;
padding-bottom: 2px;
}
.nav-group {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.nav-group h3 {
display: none;
}
.catalog-nav button {
flex: 0 0 auto;
padding: 4px 2px;
padding: 0 10px;
font-size: 0.78rem;
white-space: nowrap;
}
@@ -882,9 +1051,11 @@ dd {
.detail-row,
.plant-detail-grid,
.plant-form,
.quick-taxon-fields,
.resource-entry,
.resource-amount,
.activity-config-row,
.activity-resource-header,
.activity-resource-row,
.flag-assignment-form {
grid-template-columns: 1fr;
}
+144 -25
View File
@@ -4,18 +4,24 @@ namespace plant_manager
{
public record CreatePlantRequest(
string Nickname,
int TaxonId,
string? Location,
int? TaxonId,
int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
public record UpdatePlantRequest(
string Nickname,
int TaxonId,
string? Location,
int? TaxonId,
int? LocationId,
IReadOnlyList<SavePlantCareScheduleRequest>? CareSchedules);
public record SavePlantCareScheduleRequest(
int CareActionId,
int CareActivityId,
int? EveryDays,
bool IsEnabled);
public record BulkSavePlantCareScheduleRequest(
IReadOnlyList<int> PlantIds,
int CareActivityId,
int? EveryDays,
bool IsEnabled);
@@ -27,6 +33,11 @@ namespace plant_manager
string? Variety,
string? Authority);
public record SavePlantLocationRequest(
string Name,
string? Notes,
bool IsEnabled);
public record SaveCareActionRequest(
string Name,
string? Description,
@@ -38,34 +49,85 @@ namespace plant_manager
string? Notes,
bool IsEnabled);
public record SavePlantFlagDefinitionRequest(
public record SaveCareActivityRequest(
string Name,
IReadOnlyList<SaveCareActivityActionRequest> Actions,
string? Notes,
bool IsEnabled);
public record SaveCareActivityActionRequest(
int CareActionId,
IReadOnlyList<SaveCareActivityActionResourceRequest>? Resources);
public record SaveCareActivityActionResourceRequest(
int ActionResourceId,
decimal? Quantity,
string? Unit,
string? Notes);
public record CareActivityActionResourceDto(
int ActionResourceId,
string Name,
string? Category,
decimal? Quantity,
string? Unit,
string? Notes)
{
public static CareActivityActionResourceDto FromCareActivityActionResource(
CareActivityActionResource resource) =>
new(
resource.ActionResourceId,
resource.ActionResource.Name,
resource.ActionResource.Category,
resource.Quantity,
resource.Unit,
resource.Notes);
}
public record CareActivityActionDto(
int CareActionId,
string Name,
string? Description,
int SortOrder,
IReadOnlyList<CareActivityActionResourceDto> Resources)
{
public static CareActivityActionDto FromCareActivityAction(CareActivityAction activityAction) =>
new(
activityAction.CareActionId,
activityAction.CareAction.Name,
activityAction.CareAction.Description,
activityAction.SortOrder,
activityAction.Resources
.OrderBy(resource => resource.ActionResource.Name)
.Select(CareActivityActionResourceDto.FromCareActivityActionResource)
.ToList());
}
public record SavePlantFlagDefinitionRequest(
string Name,
string? Color,
bool IsEnabled);
public record AssignPlantFlagRequest(
int PlantFlagDefinitionId,
string? Severity,
DateOnly? StartedOn,
string? Notes);
public record UpdatePlantFlagRequest(
string? Severity,
DateOnly? StartedOn,
DateOnly? ResolvedOn,
string? Notes);
public record CreateActionLogRequest(
int PlantId,
int CareActionId,
int CareActivityId,
string? Notes,
DateOnly? PerformedOn,
IReadOnlyList<ActionLogResourceRequest>? Resources);
public record UpdateActionLogRequest(
int PlantId,
int CareActionId,
int CareActivityId,
string? Notes,
DateOnly PerformedOn,
IReadOnlyList<ActionLogResourceRequest>? Resources);
@@ -75,6 +137,13 @@ namespace plant_manager
decimal? Quantity,
string? Unit);
public record BulkCompleteCareTasksRequest(
int CareActivityId,
IReadOnlyList<int> PlantIds,
DateOnly? PerformedOn,
string? Notes,
IReadOnlyList<ActionLogResourceRequest>? Resources);
public record PlantTaxonDto(
int Id,
string Name,
@@ -95,6 +164,16 @@ namespace plant_manager
taxon.Authority);
}
public record PlantLocationDto(
int Id,
string Name,
string? Notes,
bool IsEnabled)
{
public static PlantLocationDto FromLocation(PlantLocation location) =>
new(location.Id, location.Name, location.Notes, location.IsEnabled);
}
public record CareActionDto(
int Id,
string Name,
@@ -116,11 +195,35 @@ namespace plant_manager
new(resource.Id, resource.Name, resource.Category, resource.Notes, resource.IsEnabled);
}
public record CareActivityDto(
int Id,
string Name,
int CareActionId,
string Action,
IReadOnlyList<CareActivityActionDto> Actions,
string? Notes,
bool IsEnabled)
{
public static CareActivityDto FromCareActivity(CareActivity activity) =>
new(
activity.Id,
activity.Name,
activity.PrimaryAction()?.Id ?? 0,
activity.PrimaryAction()?.Name ?? activity.Name,
activity.Actions
.OrderBy(action => action.SortOrder)
.Select(CareActivityActionDto.FromCareActivityAction)
.ToList(),
activity.Notes,
activity.IsEnabled);
}
public record PlantDto(
int Id,
string Nickname,
int TaxonId,
int? TaxonId,
string Taxon,
int? LocationId,
string Location,
string NextCare,
string Status,
@@ -131,10 +234,10 @@ namespace plant_manager
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var schedules = plant.CareSchedules
.OrderBy(schedule => schedule.CareAction.Name)
.OrderBy(schedule => schedule.CareActivity.Name)
.Select(schedule => PlantCareScheduleDto.FromSchedule(
schedule,
GetLatestPerformedOn(plant, schedule.CareActionId),
GetLatestPerformedOn(plant, schedule.CareActivityId),
today))
.ToList();
var nextCare = schedules
@@ -148,8 +251,9 @@ namespace plant_manager
plant.Id,
plant.Nickname,
plant.TaxonId,
$"{plant.Taxon.Genus} {plant.Taxon.Species}",
plant.Location,
plant.Taxon is null ? "Unassigned" : $"{plant.Taxon.Genus} {plant.Taxon.Species}",
plant.LocationId,
plant.Location?.Name ?? "Unassigned",
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
PlantCareFormatter.GetStatus(nextCare, today),
plant.Flags
@@ -161,15 +265,16 @@ namespace plant_manager
schedules);
}
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActionId) =>
private static DateOnly? GetLatestPerformedOn(Plant plant, int careActivityId) =>
plant.ActionLogs
.Where(log => log.CareActionId == careActionId)
.Where(log => log.CareActivityId == careActivityId)
.Select(log => (DateOnly?)log.PerformedOn)
.Max();
}
public record PlantCareScheduleDto(
int Id,
int CareActivityId,
int CareActionId,
string Action,
int EveryDays,
@@ -188,8 +293,9 @@ namespace plant_manager
return new PlantCareScheduleDto(
schedule.Id,
schedule.CareActivityId,
schedule.CareActionId,
schedule.CareAction.Name,
schedule.CareActivity.Name,
schedule.EveryDays,
lastPerformedOn,
PlantCareFormatter.FormatRelativeDate(lastPerformedOn, today, "Never"),
@@ -199,10 +305,26 @@ namespace plant_manager
}
}
internal static class CareActivityExtensions
{
public static CareAction? PrimaryAction(this CareActivity activity) =>
activity.Actions
.OrderBy(action => action.SortOrder)
.Select(action => action.CareAction)
.FirstOrDefault();
public static int PrimaryActionId(this CareActivity activity) =>
activity.Actions
.OrderBy(action => action.SortOrder)
.Select(action => action.CareActionId)
.FirstOrDefault();
}
public record CareTaskDto(
int Id,
int PlantId,
string PlantName,
int CareActivityId,
int CareActionId,
string Action,
string Due,
@@ -219,8 +341,9 @@ namespace plant_manager
schedule.Id,
schedule.PlantId,
schedule.Plant.Nickname,
schedule.CareActivityId,
schedule.CareActionId,
schedule.CareAction.Name,
schedule.CareActivity.Name,
PlantCareFormatter.FormatRelativeDate(nextCare, today, "Unscheduled"),
PlantCareFormatter.GetStatus(nextCare, today));
}
@@ -229,7 +352,6 @@ namespace plant_manager
public record PlantFlagDefinitionDto(
int Id,
string Name,
string Category,
string Color,
bool IsEnabled)
{
@@ -237,7 +359,6 @@ namespace plant_manager
new(
definition.Id,
definition.Name,
definition.Category,
definition.Color,
definition.IsEnabled);
}
@@ -246,9 +367,7 @@ namespace plant_manager
int Id,
int PlantFlagDefinitionId,
string Name,
string Category,
string Color,
string Severity,
DateOnly StartedOn,
DateOnly? ResolvedOn,
string? Notes)
@@ -258,9 +377,7 @@ namespace plant_manager
flag.Id,
flag.PlantFlagDefinitionId,
flag.Definition.Name,
flag.Definition.Category,
flag.Definition.Color,
flag.Severity,
flag.StartedOn,
flag.ResolvedOn,
flag.Notes);
@@ -270,6 +387,7 @@ namespace plant_manager
int Id,
int PlantId,
string PlantName,
int CareActivityId,
int CareActionId,
string Action,
string? Notes,
@@ -281,6 +399,7 @@ namespace plant_manager
log.Id,
log.PlantId,
log.Plant.Nickname,
log.CareActivityId,
log.CareActionId,
log.ActionNameSnapshot,
log.Notes,
+72 -5
View File
@@ -6,9 +6,13 @@ namespace plant_manager.Data
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
public DbSet<PlantTaxon> PlantTaxa { get; set; }
public DbSet<PlantLocation> PlantLocations { get; set; }
public DbSet<Plant> Plants { get; set; }
public DbSet<CareAction> CareActions { get; set; }
public DbSet<ActionResource> ActionResources { get; set; }
public DbSet<CareActivity> CareActivities { get; set; }
public DbSet<CareActivityAction> CareActivityActions { get; set; }
public DbSet<CareActivityActionResource> CareActivityActionResources { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<ActionLogResource> ActionLogResources { get; set; }
public DbSet<PlantCareSchedule> PlantCareSchedules { get; set; }
@@ -36,11 +40,25 @@ namespace plant_manager.Data
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Nickname).HasMaxLength(120).IsRequired();
entity.Property(e => e.Location).HasMaxLength(120).IsRequired();
entity.HasOne(e => e.Taxon)
.WithMany()
.HasForeignKey(e => e.TaxonId)
.OnDelete(DeleteBehavior.Restrict);
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Location)
.WithMany(e => e.Plants)
.HasForeignKey(e => e.LocationId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<PlantLocation>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.Property(e => e.IsEnabled).IsRequired();
entity.HasIndex(e => e.Name).IsUnique();
});
modelBuilder.Entity<CareAction>(entity =>
@@ -66,6 +84,48 @@ namespace plant_manager.Data
entity.HasIndex(e => e.Name).IsUnique();
});
modelBuilder.Entity<CareActivity>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.Property(e => e.IsEnabled).IsRequired();
entity.HasIndex(e => e.Name).IsUnique();
});
modelBuilder.Entity<CareActivityAction>(entity =>
{
entity.HasKey(e => new { e.CareActivityId, e.CareActionId });
entity.Property(e => e.SortOrder).IsRequired();
entity.HasIndex(e => new { e.CareActivityId, e.SortOrder }).IsUnique();
entity.HasOne(e => e.CareActivity)
.WithMany(e => e.Actions)
.HasForeignKey(e => e.CareActivityId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CareAction)
.WithMany(e => e.CareActivityActions)
.HasForeignKey(e => e.CareActionId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<CareActivityActionResource>(entity =>
{
entity.HasKey(e => new { e.CareActivityId, e.CareActionId, e.ActionResourceId });
entity.Property(e => e.Quantity).HasPrecision(10, 2);
entity.Property(e => e.Unit).HasMaxLength(40);
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasOne(e => e.CareActivityAction)
.WithMany(e => e.Resources)
.HasForeignKey(e => new { e.CareActivityId, e.CareActionId })
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ActionResource)
.WithMany(e => e.CareActivityActionResources)
.HasForeignKey(e => e.ActionResourceId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<ActionLog>(entity =>
{
entity.HasKey(e => e.Id);
@@ -79,6 +139,10 @@ namespace plant_manager.Data
.WithMany(e => e.ActionLogs)
.HasForeignKey(e => e.CareActionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.CareActivity)
.WithMany(e => e.ActionLogs)
.HasForeignKey(e => e.CareActivityId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<ActionLogResource>(entity =>
@@ -103,7 +167,8 @@ namespace plant_manager.Data
.ValueGeneratedOnAdd();
entity.Property(e => e.EveryDays).IsRequired();
entity.Property(e => e.IsEnabled).IsRequired();
entity.HasIndex(e => new { e.PlantId, e.CareActionId }).IsUnique();
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)
@@ -112,6 +177,10 @@ namespace plant_manager.Data
.WithMany(e => e.PlantCareSchedules)
.HasForeignKey(e => e.CareActionId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.CareActivity)
.WithMany(e => e.PlantCareSchedules)
.HasForeignKey(e => e.CareActivityId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<PlantFlagDefinition>(entity =>
@@ -120,7 +189,6 @@ namespace plant_manager.Data
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
entity.Property(e => e.Category).HasMaxLength(80).IsRequired();
entity.Property(e => e.Color).HasMaxLength(20).IsRequired();
entity.Property(e => e.IsEnabled).IsRequired();
entity.HasIndex(e => e.Name).IsUnique();
@@ -131,7 +199,6 @@ namespace plant_manager.Data
entity.HasKey(e => e.Id);
entity.Property(e => e.Id)
.ValueGeneratedOnAdd();
entity.Property(e => e.Severity).HasMaxLength(20).IsRequired();
entity.Property(e => e.Notes).HasMaxLength(1000);
entity.HasIndex(e => new { e.PlantId, e.PlantFlagDefinitionId, e.ResolvedOn });
entity.HasOne(e => e.Plant)
+184 -93
View File
@@ -26,13 +26,23 @@ namespace plant_manager.Data
new() { Name = "Pruners", Category = "Equipment", Notes = "Cutting tool for pruning or cleanup." }
];
private static readonly StarterCareActivity[] StarterCareActivities =
[
new("Water", [new("Water", ["Water"])]),
new("Repot with Potting Mix", [new("Repot", ["Potting Mix"])]),
new("Fertilize", [new("Fertilize", ["Fertilizer"])]),
new("Prune", [new("Prune", ["Pruners"])]),
new("Inspect", [new("Inspect", [])])
];
private static readonly PlantFlagDefinition[] StarterFlagDefinitions =
[
new() { Name = "Spider mites", Category = "Pest", Color = "#ffe4e1" },
new() { Name = "Fungus gnats", Category = "Pest", Color = "#fff4cc" },
new() { Name = "Quarantine", Category = "Workflow", Color = "#e8eef8" },
new() { Name = "Needs repotting", Category = "Condition", Color = "#e9f5e7" },
new() { Name = "Watch closely", Category = "Workflow", Color = "#eeeeee" }
new() { Name = "Spider mites", Color = "#ffe4e1" },
new() { Name = "Fungus gnats", Color = "#fff4cc" },
new() { Name = "Dying", Color = "#ffd6d6" },
new() { Name = "Quarantine", Color = "#e8eef8" },
new() { Name = "Needs repotting", Color = "#e9f5e7" },
new() { Name = "Watch closely", Color = "#eeeeee" }
];
private static readonly StarterTaxon[] StarterTaxa =
@@ -57,14 +67,42 @@ namespace plant_manager.Data
new("Money Tree", "Pachira", "aquatica")
];
private static readonly StarterPlant[] StarterPlants =
[
new("Chinese Money Plant 1", "Chinese Money Plant", "Plant cart"),
new("Chinese Money Plant 2", "Chinese Money Plant", "Plant cart"),
new("Chinese Money Plant 3", "Chinese Money Plant", "Plant cart"),
new("Croton Petra", "Croton Petra", "Plant cart"),
new("Parallel Peperomia", "Parallel Peperomia", "Plant cart"),
new("Marble Peperomia", "Marble Peperomia", "Plant cart"),
new("Silver Squill", "Silver Squill", "Plant cart"),
new("Fiddle-leaf Fig", "Fiddle-leaf Fig", "Living room"),
new("Ficus Audrey", "Ficus Audrey", "Plant cart"),
new("Dumbcane 1", "Dumbcane", "Plant cart"),
new("Dumbcane 2", "Dumbcane", "Plant cart"),
new("Kris Plant", "Kris Plant", "Plant cart"),
new("Lucky Bamboo", "Lucky Bamboo", "Plant cart"),
new("Common Ivy 1", "Common Ivy", "Plant cart"),
new("Common Ivy 2", "Common Ivy", "Plant cart"),
new("Peacock Plant 1", "Peacock Plant", "Plant cart"),
new("Peacock Plant 2", "Peacock Plant", "Plant cart"),
new("False Shamrock", "False Shamrock", "Plant cart"),
new("Pinstripe Plant", "Pinstripe Plant", "Computer desk"),
new("Monstera Thai Constellation", "Monstera Thai Constellation", "Plant cart"),
new("Pothos", "Pothos", "Plant cart"),
new("Moth orchid", "Moth orchid", "Plant cart"),
new("Money Tree", "Money Tree", "Computer desk")
];
public static void Seed(ApplicationDbContext db)
{
SeedCareActions(db);
SeedActionResources(db);
SeedCareActivities(db);
SeedPlantFlags(db);
SeedStarterTaxa(db);
SeedPlantLocations(db);
SeedStarterPlants(db);
SeedDefaultCareSchedules(db);
}
private static void SeedCareActions(ApplicationDbContext db)
@@ -93,33 +131,6 @@ namespace plant_manager.Data
db.SaveChanges();
}
private static void SeedDefaultCareSchedules(ApplicationDbContext db)
{
var waterAction = db.CareActions
.FirstOrDefault(action => action.Name == "Water");
if (waterAction is null)
{
return;
}
var plantsMissingWaterSchedule = db.Plants
.Include(plant => plant.CareSchedules)
.Where(plant => !plant.CareSchedules.Any(schedule => schedule.CareActionId == waterAction.Id))
.ToList();
foreach (var plant in plantsMissingWaterSchedule)
{
plant.CareSchedules.Add(new PlantCareSchedule
{
CareActionId = waterAction.Id,
EveryDays = 7,
IsEnabled = true
});
}
db.SaveChanges();
}
private static void SeedActionResources(ApplicationDbContext db)
{
var existingResourceNames = db.ActionResources
@@ -147,6 +158,65 @@ namespace plant_manager.Data
db.SaveChanges();
}
private static void SeedCareActivities(ApplicationDbContext db)
{
var existingActivityNames = db.CareActivities
.Select(activity => activity.Name)
.ToList();
var actionsByName = db.CareActions.ToDictionary(action => action.Name, StringComparer.OrdinalIgnoreCase);
var resourcesByName = db.ActionResources.ToDictionary(resource => resource.Name, StringComparer.OrdinalIgnoreCase);
var missingActivities = StarterCareActivities
.Where(starterActivity => !existingActivityNames.Any(existingName =>
string.Equals(existingName, starterActivity.Name, StringComparison.OrdinalIgnoreCase)))
.Select(starterActivity =>
{
var actions = starterActivity.Actions
.Select((starterAction, index) => actionsByName.TryGetValue(starterAction.Name, out var action)
? new CareActivityAction
{
CareActionId = action.Id,
CareAction = action,
SortOrder = index,
Resources = starterAction.ResourceNames
.Select(resourceName => resourcesByName.TryGetValue(resourceName, out var resource)
? new CareActivityActionResource
{
ActionResourceId = resource.Id,
ActionResource = resource
}
: null)
.OfType<CareActivityActionResource>()
.ToList()
}
: null)
.OfType<CareActivityAction>()
.ToList();
if (actions.Count != starterActivity.Actions.Count
|| actions.Zip(starterActivity.Actions).Any(pair => pair.First.Resources.Count != pair.Second.ResourceNames.Count))
{
return null;
}
return new CareActivity
{
Name = starterActivity.Name,
IsEnabled = true,
Actions = actions
};
})
.OfType<CareActivity>()
.ToList();
if (missingActivities.Count == 0)
{
return;
}
db.CareActivities.AddRange(missingActivities);
db.SaveChanges();
}
private static void SeedPlantFlags(ApplicationDbContext db)
{
var existingFlagNames = db.PlantFlagDefinitions
@@ -159,7 +229,6 @@ namespace plant_manager.Data
.Select(starterFlag => new PlantFlagDefinition
{
Name = starterFlag.Name,
Category = starterFlag.Category,
Color = starterFlag.Color,
IsEnabled = starterFlag.IsEnabled
})
@@ -207,78 +276,100 @@ namespace plant_manager.Data
}
var taxaByName = db.PlantTaxa.ToDictionary(taxon => taxon.Name, StringComparer.OrdinalIgnoreCase);
var locationsByName = db.PlantLocations.ToDictionary(location => location.Name, StringComparer.OrdinalIgnoreCase);
var flagsByName = db.PlantFlagDefinitions.ToDictionary(flag => flag.Name, StringComparer.OrdinalIgnoreCase);
PlantTaxon Taxon(string name) => taxaByName[name];
PlantLocation Location(string name) => locationsByName[name];
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var starterPlants = StarterPlants
.Select(starterPlant => new Plant
{
Nickname = starterPlant.Nickname,
Taxon = Taxon(starterPlant.TaxonName),
Location = Location(starterPlant.Location)
})
.ToList();
var starterPlants = new[]
{
new
{
Plant = new Plant
{
Nickname = "Pothos",
Taxon = Taxon("Pothos"),
Location = "Living room"
},
InitialWateredOn = today.AddDays(-7),
WaterIntervalDays = 7
},
new
{
Plant = new Plant
{
Nickname = "Money Tree",
Taxon = Taxon("Money Tree"),
Location = "Bedroom"
},
InitialWateredOn = today.AddDays(-13),
WaterIntervalDays = 14
},
new
{
Plant = new Plant
{
Nickname = "Chinese Money Plant",
Taxon = Taxon("Chinese Money Plant"),
Location = "Kitchen"
},
InitialWateredOn = today.AddDays(-2),
WaterIntervalDays = 6
}
};
db.Plants.AddRange(starterPlants.Select(starterPlant => starterPlant.Plant));
db.Plants.AddRange(starterPlants);
db.SaveChanges();
var waterAction = db.CareActions.FirstOrDefault(action => action.Name == "Water");
if (waterAction is null)
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var starterFlags = new List<PlantFlag>();
void AddFlag(string plantNickname, string flagName)
{
if (!flagsByName.TryGetValue(flagName, out var flag))
{
return;
}
var plant = starterPlants.FirstOrDefault(item =>
string.Equals(item.Nickname, plantNickname, StringComparison.OrdinalIgnoreCase));
if (plant is null)
{
return;
}
starterFlags.Add(new PlantFlag
{
PlantId = plant.Id,
PlantFlagDefinitionId = flag.Id,
StartedOn = today
});
}
AddFlag("Pinstripe Plant", "Dying");
AddFlag("Pinstripe Plant", "Spider mites");
AddFlag("Pinstripe Plant", "Quarantine");
AddFlag("Chinese Money Plant 1", "Quarantine");
AddFlag("Chinese Money Plant 2", "Quarantine");
AddFlag("Chinese Money Plant 3", "Quarantine");
AddFlag("Ficus Audrey", "Needs repotting");
db.PlantFlags.AddRange(starterFlags);
db.SaveChanges();
}
private static void SeedPlantLocations(ApplicationDbContext db)
{
var starterLocationNames = StarterPlants
.Select(plant => plant.Location)
.Append("Unassigned")
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var existingLocationNames = db.PlantLocations
.Select(location => location.Name)
.ToList();
var missingLocations = starterLocationNames
.Where(starterLocation => !existingLocationNames.Any(existingName =>
string.Equals(existingName, starterLocation, StringComparison.OrdinalIgnoreCase)))
.Select(starterLocation => new PlantLocation
{
Name = starterLocation,
IsEnabled = true
})
.ToList();
if (missingLocations.Count == 0)
{
return;
}
foreach (var starterPlant in starterPlants)
{
starterPlant.Plant.CareSchedules.Add(new PlantCareSchedule
{
CareActionId = waterAction.Id,
EveryDays = starterPlant.WaterIntervalDays,
IsEnabled = true
});
db.ActionLogs.Add(new ActionLog
{
PlantId = starterPlant.Plant.Id,
CareActionId = waterAction.Id,
ActionNameSnapshot = waterAction.Name,
Notes = "Starter watering history.",
PerformedOn = starterPlant.InitialWateredOn
});
}
db.PlantLocations.AddRange(missingLocations);
db.SaveChanges();
}
private sealed record StarterTaxon(string Name, string Genus, string Species);
private sealed record StarterCareActivity(string Name, IReadOnlyList<StarterCareActivityAction> Actions);
private sealed record StarterCareActivityAction(string Name, IReadOnlyList<string> ResourceNames);
private sealed record StarterPlant(
string Nickname,
string TaxonName,
string Location);
}
}
@@ -11,7 +11,7 @@ using plant_manager.Data;
namespace plant_manager.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260518153034_InitialCreate")]
[Migration("20260520180017_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
@@ -34,6 +34,9 @@ namespace plant_manager.Data.Migrations
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
@@ -48,6 +51,8 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId");
b.ToTable("ActionLogs");
@@ -132,27 +137,104 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("CareActivities");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("CareActivityId", "CareActionId");
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId", "SortOrder")
.IsUnique();
b.ToTable("CareActivityActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("Unit")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.HasKey("CareActivityId", "CareActionId", "ActionResourceId");
b.HasIndex("ActionResourceId");
b.ToTable("CareActivityActionResources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int?>("LocationId")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int>("TaxonId")
b.Property<int?>("TaxonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LocationId");
b.HasIndex("TaxonId");
b.ToTable("Plants");
@@ -167,6 +249,9 @@ namespace plant_manager.Data.Migrations
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
@@ -180,7 +265,11 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActionId");
b.HasIndex("PlantId", "CareActionId")
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActionId");
b.HasIndex("PlantId", "CareActivityId")
.IsUnique();
b.ToTable("PlantCareSchedules");
@@ -205,11 +294,6 @@ namespace plant_manager.Data.Migrations
b.Property<DateOnly?>("ResolvedOn")
.HasColumnType("TEXT");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly>("StartedOn")
.HasColumnType("TEXT");
@@ -228,11 +312,6 @@ namespace plant_manager.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
@@ -254,6 +333,32 @@ namespace plant_manager.Data.Migrations
b.ToTable("PlantFlagDefinitions");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PlantLocations");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
{
b.Property<int>("Id")
@@ -300,6 +405,12 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("ActionLogs")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("ActionLogs")
.HasForeignKey("PlantId")
@@ -308,6 +419,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant");
});
@@ -330,13 +443,57 @@ namespace plant_manager.Data.Migrations
b.Navigation("ActionResource");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
.WithMany("CareActivityActions")
.HasForeignKey("CareActionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("Actions")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
.WithMany("CareActivityActionResources")
.HasForeignKey("ActionResourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction")
.WithMany("Resources")
.HasForeignKey("CareActivityId", "CareActionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActionResource");
b.Navigation("CareActivityAction");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
.WithMany("Plants")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
.WithMany()
.HasForeignKey("TaxonId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Location");
b.Navigation("Taxon");
});
@@ -349,6 +506,12 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("PlantCareSchedules")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSchedules")
.HasForeignKey("PlantId")
@@ -357,6 +520,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant");
});
@@ -387,15 +552,33 @@ namespace plant_manager.Data.Migrations
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
{
b.Navigation("ActionLogResources");
b.Navigation("CareActivityActionResources");
});
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
{
b.Navigation("ActionLogs");
b.Navigation("CareActivityActions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Navigation("ActionLogs");
b.Navigation("Actions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Navigation("Resources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Navigation("ActionLogs");
@@ -409,6 +592,11 @@ namespace plant_manager.Data.Migrations
{
b.Navigation("PlantFlags");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Navigation("Plants");
});
#pragma warning restore 612, 618
}
}
@@ -42,6 +42,21 @@ namespace plant_manager.Data.Migrations
table.PrimaryKey("PK_CareActions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CareActivities",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareActivities", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PlantFlagDefinitions",
columns: table => new
@@ -49,7 +64,6 @@ namespace plant_manager.Data.Migrations
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Category = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
Color = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
@@ -58,6 +72,21 @@ namespace plant_manager.Data.Migrations
table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PlantLocations",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlantLocations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PlantTaxa",
columns: table => new
@@ -76,25 +105,84 @@ namespace plant_manager.Data.Migrations
table.PrimaryKey("PK_PlantTaxa", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CareActivityActions",
columns: table => new
{
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareActivityActions", x => new { x.CareActivityId, x.CareActionId });
table.ForeignKey(
name: "FK_CareActivityActions_CareActions_CareActionId",
column: x => x.CareActionId,
principalTable: "CareActions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CareActivityActions_CareActivities_CareActivityId",
column: x => x.CareActivityId,
principalTable: "CareActivities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Plants",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TaxonId = table.Column<int>(type: "INTEGER", nullable: false),
Nickname = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
Location = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false)
TaxonId = table.Column<int>(type: "INTEGER", nullable: true),
LocationId = table.Column<int>(type: "INTEGER", nullable: true),
Nickname = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Plants", x => x.Id);
table.ForeignKey(
name: "FK_Plants_PlantLocations_LocationId",
column: x => x.LocationId,
principalTable: "PlantLocations",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Plants_PlantTaxa_TaxonId",
column: x => x.TaxonId,
principalTable: "PlantTaxa",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "CareActivityActionResources",
columns: table => new
{
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
ActionResourceId = table.Column<int>(type: "INTEGER", nullable: false),
Quantity = table.Column<decimal>(type: "TEXT", precision: 10, scale: 2, nullable: true),
Unit = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CareActivityActionResources", x => new { x.CareActivityId, x.CareActionId, x.ActionResourceId });
table.ForeignKey(
name: "FK_CareActivityActionResources_ActionResources_ActionResourceId",
column: x => x.ActionResourceId,
principalTable: "ActionResources",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_CareActivityActionResources_CareActivityActions_CareActivityId_CareActionId",
columns: x => new { x.CareActivityId, x.CareActionId },
principalTable: "CareActivityActions",
principalColumns: new[] { "CareActivityId", "CareActionId" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
@@ -105,6 +193,7 @@ namespace plant_manager.Data.Migrations
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
ActionNameSnapshot = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
PerformedOn = table.Column<DateOnly>(type: "TEXT", nullable: false)
@@ -118,6 +207,12 @@ namespace plant_manager.Data.Migrations
principalTable: "CareActions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ActionLogs_CareActivities_CareActivityId",
column: x => x.CareActivityId,
principalTable: "CareActivities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ActionLogs_Plants_PlantId",
column: x => x.PlantId,
@@ -134,6 +229,7 @@ namespace plant_manager.Data.Migrations
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
CareActionId = table.Column<int>(type: "INTEGER", nullable: false),
CareActivityId = table.Column<int>(type: "INTEGER", nullable: false),
EveryDays = table.Column<int>(type: "INTEGER", nullable: false),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false)
},
@@ -146,6 +242,12 @@ namespace plant_manager.Data.Migrations
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);
table.ForeignKey(
name: "FK_PlantCareSchedules_Plants_PlantId",
column: x => x.PlantId,
@@ -162,7 +264,6 @@ namespace plant_manager.Data.Migrations
.Annotation("Sqlite:Autoincrement", true),
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
PlantFlagDefinitionId = table.Column<int>(type: "INTEGER", nullable: false),
Severity = table.Column<string>(type: "TEXT", maxLength: 20, nullable: false),
StartedOn = table.Column<DateOnly>(type: "TEXT", nullable: false),
ResolvedOn = table.Column<DateOnly>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
@@ -220,6 +321,11 @@ namespace plant_manager.Data.Migrations
table: "ActionLogs",
column: "CareActionId");
migrationBuilder.CreateIndex(
name: "IX_ActionLogs_CareActivityId",
table: "ActionLogs",
column: "CareActivityId");
migrationBuilder.CreateIndex(
name: "IX_ActionLogs_PlantId",
table: "ActionLogs",
@@ -237,15 +343,47 @@ namespace plant_manager.Data.Migrations
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CareActivities_Name",
table: "CareActivities",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CareActivityActionResources_ActionResourceId",
table: "CareActivityActionResources",
column: "ActionResourceId");
migrationBuilder.CreateIndex(
name: "IX_CareActivityActions_CareActionId",
table: "CareActivityActions",
column: "CareActionId");
migrationBuilder.CreateIndex(
name: "IX_CareActivityActions_CareActivityId_SortOrder",
table: "CareActivityActions",
columns: new[] { "CareActivityId", "SortOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_CareActionId",
table: "PlantCareSchedules",
column: "CareActionId");
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_CareActivityId",
table: "PlantCareSchedules",
column: "CareActivityId");
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_PlantId_CareActionId",
table: "PlantCareSchedules",
columns: new[] { "PlantId", "CareActionId" },
columns: new[] { "PlantId", "CareActionId" });
migrationBuilder.CreateIndex(
name: "IX_PlantCareSchedules_PlantId_CareActivityId",
table: "PlantCareSchedules",
columns: new[] { "PlantId", "CareActivityId" },
unique: true);
migrationBuilder.CreateIndex(
@@ -264,6 +402,17 @@ namespace plant_manager.Data.Migrations
table: "PlantFlags",
columns: new[] { "PlantId", "PlantFlagDefinitionId", "ResolvedOn" });
migrationBuilder.CreateIndex(
name: "IX_PlantLocations_Name",
table: "PlantLocations",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Plants_LocationId",
table: "Plants",
column: "LocationId");
migrationBuilder.CreateIndex(
name: "IX_Plants_TaxonId",
table: "Plants",
@@ -276,6 +425,9 @@ namespace plant_manager.Data.Migrations
migrationBuilder.DropTable(
name: "ActionLogResources");
migrationBuilder.DropTable(
name: "CareActivityActionResources");
migrationBuilder.DropTable(
name: "PlantCareSchedules");
@@ -288,14 +440,23 @@ namespace plant_manager.Data.Migrations
migrationBuilder.DropTable(
name: "ActionResources");
migrationBuilder.DropTable(
name: "CareActivityActions");
migrationBuilder.DropTable(
name: "PlantFlagDefinitions");
migrationBuilder.DropTable(
name: "Plants");
migrationBuilder.DropTable(
name: "CareActions");
migrationBuilder.DropTable(
name: "Plants");
name: "CareActivities");
migrationBuilder.DropTable(
name: "PlantLocations");
migrationBuilder.DropTable(
name: "PlantTaxa");
@@ -31,6 +31,9 @@ namespace plant_manager.Data.Migrations
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
@@ -45,6 +48,8 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId");
b.HasIndex("PlantId");
b.ToTable("ActionLogs");
@@ -129,27 +134,104 @@ namespace plant_manager.Data.Migrations
b.ToTable("CareActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("CareActivities");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("CareActivityId", "CareActionId");
b.HasIndex("CareActionId");
b.HasIndex("CareActivityId", "SortOrder")
.IsUnique();
b.ToTable("CareActivityActions");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("ActionResourceId")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<decimal?>("Quantity")
.HasPrecision(10, 2)
.HasColumnType("TEXT");
b.Property<string>("Unit")
.HasMaxLength(40)
.HasColumnType("TEXT");
b.HasKey("CareActivityId", "CareActionId", "ActionResourceId");
b.HasIndex("ActionResourceId");
b.ToTable("CareActivityActionResources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Location")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int?>("LocationId")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<int>("TaxonId")
b.Property<int?>("TaxonId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LocationId");
b.HasIndex("TaxonId");
b.ToTable("Plants");
@@ -164,6 +246,9 @@ namespace plant_manager.Data.Migrations
b.Property<int>("CareActionId")
.HasColumnType("INTEGER");
b.Property<int>("CareActivityId")
.HasColumnType("INTEGER");
b.Property<int>("EveryDays")
.HasColumnType("INTEGER");
@@ -177,7 +262,11 @@ namespace plant_manager.Data.Migrations
b.HasIndex("CareActionId");
b.HasIndex("PlantId", "CareActionId")
b.HasIndex("CareActivityId");
b.HasIndex("PlantId", "CareActionId");
b.HasIndex("PlantId", "CareActivityId")
.IsUnique();
b.ToTable("PlantCareSchedules");
@@ -202,11 +291,6 @@ namespace plant_manager.Data.Migrations
b.Property<DateOnly?>("ResolvedOn")
.HasColumnType("TEXT");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateOnly>("StartedOn")
.HasColumnType("TEXT");
@@ -225,11 +309,6 @@ namespace plant_manager.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT");
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
@@ -251,6 +330,32 @@ namespace plant_manager.Data.Migrations
b.ToTable("PlantFlagDefinitions");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PlantLocations");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
{
b.Property<int>("Id")
@@ -297,6 +402,12 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("ActionLogs")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("ActionLogs")
.HasForeignKey("PlantId")
@@ -305,6 +416,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant");
});
@@ -327,13 +440,57 @@ namespace plant_manager.Data.Migrations
b.Navigation("ActionResource");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
.WithMany("CareActivityActions")
.HasForeignKey("CareActionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("Actions")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CareAction");
b.Navigation("CareActivity");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
{
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
.WithMany("CareActivityActionResources")
.HasForeignKey("ActionResourceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction")
.WithMany("Resources")
.HasForeignKey("CareActivityId", "CareActionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ActionResource");
b.Navigation("CareActivityAction");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
.WithMany("Plants")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
.WithMany()
.HasForeignKey("TaxonId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Location");
b.Navigation("Taxon");
});
@@ -346,6 +503,12 @@ namespace plant_manager.Data.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
.WithMany("PlantCareSchedules")
.HasForeignKey("CareActivityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
.WithMany("CareSchedules")
.HasForeignKey("PlantId")
@@ -354,6 +517,8 @@ namespace plant_manager.Data.Migrations
b.Navigation("CareAction");
b.Navigation("CareActivity");
b.Navigation("Plant");
});
@@ -384,15 +549,33 @@ namespace plant_manager.Data.Migrations
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
{
b.Navigation("ActionLogResources");
b.Navigation("CareActivityActionResources");
});
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
{
b.Navigation("ActionLogs");
b.Navigation("CareActivityActions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
{
b.Navigation("ActionLogs");
b.Navigation("Actions");
b.Navigation("PlantCareSchedules");
});
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
{
b.Navigation("Resources");
});
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
{
b.Navigation("ActionLogs");
@@ -406,6 +589,11 @@ namespace plant_manager.Data.Migrations
{
b.Navigation("PlantFlags");
});
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
{
b.Navigation("Plants");
});
#pragma warning restore 612, 618
}
}
+2
View File
@@ -5,12 +5,14 @@ namespace plant_manager.Data.Models
public int Id { get; set; }
public int PlantId { get; set; }
public int CareActionId { get; set; }
public int CareActivityId { get; set; }
public string ActionNameSnapshot { get; set; } = string.Empty;
public string? Notes { get; set; }
public DateOnly PerformedOn { get; set; }
public Plant Plant { get; set; } = null!;
public CareAction CareAction { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!;
public List<ActionLogResource> Resources { get; set; } = [];
}
}
@@ -9,6 +9,7 @@ namespace plant_manager.Data.Models
public string? Notes { get; set; }
public bool IsEnabled { get; set; } = true;
public List<CareActivityActionResource> CareActivityActionResources { get; set; } = [];
public List<ActionLogResource> ActionLogResources { get; set; } = [];
}
}
+1
View File
@@ -8,6 +8,7 @@ namespace plant_manager.Data.Models
public string? Description { get; set; }
public bool IsEnabled { get; set; } = true;
public List<CareActivityAction> CareActivityActions { get; set; } = [];
public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
}
+15
View File
@@ -0,0 +1,15 @@
namespace plant_manager.Data.Models
{
public class CareActivity
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Notes { get; set; }
public bool IsEnabled { get; set; } = true;
public List<CareActivityAction> Actions { get; set; } = [];
public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> PlantCareSchedules { get; set; } = [];
}
}
@@ -0,0 +1,13 @@
namespace plant_manager.Data.Models
{
public class CareActivityAction
{
public int CareActivityId { get; set; }
public int CareActionId { get; set; }
public int SortOrder { get; set; }
public CareActivity CareActivity { get; set; } = null!;
public CareAction CareAction { get; set; } = null!;
public List<CareActivityActionResource> Resources { get; set; } = [];
}
}
@@ -0,0 +1,15 @@
namespace plant_manager.Data.Models
{
public class CareActivityActionResource
{
public int CareActivityId { get; set; }
public int CareActionId { get; set; }
public int ActionResourceId { get; set; }
public decimal? Quantity { get; set; }
public string? Unit { get; set; }
public string? Notes { get; set; }
public CareActivityAction CareActivityAction { get; set; } = null!;
public ActionResource ActionResource { get; set; } = null!;
}
}
+4 -3
View File
@@ -3,11 +3,12 @@ namespace plant_manager.Data.Models
public class Plant
{
public int Id { get; set; }
public int TaxonId { get; set; }
public int? TaxonId { get; set; }
public int? LocationId { get; set; }
public string Nickname { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public PlantTaxon Taxon { get; set; } = null!;
public PlantTaxon? Taxon { get; set; }
public PlantLocation? Location { get; set; }
public List<ActionLog> ActionLogs { get; set; } = [];
public List<PlantCareSchedule> CareSchedules { get; set; } = [];
public List<PlantFlag> Flags { get; set; } = [];
@@ -5,10 +5,12 @@ namespace plant_manager.Data.Models
public int Id { get; set; }
public int PlantId { get; set; }
public int CareActionId { get; set; }
public int CareActivityId { get; set; }
public int EveryDays { get; set; } = 7;
public bool IsEnabled { get; set; } = true;
public Plant Plant { get; set; } = null!;
public CareAction CareAction { get; set; } = null!;
public CareActivity CareActivity { get; set; } = null!;
}
}
-1
View File
@@ -5,7 +5,6 @@ namespace plant_manager.Data.Models
public int Id { get; set; }
public int PlantId { get; set; }
public int PlantFlagDefinitionId { get; set; }
public string Severity { get; set; } = "medium";
public DateOnly StartedOn { get; set; }
public DateOnly? ResolvedOn { get; set; }
public string? Notes { get; set; }
@@ -4,7 +4,6 @@ namespace plant_manager.Data.Models
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Color { get; set; } = "#f2f2f2";
public bool IsEnabled { get; set; } = true;
@@ -0,0 +1,13 @@
namespace plant_manager.Data.Models
{
public class PlantLocation
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Notes { get; set; }
public bool IsEnabled { get; set; } = true;
public List<Plant> Plants { get; set; } = [];
}
}
+55 -21
View File
@@ -12,6 +12,7 @@ namespace plant_manager.Endpoints
{
var logs = await db.ActionLogs
.Include(log => log.Plant)
.Include(log => log.CareActivity)
.Include(log => log.Resources)
.ThenInclude(resource => resource.ActionResource)
.OrderByDescending(log => log.PerformedOn)
@@ -29,19 +30,26 @@ namespace plant_manager.Endpoints
return Results.BadRequest(new { error = "Plant was not found." });
}
var action = await db.CareActions.FindAsync(request.CareActionId);
if (action is null)
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 action was not found." });
return Results.BadRequest(new { error = "Care activity was not found." });
}
if (!action.IsEnabled)
var primaryAction = activity.PrimaryAction();
if (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled))
{
return Results.BadRequest(new { error = "Disabled actions cannot be logged." });
return Results.BadRequest(new { error = "Disabled activities cannot be logged." });
}
var performedOn = request.PerformedOn ?? DateOnly.FromDateTime(DateTime.UtcNow);
var (resources, resourceError) = await BuildLogResources(request.Resources, db);
var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db);
if (resourceError is not null)
{
return Results.BadRequest(new { error = resourceError });
@@ -50,9 +58,11 @@ namespace plant_manager.Endpoints
var log = new ActionLog
{
PlantId = plant.Id,
CareActionId = action.Id,
CareAction = action,
ActionNameSnapshot = action.Name,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
CareAction = primaryAction,
CareActivity = activity,
ActionNameSnapshot = activity.Name,
Notes = request.Notes?.Trim(),
PerformedOn = performedOn,
Resources = resources
@@ -70,6 +80,7 @@ namespace plant_manager.Endpoints
{
var log = await db.ActionLogs
.Include(item => item.Plant)
.Include(item => item.CareActivity)
.Include(item => item.Resources)
.FirstOrDefaultAsync(item => item.Id == id);
if (log is null)
@@ -77,27 +88,31 @@ namespace plant_manager.Endpoints
return Results.NotFound();
}
var oldPlantId = log.PlantId;
var oldCareActionId = log.CareActionId;
var plant = await db.Plants.FindAsync(request.PlantId);
if (plant is null)
{
return Results.BadRequest(new { error = "Plant was not found." });
}
var action = await db.CareActions.FindAsync(request.CareActionId);
if (action is null)
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 action was not found." });
return Results.BadRequest(new { error = "Care activity was not found." });
}
if (!action.IsEnabled)
var primaryAction = activity.PrimaryAction();
if (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled))
{
return Results.BadRequest(new { error = "Disabled actions cannot be logged." });
return Results.BadRequest(new { error = "Disabled activities cannot be logged." });
}
var (resources, resourceError) = await BuildLogResources(request.Resources, db);
var (resources, resourceError) = await BuildLogResources(request.Resources, activity, db);
if (resourceError is not null)
{
return Results.BadRequest(new { error = resourceError });
@@ -107,9 +122,11 @@ namespace plant_manager.Endpoints
log.PlantId = plant.Id;
log.Plant = plant;
log.CareActionId = action.Id;
log.CareAction = action;
log.ActionNameSnapshot = action.Name;
log.CareActionId = primaryAction.Id;
log.CareActivityId = activity.Id;
log.CareAction = primaryAction;
log.CareActivity = activity;
log.ActionNameSnapshot = activity.Name;
log.Notes = request.Notes?.Trim();
log.PerformedOn = request.PerformedOn;
log.Resources = resources;
@@ -136,12 +153,23 @@ namespace plant_manager.Endpoints
private static async Task<(List<ActionLogResource> Resources, string? Error)> BuildLogResources(
IReadOnlyList<ActionLogResourceRequest>? requestResources,
CareActivity activity,
ApplicationDbContext db)
{
var requestedResources = requestResources?
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First())
.ToList() ?? [];
foreach (var configuredResource in GetConfiguredResources(activity))
{
if (!requestedResources.Any(resource => resource.ActionResourceId == configuredResource.ActionResourceId))
{
requestedResources.Add(new ActionLogResourceRequest(
configuredResource.ActionResourceId,
configuredResource.Quantity,
configuredResource.Unit));
}
}
if (requestedResources.Any(resource => resource.Quantity < 0))
{
@@ -176,5 +204,11 @@ namespace plant_manager.Endpoints
.ToList(), null);
}
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
activity.Actions
.SelectMany(action => action.Resources)
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First());
}
}
@@ -87,12 +87,13 @@ namespace plant_manager.Endpoints
return Results.NotFound();
}
var hasLogs = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id);
if (hasLogs)
var isInUse = await db.ActionLogResources.AnyAsync(logResource => logResource.ActionResourceId == id)
|| await db.CareActivityActionResources.AnyAsync(activityResource => activityResource.ActionResourceId == id);
if (isInUse)
{
resource.IsEnabled = false;
await db.SaveChangesAsync();
return Results.Conflict(new { error = "Resource has care history, so it was disabled instead of deleted." });
return Results.Conflict(new { error = "Resource is in use, so it was disabled instead of deleted." });
}
db.ActionResources.Remove(resource);
@@ -0,0 +1,215 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class CareActivityEndpoints
{
public static void MapCareActivityEndpoints(this WebApplication app)
{
app.MapGet("/api/care-activities", async (ApplicationDbContext db) =>
{
var activities = await db.CareActivities
.Include(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.OrderByDescending(activity => activity.IsEnabled)
.ThenBy(activity => activity.Name)
.Select(activity => CareActivityDto.FromCareActivity(activity))
.ToListAsync();
return Results.Ok(activities);
});
app.MapPost("/api/care-activities", async (SaveCareActivityRequest request, ApplicationDbContext db) =>
{
var validation = await ValidateRequest(request, db);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.CareActivities.AnyAsync(activity => activity.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "An activity with this name already exists." });
}
var activity = new CareActivity
{
Name = name,
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
IsEnabled = request.IsEnabled,
Actions = validation.Actions
};
db.CareActivities.Add(activity);
await db.SaveChangesAsync();
return Results.Created($"/api/care-activities/{activity.Id}", CareActivityDto.FromCareActivity(activity));
});
app.MapPut("/api/care-activities/{id:int}", async (int id, SaveCareActivityRequest request, ApplicationDbContext db) =>
{
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 == id);
if (activity is null)
{
return Results.NotFound();
}
var validation = await ValidateRequest(request, db);
if (validation.Error is not null)
{
return Results.BadRequest(new { error = validation.Error });
}
var name = request.Name.Trim();
var exists = await db.CareActivities.AnyAsync(item =>
item.Id != id && item.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "An activity with this name already exists." });
}
activity.Name = name;
activity.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
activity.IsEnabled = request.IsEnabled;
db.CareActivityActions.RemoveRange(activity.Actions);
activity.Actions = validation.Actions;
await db.SaveChangesAsync();
return Results.Ok(CareActivityDto.FromCareActivity(activity));
});
app.MapDelete("/api/care-activities/{id:int}", async (int id, ApplicationDbContext db) =>
{
var activity = await db.CareActivities.FindAsync(id);
if (activity is null)
{
return Results.NotFound();
}
var hasHistory = await db.ActionLogs.AnyAsync(log => log.CareActivityId == id)
|| await db.PlantCareSchedules.AnyAsync(schedule => schedule.CareActivityId == id);
if (hasHistory)
{
activity.IsEnabled = false;
await db.SaveChangesAsync();
return Results.Conflict(new { error = "Activity is in use, so it was disabled instead of deleted." });
}
db.CareActivities.Remove(activity);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
private static async Task<(List<CareActivityAction> Actions, string? Error)> ValidateRequest(
SaveCareActivityRequest request,
ApplicationDbContext db)
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return ([], "Activity name is required.");
}
var requestedActions = request.Actions?
.GroupBy(action => action.CareActionId)
.Select(group => group.First())
.ToList();
if (requestedActions is null || requestedActions.Count == 0)
{
return ([], "Select at least one care action.");
}
if (requestedActions.Any(action => action.CareActionId <= 0))
{
return ([], "Select a care action for every activity action.");
}
var actionIds = requestedActions
.Select(action => action.CareActionId)
.ToList();
var actionsById = await db.CareActions
.Where(action => actionIds.Contains(action.Id))
.ToDictionaryAsync(action => action.Id);
if (actionsById.Count != actionIds.Count)
{
return ([], "One or more care actions were not found.");
}
if (actionsById.Values.Any(action => !action.IsEnabled))
{
return ([], "Disabled actions cannot be used in activities.");
}
var requestedResources = requestedActions
.SelectMany(action => action.Resources ?? [])
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First())
.ToList();
if (requestedResources.Any(resource => resource.ActionResourceId <= 0))
{
return ([], "Select a resource for every activity resource.");
}
if (requestedResources.Any(resource => resource.Quantity < 0))
{
return ([], "Resource quantities cannot be negative.");
}
var resourceIds = requestedResources
.Select(resource => resource.ActionResourceId)
.ToList();
var resourcesById = await db.ActionResources
.Where(resource => resourceIds.Contains(resource.Id))
.ToDictionaryAsync(resource => resource.Id);
if (resourcesById.Count != resourceIds.Count)
{
return ([], "One or more resources were not found.");
}
if (resourcesById.Values.Any(resource => !resource.IsEnabled))
{
return ([], "Disabled resources cannot be used in activities.");
}
var activityActions = requestedActions
.Select((actionRequest, index) => new CareActivityAction
{
CareActionId = actionRequest.CareActionId,
CareAction = actionsById[actionRequest.CareActionId],
SortOrder = index,
Resources = (actionRequest.Resources ?? [])
.Where(resource => resource.ActionResourceId > 0)
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First())
.Select(resource => new CareActivityActionResource
{
CareActionId = actionRequest.CareActionId,
ActionResourceId = resource.ActionResourceId,
ActionResource = resourcesById[resource.ActionResourceId],
Quantity = resource.Quantity,
Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim(),
Notes = string.IsNullOrWhiteSpace(resource.Notes) ? null : resource.Notes.Trim()
})
.ToList()
})
.ToList();
return (activityActions, null);
}
}
}
+161 -6
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
@@ -13,27 +14,37 @@ namespace plant_manager.Endpoints
var schedules = await db.PlantCareSchedules
.Include(schedule => schedule.Plant)
.Include(schedule => schedule.CareAction)
.Where(schedule => schedule.IsEnabled && schedule.CareAction.IsEnabled)
.Include(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Where(schedule =>
schedule.IsEnabled
&& schedule.CareActivity.Actions.All(action => action.CareAction.IsEnabled)
&& schedule.CareActivity.IsEnabled)
.OrderBy(schedule => schedule.Plant.Nickname)
.ThenBy(schedule => schedule.CareAction.Name)
.ThenBy(schedule => schedule.CareActivity.Name)
.ToListAsync();
var latestLogs = await db.ActionLogs
.GroupBy(log => new { log.PlantId, log.CareActionId })
.GroupBy(log => new { log.PlantId, log.CareActivityId })
.Select(group => new
{
group.Key.PlantId,
group.Key.CareActionId,
group.Key.CareActivityId,
LastPerformedOn = group.Max(log => log.PerformedOn)
})
.ToListAsync();
var latestLogLookup = latestLogs.ToDictionary(
log => (log.PlantId, log.CareActionId),
log => (log.PlantId, log.CareActivityId),
log => (DateOnly?)log.LastPerformedOn);
var tasks = schedules
.Select(schedule => CareTaskDto.FromSchedule(
schedule,
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActionId)),
latestLogLookup.GetValueOrDefault((schedule.PlantId, schedule.CareActivityId)),
today))
.Where(task => task.Status is "due" or "soon")
.ToList();
@@ -43,6 +54,150 @@ namespace plant_manager.Endpoints
app.MapGet("/api/care-tasks/upcoming", GetUpcomingCareTasks);
app.MapGet("/api/care-tasks/today", GetUpcomingCareTasks);
app.MapPost("/api/care-tasks/complete-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 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 (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled))
{
return Results.BadRequest(new { error = "Disabled activities cannot be logged." });
}
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var schedules = await db.PlantCareSchedules
.Include(schedule => schedule.Plant)
.Where(schedule =>
schedule.IsEnabled
&& schedule.CareActivityId == activity.Id
&& plantIds.Contains(schedule.PlantId))
.ToListAsync();
var schedulePlantIds = schedules
.Select(schedule => schedule.PlantId)
.ToHashSet();
if (schedulePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "One or more plants do not have this enabled schedule." });
}
var latestLogs = await db.ActionLogs
.Where(log => log.CareActivityId == activity.Id && plantIds.Contains(log.PlantId))
.GroupBy(log => log.PlantId)
.Select(group => new
{
PlantId = group.Key,
LastPerformedOn = group.Max(log => log.PerformedOn)
})
.ToListAsync();
var latestLogLookup = latestLogs.ToDictionary(
log => log.PlantId,
log => (DateOnly?)log.LastPerformedOn);
var duePlantIds = schedules
.Where(schedule =>
PlantCareFormatter.GetStatus(
PlantCareFormatter.GetNextCareDate(
latestLogLookup.GetValueOrDefault(schedule.PlantId),
schedule.EveryDays),
today) == "due")
.Select(schedule => schedule.PlantId)
.ToHashSet();
if (duePlantIds.Count != plantIds.Count)
{
return Results.BadRequest(new { error = "Only due care tasks can be completed in bulk." });
}
var resourceIds = request.Resources?
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First())
.ToList() ?? [];
foreach (var configuredResource in GetConfiguredResources(activity))
{
if (!resourceIds.Any(resource => resource.ActionResourceId == configuredResource.ActionResourceId))
{
resourceIds.Add(new ActionLogResourceRequest(
configuredResource.ActionResourceId,
configuredResource.Quantity,
configuredResource.Unit));
}
}
if (resourceIds.Any(resource => resource.Quantity < 0))
{
return Results.BadRequest(new { error = "Resource quantities cannot be negative." });
}
var requestedResourceIds = resourceIds
.Select(resource => resource.ActionResourceId)
.ToList();
var resourcesById = await db.ActionResources
.Where(resource => requestedResourceIds.Contains(resource.Id))
.ToDictionaryAsync(resource => resource.Id);
if (resourcesById.Count != requestedResourceIds.Count)
{
return Results.BadRequest(new { error = "One or more resources were not found." });
}
if (resourcesById.Values.Any(resource => !resource.IsEnabled))
{
return Results.BadRequest(new { error = "Disabled resources cannot be logged." });
}
var performedOn = request.PerformedOn ?? today;
var notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
var logs = schedules
.OrderBy(schedule => schedule.Plant.Nickname)
.Select(schedule => new ActionLog
{
PlantId = schedule.PlantId,
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
ActionNameSnapshot = activity.Name,
Notes = notes,
PerformedOn = performedOn,
Resources = resourceIds
.Select(resource => new ActionLogResource
{
ActionResourceId = resource.ActionResourceId,
Quantity = resource.Quantity,
Unit = string.IsNullOrWhiteSpace(resource.Unit) ? null : resource.Unit.Trim()
})
.ToList()
})
.ToList();
db.ActionLogs.AddRange(logs);
await db.SaveChangesAsync();
return Results.Ok(new { completed = logs.Count });
});
}
private static IEnumerable<CareActivityActionResource> GetConfiguredResources(CareActivity activity) =>
activity.Actions
.SelectMany(action => action.Resources)
.GroupBy(resource => resource.ActionResourceId)
.Select(group => group.First());
}
}
@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class PlantCareScheduleEndpoints
{
public static void MapPlantCareScheduleEndpoints(this WebApplication app)
{
app.MapPost("/api/plant-care-schedules/bulk", 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." });
}
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 (!activity.IsEnabled || primaryAction is null || activity.Actions.Any(action => !action.CareAction.IsEnabled))
{
return Results.BadRequest(new { error = "Disabled activities cannot be scheduled." });
}
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 everyDays = Math.Clamp(request.EveryDays ?? 7, 1, 365);
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;
schedule.EveryDays = everyDays;
schedule.IsEnabled = request.IsEnabled;
}
await db.SaveChangesAsync();
return Results.Ok(new { updated = plants.Count });
});
}
}
}
+96 -27
View File
@@ -12,8 +12,18 @@ namespace plant_manager.Endpoints
{
var plants = await db.Plants
.Include(plant => plant.Taxon)
.Include(plant => plant.Location)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareAction)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
@@ -27,8 +37,18 @@ namespace plant_manager.Endpoints
{
var plant = await db.Plants
.Include(item => item.Taxon)
.Include(item => item.Location)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareAction)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(plant => plant.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(plant => plant.ActionLogs)
.Include(plant => plant.Flags)
.ThenInclude(flag => flag.Definition)
@@ -46,24 +66,33 @@ namespace plant_manager.Endpoints
return Results.BadRequest(new { error = "Nickname is required." });
}
var taxon = await db.PlantTaxa.FindAsync(request.TaxonId);
if (taxon is null)
var taxon = request.TaxonId is null ? null : await db.PlantTaxa.FindAsync(request.TaxonId);
if (request.TaxonId is not null && taxon is null)
{
return Results.BadRequest(new { error = "Taxon was not found." });
}
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
if (request.LocationId is not null && location is null)
{
return Results.BadRequest(new { error = "Location was not found." });
}
var plant = new Plant
{
Nickname = request.Nickname.Trim(),
Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim(),
TaxonId = request.TaxonId
TaxonId = request.TaxonId,
LocationId = request.LocationId
};
db.Plants.Add(plant);
await db.SaveChangesAsync();
plant.Taxon = taxon;
var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db);
plant.Location = location;
var scheduleError = request.CareSchedules is null
? null
: await ApplyCareSchedules(plant, request.CareSchedules, db);
if (scheduleError is not null)
{
return Results.BadRequest(new { error = scheduleError });
@@ -83,8 +112,18 @@ namespace plant_manager.Endpoints
var plant = await db.Plants
.Include(item => item.Taxon)
.Include(item => item.Location)
.Include(item => item.CareSchedules)
.ThenInclude(schedule => schedule.CareAction)
.Include(item => item.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(item => item.CareSchedules)
.ThenInclude(schedule => schedule.CareActivity)
.ThenInclude(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Include(item => item.ActionLogs)
.Include(item => item.Flags)
.ThenInclude(flag => flag.Definition)
@@ -95,17 +134,26 @@ namespace plant_manager.Endpoints
return Results.NotFound();
}
var taxon = await db.PlantTaxa.FindAsync(request.TaxonId);
if (taxon is null)
var taxon = request.TaxonId is null ? null : await db.PlantTaxa.FindAsync(request.TaxonId);
if (request.TaxonId is not null && taxon is null)
{
return Results.BadRequest(new { error = "Taxon was not found." });
}
var location = request.LocationId is null ? null : await db.PlantLocations.FindAsync(request.LocationId);
if (request.LocationId is not null && location is null)
{
return Results.BadRequest(new { error = "Location was not found." });
}
plant.Nickname = request.Nickname.Trim();
plant.Location = string.IsNullOrWhiteSpace(request.Location) ? "Unassigned" : request.Location.Trim();
plant.TaxonId = request.TaxonId;
plant.LocationId = request.LocationId;
plant.Taxon = taxon;
var scheduleError = await ApplyCareSchedules(plant, request.CareSchedules, db);
plant.Location = location;
var scheduleError = request.CareSchedules is null
? null
: await ApplyCareSchedules(plant, request.CareSchedules, db);
if (scheduleError is not null)
{
return Results.BadRequest(new { error = scheduleError });
@@ -139,9 +187,9 @@ namespace plant_manager.Endpoints
var schedules = requestedSchedules?.ToList();
if (schedules is null)
{
var waterAction = await db.CareActions
.FirstOrDefaultAsync(action => action.Name.ToLower() == "water");
if (waterAction is null)
var waterActivity = await db.CareActivities
.FirstOrDefaultAsync(activity => activity.Name.ToLower() == "water");
if (waterActivity is null)
{
return null;
}
@@ -149,50 +197,71 @@ namespace plant_manager.Endpoints
schedules =
[
new SavePlantCareScheduleRequest(
waterAction.Id,
waterActivity.Id,
7,
true)
];
}
var normalizedSchedules = schedules
.GroupBy(schedule => schedule.CareActionId)
.GroupBy(schedule => schedule.CareActivityId)
.Select(group => group.First())
.Where(schedule => schedule.CareActionId > 0)
.Where(schedule => schedule.CareActivityId > 0)
.ToList();
var actionIds = normalizedSchedules
.Select(schedule => schedule.CareActionId)
var activityIds = normalizedSchedules
.Select(schedule => schedule.CareActivityId)
.ToList();
var actionsById = await db.CareActions
.Where(action => actionIds.Contains(action.Id))
.ToDictionaryAsync(action => action.Id);
var activitiesById = await db.CareActivities
.Include(activity => activity.Actions)
.ThenInclude(action => action.CareAction)
.Include(activity => activity.Actions)
.ThenInclude(action => action.Resources)
.ThenInclude(resource => resource.ActionResource)
.Where(activity => activityIds.Contains(activity.Id))
.ToDictionaryAsync(activity => activity.Id);
if (actionsById.Count != actionIds.Count)
if (activitiesById.Count != activityIds.Count)
{
return "One or more care actions were not found.";
return "One or more care activities were not found.";
}
var requestedActionIds = actionIds.ToHashSet();
if (activitiesById.Values.Any(activity =>
!activity.IsEnabled
|| activity.PrimaryAction() is null
|| activity.Actions.Any(action => !action.CareAction.IsEnabled)))
{
return "Disabled activities cannot be scheduled.";
}
var requestedActivityIds = activityIds.ToHashSet();
var schedulesToRemove = plant.CareSchedules
.Where(schedule => !requestedActionIds.Contains(schedule.CareActionId))
.Where(schedule => !requestedActivityIds.Contains(schedule.CareActivityId))
.ToList();
db.PlantCareSchedules.RemoveRange(schedulesToRemove);
foreach (var requestedSchedule in normalizedSchedules)
{
var activity = activitiesById[requestedSchedule.CareActivityId];
var primaryAction = activity.PrimaryAction()!;
var schedule = plant.CareSchedules
.FirstOrDefault(item => item.CareActionId == requestedSchedule.CareActionId);
.FirstOrDefault(item => item.CareActivityId == requestedSchedule.CareActivityId);
if (schedule is null)
{
schedule = new PlantCareSchedule
{
PlantId = plant.Id,
CareActionId = requestedSchedule.CareActionId,
CareAction = actionsById[requestedSchedule.CareActionId]
CareActionId = primaryAction.Id,
CareActivityId = activity.Id,
CareAction = primaryAction,
CareActivity = activity
};
plant.CareSchedules.Add(schedule);
}
schedule.CareActionId = primaryAction.Id;
schedule.CareActivityId = activity.Id;
schedule.CareAction = primaryAction;
schedule.CareActivity = activity;
schedule.EveryDays = Math.Clamp(requestedSchedule.EveryDays ?? 7, 1, 365);
schedule.IsEnabled = requestedSchedule.IsEnabled;
}
@@ -6,20 +6,12 @@ namespace plant_manager.Endpoints
{
public static class PlantFlagEndpoints
{
private static readonly HashSet<string> ValidSeverities = new(StringComparer.OrdinalIgnoreCase)
{
"low",
"medium",
"high"
};
public static void MapPlantFlagEndpoints(this WebApplication app)
{
app.MapGet("/api/plant-flags", async (ApplicationDbContext db) =>
{
var definitions = await db.PlantFlagDefinitions
.OrderByDescending(definition => definition.IsEnabled)
.ThenBy(definition => definition.Category)
.ThenBy(definition => definition.Name)
.Select(definition => PlantFlagDefinitionDto.FromDefinition(definition))
.ToListAsync();
@@ -45,7 +37,6 @@ namespace plant_manager.Endpoints
var definition = new PlantFlagDefinition
{
Name = name,
Category = NormalizeCategory(request.Category),
Color = NormalizeColor(request.Color),
IsEnabled = request.IsEnabled
};
@@ -78,7 +69,6 @@ namespace plant_manager.Endpoints
}
definition.Name = name;
definition.Category = NormalizeCategory(request.Category);
definition.Color = NormalizeColor(request.Color);
definition.IsEnabled = request.IsEnabled;
@@ -140,7 +130,6 @@ namespace plant_manager.Endpoints
PlantId = plantId,
PlantFlagDefinitionId = request.PlantFlagDefinitionId,
Definition = definition,
Severity = NormalizeSeverity(request.Severity),
StartedOn = request.StartedOn ?? DateOnly.FromDateTime(DateTime.UtcNow),
Notes = NormalizeNotes(request.Notes)
};
@@ -165,7 +154,6 @@ namespace plant_manager.Endpoints
return Results.NotFound();
}
flag.Severity = NormalizeSeverity(request.Severity);
flag.StartedOn = request.StartedOn ?? flag.StartedOn;
flag.ResolvedOn = request.ResolvedOn;
flag.Notes = NormalizeNotes(request.Notes);
@@ -213,23 +201,9 @@ namespace plant_manager.Endpoints
});
}
private static string NormalizeCategory(string? category) =>
string.IsNullOrWhiteSpace(category) ? "General" : category.Trim();
private static string NormalizeColor(string? color) =>
string.IsNullOrWhiteSpace(color) ? "#f2f2f2" : color.Trim();
private static string NormalizeSeverity(string? severity)
{
if (string.IsNullOrWhiteSpace(severity))
{
return "medium";
}
var normalized = severity.Trim().ToLower();
return ValidSeverities.Contains(normalized) ? normalized : "medium";
}
private static string? NormalizeNotes(string? notes) =>
string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using plant_manager.Data;
using plant_manager.Data.Models;
namespace plant_manager.Endpoints
{
public static class PlantLocationEndpoints
{
public static void MapPlantLocationEndpoints(this WebApplication app)
{
app.MapGet("/api/plant-locations", async (ApplicationDbContext db) =>
{
var locations = await db.PlantLocations
.OrderByDescending(location => location.IsEnabled)
.ThenBy(location => location.Name)
.Select(location => PlantLocationDto.FromLocation(location))
.ToListAsync();
return Results.Ok(locations);
});
app.MapPost("/api/plant-locations", async (SavePlantLocationRequest request, ApplicationDbContext db) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Location name is required." });
}
var name = request.Name.Trim();
var exists = await db.PlantLocations.AnyAsync(location => location.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A location with this name already exists." });
}
var location = new PlantLocation
{
Name = name,
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
IsEnabled = request.IsEnabled
};
db.PlantLocations.Add(location);
await db.SaveChangesAsync();
return Results.Created($"/api/plant-locations/{location.Id}", PlantLocationDto.FromLocation(location));
});
app.MapPut("/api/plant-locations/{id:int}", async (int id, SavePlantLocationRequest request, ApplicationDbContext db) =>
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest(new { error = "Location name is required." });
}
var location = await db.PlantLocations.FindAsync(id);
if (location is null)
{
return Results.NotFound();
}
var name = request.Name.Trim();
var exists = await db.PlantLocations.AnyAsync(item =>
item.Id != id && item.Name.ToLower() == name.ToLower());
if (exists)
{
return Results.Conflict(new { error = "A location with this name already exists." });
}
location.Name = name;
location.Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim();
location.IsEnabled = request.IsEnabled;
await db.SaveChangesAsync();
return Results.Ok(PlantLocationDto.FromLocation(location));
});
app.MapDelete("/api/plant-locations/{id:int}", async (int id, ApplicationDbContext db) =>
{
var location = await db.PlantLocations.FindAsync(id);
if (location is null)
{
return Results.NotFound();
}
var isInUse = await db.Plants.AnyAsync(plant => plant.LocationId == id);
if (isInUse)
{
location.IsEnabled = false;
await db.SaveChangesAsync();
return Results.Conflict(new { error = "Location is in use, so it was disabled instead of deleted." });
}
db.PlantLocations.Remove(location);
await db.SaveChangesAsync();
return Results.NoContent();
});
}
}
}
+2
View File
@@ -13,8 +13,10 @@ namespace plant_manager.Endpoints
"/api/health",
"/api/plants",
"/api/plant-taxa",
"/api/plant-locations",
"/api/care-actions",
"/api/action-resources",
"/api/care-activities",
"/api/care-tasks/upcoming",
"/api/action-logs"
}
+4 -1
View File
@@ -10,7 +10,7 @@ builder.Services.AddCors(options =>
{
options.AddPolicy(frontendPolicy, policy =>
{
policy.WithOrigins("http://localhost:5173")
policy.WithOrigins("http://localhost:5173", "http://localhost:5174")
.AllowAnyHeader()
.AllowAnyMethod();
});
@@ -42,8 +42,11 @@ using (var scope = app.Services.CreateScope())
app.MapRootEndpoints();
app.MapPlantEndpoints();
app.MapPlantTaxonEndpoints();
app.MapPlantLocationEndpoints();
app.MapCareActionEndpoints();
app.MapActionResourceEndpoints();
app.MapCareActivityEndpoints();
app.MapPlantCareScheduleEndpoints();
app.MapCareTaskEndpoints();
app.MapActionLogEndpoints();
app.MapPlantFlagEndpoints();