refactor plant concepts and care scheduling
This commit is contained in:
+107
-45
@@ -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 |
|
||||
|
||||
+492
-205
File diff suppressed because it is too large
Load Diff
@@ -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(' | ');
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+207
-19
@@ -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
|
||||
}
|
||||
}
|
||||
+168
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user