misc improvements
This commit is contained in:
Generated
-10
@@ -8,7 +8,6 @@
|
||||
"name": "plant-manager-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
@@ -2168,15 +2167,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ import { HomeView } from './components/HomeView';
|
||||
import { ImportExportView } from './components/ImportExportView';
|
||||
import { LocationsView } from './components/LocationsView';
|
||||
import { PlantManagementView } from './components/PlantManagementView';
|
||||
import { PlantsView } from './components/PlantsView';
|
||||
import { RecipesView } from './components/RecipesView';
|
||||
import { ResourcesView } from './components/ResourcesView';
|
||||
import { SchedulesView } from './components/SchedulesView';
|
||||
@@ -90,7 +89,6 @@ export function App() {
|
||||
isTaxonEditorOpen,
|
||||
locationForm,
|
||||
openPlantDetail,
|
||||
openPlantReadOnlyDetail,
|
||||
plantFlagForm,
|
||||
plantGroupForm,
|
||||
recipeForm,
|
||||
@@ -100,7 +98,6 @@ export function App() {
|
||||
selectedFlagDefinition,
|
||||
selectedLocation,
|
||||
selectedPlant,
|
||||
selectedPlantId,
|
||||
selectedRecipe,
|
||||
selectedResource,
|
||||
selectedTaxon,
|
||||
@@ -127,7 +124,6 @@ export function App() {
|
||||
startEditingActivity,
|
||||
startEditingFlagDefinition,
|
||||
startEditingLocation,
|
||||
startEditingPlant,
|
||||
startEditingPlantGroup,
|
||||
startEditingRecipe,
|
||||
startEditingResource,
|
||||
@@ -184,8 +180,6 @@ export function App() {
|
||||
saveTaxon,
|
||||
searchTaxonInfo,
|
||||
previewCatalogImportFile,
|
||||
setManagedPlantLocation,
|
||||
setManagedPlantTaxon,
|
||||
setPlantInfoQuery,
|
||||
} = useAppActions({
|
||||
editors,
|
||||
@@ -216,7 +210,6 @@ export function App() {
|
||||
|
||||
<div className="catalog-layout">
|
||||
<aside className="catalog-sidebar">
|
||||
<h2>Workbench</h2>
|
||||
<nav className="catalog-nav" aria-label="Primary navigation">
|
||||
<div className="nav-group">
|
||||
<h3>Operations</h3>
|
||||
@@ -227,13 +220,6 @@ export function App() {
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'plants' ? 'page' : undefined}
|
||||
onClick={() => setView('plants')}
|
||||
>
|
||||
Plants
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={view === 'plant-management' ? 'page' : undefined}
|
||||
@@ -325,6 +311,7 @@ export function App() {
|
||||
<section className="catalog-help" aria-label="Catalog status">
|
||||
<h3>Catalog Status</h3>
|
||||
<p>{dueCount} due</p>
|
||||
<p>{plantTaxa.length} taxa</p>
|
||||
<p>{plantLocations.length} locations</p>
|
||||
<p>{plantGroups.length} groups</p>
|
||||
<p>{careActivities.length} activities</p>
|
||||
@@ -336,34 +323,13 @@ export function App() {
|
||||
</aside>
|
||||
|
||||
<div className="catalog-main">
|
||||
<div className="catalog-page-title">
|
||||
<p className="eyebrow">{getViewEyebrow(view)}</p>
|
||||
<h1>{getViewTitle(view)}</h1>
|
||||
</div>
|
||||
|
||||
<main className="content">
|
||||
{view === 'plants' ? (
|
||||
<PlantsView
|
||||
{view === 'plant-management' ? (
|
||||
<PlantManagementView
|
||||
activePlantName={activePlant?.nickname}
|
||||
error={error}
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
isPlantEditorOpen={isPlantEditorOpen}
|
||||
isSaving={isSaving}
|
||||
plants={plants}
|
||||
selectedPlant={selectedPlant}
|
||||
onCancel={cancelEditing}
|
||||
onCloseDetail={() => setSelectedPlantId(null)}
|
||||
onDelete={(plant) => void removePlant(plant)}
|
||||
onEdit={startEditingPlant}
|
||||
onFieldChange={updateForm}
|
||||
onNew={startAddingPlant}
|
||||
onOpenDetail={openPlantReadOnlyDetail}
|
||||
onSave={() => void savePlant()}
|
||||
/>
|
||||
) : view === 'plant-management' ? (
|
||||
<PlantManagementView
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
plantFlagDefinitions={plantFlagDefinitions}
|
||||
@@ -372,14 +338,16 @@ export function App() {
|
||||
plantTaxa={plantTaxa}
|
||||
plants={plants}
|
||||
selectedPlant={selectedPlant}
|
||||
selectedPlantId={selectedPlantId}
|
||||
onAssignFlag={() => void assignFlagToSelectedPlant()}
|
||||
onCancelPlant={cancelEditing}
|
||||
onDeletePlant={(plant) => void removePlant(plant)}
|
||||
onFieldChange={updatePlantFlagForm}
|
||||
onNewPlant={startAddingPlant}
|
||||
onPlantFieldChange={updateForm}
|
||||
onRemoveFlag={(flag) => void removeAssignedPlantFlag(flag)}
|
||||
onResolveFlag={(flag) => void resolveAssignedPlantFlag(flag)}
|
||||
onSavePlant={() => void savePlant()}
|
||||
onSelectPlant={selectManagedPlant}
|
||||
onSetLocation={setManagedPlantLocation}
|
||||
onSetTaxon={setManagedPlantTaxon}
|
||||
/>
|
||||
) : view === 'import-export' ? (
|
||||
<ImportExportView
|
||||
@@ -568,7 +536,6 @@ export function App() {
|
||||
) : (
|
||||
<HomeView
|
||||
careTasks={careTasks}
|
||||
dueCount={dueCount}
|
||||
error={error}
|
||||
groups={plantGroups}
|
||||
isLoading={isLoading}
|
||||
@@ -584,59 +551,3 @@ export function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getViewEyebrow(view: View) {
|
||||
switch (view) {
|
||||
case 'home':
|
||||
return 'Operations';
|
||||
case 'plants':
|
||||
case 'plant-management':
|
||||
case 'import-export':
|
||||
return 'Operations';
|
||||
case 'schedules':
|
||||
return 'Care';
|
||||
case 'taxa':
|
||||
case 'locations':
|
||||
case 'groups':
|
||||
case 'resources':
|
||||
case 'recipes':
|
||||
case 'actions':
|
||||
case 'flags':
|
||||
return 'Catalogs';
|
||||
case 'activities':
|
||||
return 'Care';
|
||||
default:
|
||||
return 'Operations';
|
||||
}
|
||||
}
|
||||
|
||||
function getViewTitle(view: View) {
|
||||
switch (view) {
|
||||
case 'plants':
|
||||
return 'Plants';
|
||||
case 'plant-management':
|
||||
return 'Plant Management';
|
||||
case 'import-export':
|
||||
return 'Import / Export';
|
||||
case 'schedules':
|
||||
return 'Scheduler';
|
||||
case 'taxa':
|
||||
return 'Plant Taxa';
|
||||
case 'locations':
|
||||
return 'Locations';
|
||||
case 'groups':
|
||||
return 'Groups';
|
||||
case 'actions':
|
||||
return 'Actions';
|
||||
case 'activities':
|
||||
return 'Care Activities';
|
||||
case 'resources':
|
||||
return 'Resources';
|
||||
case 'recipes':
|
||||
return 'Recipes';
|
||||
case 'flags':
|
||||
return 'Plant Flags';
|
||||
default:
|
||||
return 'Dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CareAction } from '../domain';
|
||||
import type { ActionFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type ActionsViewProps = {
|
||||
activeActionName?: string;
|
||||
@@ -39,37 +40,69 @@ export function ActionsView({
|
||||
onOpenDetail,
|
||||
onSave,
|
||||
}: ActionsViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredActions = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
return actions.filter((action) =>
|
||||
`${action.name} ${action.description ?? ''}`.toLowerCase().includes(query)
|
||||
);
|
||||
}, [actions, filterQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="actions-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Care menu</p>
|
||||
<h2 id="actions-summary-heading">
|
||||
{isLoading ? 'Loading actions' : `${actions.length} actions`}
|
||||
</h2>
|
||||
<p>{error ?? 'Configure the care actions available when logging plant work.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New action
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Actions summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Configure the care actions available when logging plant work.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search actions"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Actions"
|
||||
emptyMessage={actions.length === 0 ? 'No actions yet.' : 'No actions match this search.'}
|
||||
getKey={(action) => action.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredActions}
|
||||
renderActions={(action) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${action.name}`}
|
||||
editLabel={`Edit ${action.name}`}
|
||||
viewLabel={`View ${action.name}`}
|
||||
onDelete={() => onDelete(action)}
|
||||
onEdit={() => onEdit(action)}
|
||||
onView={() => onOpenDetail(action)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(action) => (
|
||||
<>
|
||||
<h3>{action.name}</h3>
|
||||
<p>{action.description ?? 'No description'}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedAction && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="action-detail-heading">
|
||||
<section className="work-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>
|
||||
<DetailActions
|
||||
closeLabel="Close action detail"
|
||||
editLabel={`Edit ${selectedAction.name}`}
|
||||
onClose={onCloseDetail}
|
||||
onEdit={() => onEdit(selectedAction)}
|
||||
/>
|
||||
</div>
|
||||
<div className="plant-detail-meta">
|
||||
<div>
|
||||
@@ -81,15 +114,12 @@ export function ActionsView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="action-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="action-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeActionName ? 'Editing' : 'New action'}</p>
|
||||
<h2 id="action-editor-heading">{activeActionName ?? 'Action details'}</h2>
|
||||
<h2 id="action-editor-heading">{activeActionName ?? 'New action'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -111,8 +141,7 @@ export function ActionsView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save action'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -121,39 +150,6 @@ export function ActionsView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="actions-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="actions-list-heading">All Actions</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && actions.length === 0 ? (
|
||||
<p className="empty-state">No actions yet.</p>
|
||||
) : null}
|
||||
|
||||
{actions.map((action) => (
|
||||
<article className="plant-row" key={action.id}>
|
||||
<div>
|
||||
<h3>{action.name}</h3>
|
||||
<p>
|
||||
{action.description ?? 'No description'}
|
||||
</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>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${action.name}`} onClick={() => onDelete(action)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionResource, CareAction, CareActivity } from '../domain';
|
||||
import type { ActivityFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type ActivitiesViewProps = {
|
||||
actions: CareAction[];
|
||||
@@ -46,6 +47,18 @@ export function ActivitiesView({
|
||||
onSave,
|
||||
resources,
|
||||
}: ActivitiesViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredActivities = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return activities;
|
||||
}
|
||||
|
||||
return activities.filter((activity) =>
|
||||
`${activity.name} ${activity.notes ?? ''} ${formatActivitySummary(activity)}`.toLowerCase().includes(query)
|
||||
);
|
||||
}, [activities, filterQuery]);
|
||||
|
||||
function updateAction(index: number, nextAction: ActivityFormState['actions'][number]) {
|
||||
onFieldChange(
|
||||
'actions',
|
||||
@@ -69,35 +82,55 @@ export function ActivitiesView({
|
||||
|
||||
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' : `${activities.length} activities`}
|
||||
</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>
|
||||
<SummaryStrip
|
||||
ariaLabel="Activities summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Configure reusable care bundles with actions and per-action resources.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search activities"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Activities"
|
||||
emptyMessage={activities.length === 0 ? 'No activities yet.' : 'No activities match this search.'}
|
||||
getKey={(activity) => activity.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredActivities}
|
||||
renderActions={(activity) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${activity.name}`}
|
||||
editLabel={`Edit ${activity.name}`}
|
||||
viewLabel={`View ${activity.name}`}
|
||||
onDelete={() => onDelete(activity)}
|
||||
onEdit={() => onEdit(activity)}
|
||||
onView={() => onOpenDetail(activity)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(activity) => (
|
||||
<>
|
||||
<h3>{activity.name}</h3>
|
||||
<p>{formatActivitySummary(activity)}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedActivity && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="activity-detail-heading">
|
||||
<section className="work-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>
|
||||
<DetailActions
|
||||
closeLabel="Close activity detail"
|
||||
editLabel={`Edit ${selectedActivity.name}`}
|
||||
onClose={onCloseDetail}
|
||||
onEdit={() => onEdit(selectedActivity)}
|
||||
/>
|
||||
</div>
|
||||
<div className="plant-detail-meta">
|
||||
<div>
|
||||
@@ -133,15 +166,12 @@ export function ActivitiesView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="activity-editor-heading">
|
||||
<section className="work-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>
|
||||
<h2 id="activity-editor-heading">{activeActivityName ?? 'New activity'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -292,7 +322,7 @@ export function ActivitiesView({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -313,7 +343,6 @@ export function ActivitiesView({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add resource
|
||||
</button>
|
||||
</div>
|
||||
@@ -324,7 +353,7 @@ export function ActivitiesView({
|
||||
aria-label="Remove action"
|
||||
onClick={() => removeAction(actionIndex)}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -336,7 +365,6 @@ export function ActivitiesView({
|
||||
disabled={actions.length === 0}
|
||||
onClick={addAction}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add action
|
||||
</button>
|
||||
</fieldset>
|
||||
@@ -351,8 +379,7 @@ export function ActivitiesView({
|
||||
|
||||
<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'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -361,37 +388,6 @@ export function ActivitiesView({
|
||||
</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)}</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { PlantFlagDefinition } from '../domain';
|
||||
import type { FlagDefinitionFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
const flagThemeColors = [
|
||||
{ label: 'Alert', value: '#FFEDEB' },
|
||||
{ label: 'Attention', value: '#FFF7D6' },
|
||||
{ label: 'Monitor', value: '#E9F2FF' },
|
||||
{ label: 'Healthy', value: '#DCFFF1' },
|
||||
{ label: 'Info', value: '#F1F2F4' },
|
||||
{ label: 'Special', value: '#F3F0FF' },
|
||||
{ label: 'Paused', value: '#DCDFE4' },
|
||||
{ label: 'Review', value: '#EAE6FF' },
|
||||
];
|
||||
|
||||
type FlagsViewProps = {
|
||||
activeFlagName?: string;
|
||||
@@ -39,61 +51,81 @@ export function FlagsView({
|
||||
onOpenDetail,
|
||||
onSave,
|
||||
}: FlagsViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredFlags = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return flags;
|
||||
}
|
||||
|
||||
return flags.filter((flag) => flag.name.toLowerCase().includes(query));
|
||||
}, [filterQuery, flags]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="flags-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant flags</p>
|
||||
<h2 id="flags-summary-heading">
|
||||
{isLoading ? 'Loading flags' : `${flags.length} flags`}
|
||||
</h2>
|
||||
<p>{error ?? 'Configure reusable flags for plants.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New flag
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Flags summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Configure reusable flags for plants.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search flags"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Flags"
|
||||
emptyMessage={flags.length === 0 ? 'No flags yet.' : 'No flags match this search.'}
|
||||
getKey={(flag) => flag.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredFlags}
|
||||
renderActions={(flag) => (
|
||||
<>
|
||||
<span className="flag-chip" style={{ backgroundColor: flag.color }}>
|
||||
{flag.name}
|
||||
</span>
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${flag.name}`}
|
||||
editLabel={`Edit ${flag.name}`}
|
||||
viewLabel={`View ${flag.name}`}
|
||||
onDelete={() => onDelete(flag)}
|
||||
onEdit={() => onEdit(flag)}
|
||||
onView={() => onOpenDetail(flag)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
renderContent={(flag) => <h3>{flag.name}</h3>}
|
||||
/>
|
||||
|
||||
{selectedFlag && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="flag-detail-heading">
|
||||
<section className="work-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>
|
||||
<DetailActions
|
||||
closeLabel="Close flag detail"
|
||||
editLabel={`Edit ${selectedFlag.name}`}
|
||||
onClose={onCloseDetail}
|
||||
onEdit={() => onEdit(selectedFlag)}
|
||||
/>
|
||||
</div>
|
||||
<span className="flag-chip" style={{ backgroundColor: selectedFlag.color }}>
|
||||
{selectedFlag.name}
|
||||
</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="flag-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="flag-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeFlagName ? 'Editing' : 'New flag'}</p>
|
||||
<h2 id="flag-editor-heading">{activeFlagName ?? 'Flag details'}</h2>
|
||||
<h2 id="flag-editor-heading">{activeFlagName ?? 'New flag'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -104,20 +136,29 @@ export function FlagsView({
|
||||
onChange={(event) => onFieldChange('name', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Color
|
||||
<input
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(event) => onFieldChange('color', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="flag-theme-picker form-wide">
|
||||
<legend>Color theme</legend>
|
||||
<div className="flag-theme-options">
|
||||
{flagThemeColors.map((theme) => (
|
||||
<label className="flag-theme-option" key={theme.value}>
|
||||
<input
|
||||
checked={form.color.toLowerCase() === theme.value.toLowerCase()}
|
||||
type="radio"
|
||||
name="flag-color-theme"
|
||||
value={theme.value}
|
||||
onChange={() => onFieldChange('color', theme.value)}
|
||||
/>
|
||||
<span className="flag-theme-swatch" style={{ backgroundColor: theme.value }} />
|
||||
<span>{theme.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save flag'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -126,39 +167,6 @@ export function FlagsView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="flags-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="flags-list-heading">All Flags</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && flags.length === 0 ? (
|
||||
<p className="empty-state">No flags yet.</p>
|
||||
) : null}
|
||||
|
||||
{flags.map((flag) => (
|
||||
<article className="plant-row" key={flag.id}>
|
||||
<div>
|
||||
<h3>{flag.name}</h3>
|
||||
</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>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${flag.name}`} onClick={() => onDelete(flag)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Edit3, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Plant, PlantGroup } from '../domain';
|
||||
import type { PlantGroupFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type GroupsViewProps = {
|
||||
activeGroupName?: string;
|
||||
@@ -36,33 +37,62 @@ export function GroupsView({
|
||||
onSave,
|
||||
}: GroupsViewProps) {
|
||||
const selectedPlantIds = new Set(form.plantIds);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredGroups = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.filter((group) =>
|
||||
`${group.name} ${group.notes ?? ''} ${group.plants.map((plant) => plant.nickname).join(' ')}`.toLowerCase().includes(query)
|
||||
);
|
||||
}, [filterQuery, groups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="groups-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant groups</p>
|
||||
<h2 id="groups-summary-heading">
|
||||
{isLoading ? 'Loading groups' : `${groups.length} groups`}
|
||||
</h2>
|
||||
<p>{error ?? 'Organize plants for scheduling and care logging.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New group
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Groups summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Organize plants for scheduling and care logging.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search groups"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Groups"
|
||||
emptyMessage={groups.length === 0 ? 'No groups yet.' : 'No groups match this search.'}
|
||||
getKey={(group) => group.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredGroups}
|
||||
renderActions={(group) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${group.name}`}
|
||||
editLabel={`Edit ${group.name}`}
|
||||
onDelete={() => onDelete(group)}
|
||||
onEdit={() => onEdit(group)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(group) => (
|
||||
<>
|
||||
<h3>{group.name}</h3>
|
||||
<p>{group.plants.length} plants{group.notes ? ` - ${group.notes}` : ''}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="group-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="group-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeGroupName ? 'Editing' : 'New group'}</p>
|
||||
<h2 id="group-editor-heading">{activeGroupName ?? 'Group details'}</h2>
|
||||
<h2 id="group-editor-heading">{activeGroupName ?? 'New group'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -116,8 +146,7 @@ export function GroupsView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving || !form.name.trim()} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save group'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -126,34 +155,6 @@ export function GroupsView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="groups-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="groups-list-heading">All Groups</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && groups.length === 0 ? (
|
||||
<p className="empty-state">No groups yet.</p>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => (
|
||||
<article className="plant-row" key={group.id}>
|
||||
<div>
|
||||
<h3>{group.name}</h3>
|
||||
<p>{group.plants.length} plants{group.notes ? ` - ${group.notes}` : ''}</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${group.name}`} onClick={() => onEdit(group)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${group.name}`} onClick={() => onDelete(group)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarCheck } from 'lucide-react';
|
||||
import type { CareTask, Plant, PlantGroup } from '../domain';
|
||||
import { PlantCard } from './PlantCard';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type HomeViewProps = {
|
||||
careTasks: CareTask[];
|
||||
dueCount: number;
|
||||
error: string | null;
|
||||
groups: PlantGroup[];
|
||||
isLoading: boolean;
|
||||
@@ -17,7 +16,6 @@ type HomeViewProps = {
|
||||
|
||||
export function HomeView({
|
||||
careTasks,
|
||||
dueCount,
|
||||
error,
|
||||
groups,
|
||||
isLoading,
|
||||
@@ -35,17 +33,11 @@ export function HomeView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Care queue</p>
|
||||
<h2 id="summary-heading">
|
||||
{isLoading ? 'Loading care' : `${dueCount} tasks due`}
|
||||
</h2>
|
||||
<p>{error ?? 'Start with the plants that need attention now.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
<SummaryStrip ariaLabel="Dashboard summary">
|
||||
<p>{error ?? 'Start with the plants that need attention now.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="section" aria-labelledby="calendar-heading">
|
||||
<section className="work-panel" aria-labelledby="calendar-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="calendar-heading">Care Calendar</h2>
|
||||
<span className="schedule-count">{selectedTasks.length} selected</span>
|
||||
@@ -93,7 +85,6 @@ export function HomeView({
|
||||
disabled={task.status !== 'due'}
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
@@ -101,7 +92,7 @@ export function HomeView({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" aria-labelledby="care-heading">
|
||||
<section className="work-panel" aria-labelledby="care-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="care-heading">Due Care</h2>
|
||||
<button className="text-button" type="button">View all</button>
|
||||
@@ -129,7 +120,6 @@ export function HomeView({
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log all due
|
||||
</button>
|
||||
</article>
|
||||
@@ -154,7 +144,6 @@ export function HomeView({
|
||||
type="button"
|
||||
onClick={() => onCompleteBulkTasks(group.tasks)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log group
|
||||
</button>
|
||||
</article>
|
||||
@@ -177,7 +166,6 @@ export function HomeView({
|
||||
type="button"
|
||||
onClick={() => onCompleteTask(task)}
|
||||
>
|
||||
<CalendarCheck size={16} />
|
||||
Log
|
||||
</button>
|
||||
</article>
|
||||
@@ -187,7 +175,7 @@ export function HomeView({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" aria-labelledby="plants-heading">
|
||||
<section className="work-panel" aria-labelledby="plants-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="plants-heading">My Plants</h2>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Download, FileSearch, Upload } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { CatalogImportResult } from '../domain';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type ImportExportViewProps = {
|
||||
catalogImportResult: CatalogImportResult | null;
|
||||
@@ -29,26 +29,22 @@ export function ImportExportView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="import-export-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Data movement</p>
|
||||
<h2 id="import-export-summary-heading">Import / Export</h2>
|
||||
<p>{error ?? 'Move catalog data in and out of Plant-Man spreadsheets.'}</p>
|
||||
</div>
|
||||
<div className="summary-actions">
|
||||
<SummaryStrip
|
||||
ariaLabel="Import and export summary"
|
||||
action={(
|
||||
<div className="summary-actions">
|
||||
<button className="primary-action" type="button" disabled={isExporting} onClick={onExport}>
|
||||
<Download size={18} />
|
||||
{isExporting ? 'Exporting' : 'Export spreadsheet'}
|
||||
{isExporting ? 'Exporting' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="editor-panel catalog-import-panel" aria-labelledby="catalog-import-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Catalogs</p>
|
||||
<h2 id="catalog-import-heading">Catalog Import</h2>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p>{error ?? 'Move catalog data in and out of Plant-Man spreadsheets.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="work-panel catalog-import-panel" aria-labelledby="catalog-import-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="catalog-import-heading">Spreadsheet import</h2>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
className="small-action"
|
||||
@@ -61,7 +57,6 @@ export function ImportExportView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileSearch size={16} />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
@@ -70,7 +65,6 @@ export function ImportExportView({
|
||||
disabled={isImportingCatalog || !canApplyPreviewedFile}
|
||||
onClick={() => catalogImportFile ? onApplyCatalogImport(catalogImportFile) : undefined}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { PlantLocation } from '../domain';
|
||||
import type { LocationFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type LocationsViewProps = {
|
||||
activeLocationName?: string;
|
||||
@@ -39,37 +40,69 @@ export function LocationsView({
|
||||
onOpenDetail,
|
||||
onSave,
|
||||
}: LocationsViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredLocations = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return locations;
|
||||
}
|
||||
|
||||
return locations.filter((location) =>
|
||||
`${location.name} ${location.notes ?? ''}`.toLowerCase().includes(query)
|
||||
);
|
||||
}, [filterQuery, locations]);
|
||||
|
||||
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' : `${locations.length} locations`}
|
||||
</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>
|
||||
<SummaryStrip
|
||||
ariaLabel="Locations summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Create and maintain the places where plants live.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search locations"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Locations"
|
||||
emptyMessage={locations.length === 0 ? 'No locations yet.' : 'No locations match this search.'}
|
||||
getKey={(location) => location.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredLocations}
|
||||
renderActions={(location) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${location.name}`}
|
||||
editLabel={`Edit ${location.name}`}
|
||||
viewLabel={`View ${location.name}`}
|
||||
onDelete={() => onDelete(location)}
|
||||
onEdit={() => onEdit(location)}
|
||||
onView={() => onOpenDetail(location)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(location) => (
|
||||
<>
|
||||
<h3>{location.name}</h3>
|
||||
<p>{location.notes ?? 'No notes'}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedLocation && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="location-detail-heading">
|
||||
<section className="work-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>
|
||||
<DetailActions
|
||||
closeLabel="Close location detail"
|
||||
editLabel={`Edit ${selectedLocation.name}`}
|
||||
onClose={onCloseDetail}
|
||||
onEdit={() => onEdit(selectedLocation)}
|
||||
/>
|
||||
</div>
|
||||
<div className="plant-detail-meta">
|
||||
<div>
|
||||
@@ -81,15 +114,12 @@ export function LocationsView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="location-editor-heading">
|
||||
<section className="work-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>
|
||||
<h2 id="location-editor-heading">{activeLocationName ?? 'New location'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -111,8 +141,7 @@ export function LocationsView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save location'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -121,39 +150,6 @@ export function LocationsView({
|
||||
</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'}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Eye } from 'lucide-react';
|
||||
import type { CareStatus, Plant } from '../domain';
|
||||
|
||||
const statusLabel: Record<CareStatus, string> = {
|
||||
@@ -61,7 +60,7 @@ export function PlantCard({ plant, onOpen }: PlantCardProps) {
|
||||
aria-label={`View ${plant.nickname}`}
|
||||
onClick={() => onOpen(plant)}
|
||||
>
|
||||
<Eye size={17} />
|
||||
View
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, MapPin, Save, Tags, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type {
|
||||
Plant,
|
||||
PlantFlag,
|
||||
@@ -6,11 +6,15 @@ import type {
|
||||
PlantLocation,
|
||||
PlantTaxon,
|
||||
} from '../domain';
|
||||
import type { PlantFlagFormState } from '../form-state';
|
||||
import type { PlantFlagFormState, PlantFormState } from '../form-state';
|
||||
import { formatTaxon } from '../form-state';
|
||||
import { CatalogFilterSection, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type PlantManagementViewProps = {
|
||||
activePlantName?: string;
|
||||
error: string | null;
|
||||
form: PlantFormState;
|
||||
isPlantEditorOpen: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
plantFlagDefinitions: PlantFlagDefinition[];
|
||||
@@ -19,18 +23,23 @@ type PlantManagementViewProps = {
|
||||
plantTaxa: PlantTaxon[];
|
||||
plants: Plant[];
|
||||
selectedPlant?: Plant;
|
||||
selectedPlantId: number | null;
|
||||
onAssignFlag: () => void;
|
||||
onCancelPlant: () => void;
|
||||
onDeletePlant: (plant: Plant) => void;
|
||||
onFieldChange: (field: keyof PlantFlagFormState, value: string) => void;
|
||||
onNewPlant: () => void;
|
||||
onPlantFieldChange: (field: keyof PlantFormState, value: string) => void;
|
||||
onRemoveFlag: (flag: PlantFlag) => void;
|
||||
onResolveFlag: (flag: PlantFlag) => void;
|
||||
onSavePlant: () => void;
|
||||
onSelectPlant: (plantId: string) => void;
|
||||
onSetLocation: (locationId: string) => void;
|
||||
onSetTaxon: (taxonId: string) => void;
|
||||
};
|
||||
|
||||
export function PlantManagementView({
|
||||
activePlantName,
|
||||
error,
|
||||
form,
|
||||
isPlantEditorOpen,
|
||||
isLoading,
|
||||
isSaving,
|
||||
plantFlagDefinitions,
|
||||
@@ -39,166 +48,151 @@ export function PlantManagementView({
|
||||
plantTaxa,
|
||||
plants,
|
||||
selectedPlant,
|
||||
selectedPlantId,
|
||||
onAssignFlag,
|
||||
onCancelPlant,
|
||||
onDeletePlant,
|
||||
onFieldChange,
|
||||
onNewPlant,
|
||||
onPlantFieldChange,
|
||||
onRemoveFlag,
|
||||
onResolveFlag,
|
||||
onSavePlant,
|
||||
onSelectPlant,
|
||||
onSetLocation,
|
||||
onSetTaxon,
|
||||
}: PlantManagementViewProps) {
|
||||
const selectedTaxon = plantTaxa.find((taxon) => taxon.id === selectedPlant?.taxonId);
|
||||
const selectedLocation = plantLocations.find((location) => location.id === selectedPlant?.locationId);
|
||||
const [plantSearchQuery, setPlantSearchQuery] = useState('');
|
||||
const isNewPlant = isPlantEditorOpen && !activePlantName;
|
||||
const activeFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn === null) ?? [];
|
||||
const resolvedFlags = selectedPlant?.flags.filter((flag) => flag.resolvedOn !== null) ?? [];
|
||||
const filteredPlants = useMemo(() => {
|
||||
const query = plantSearchQuery.trim().toLowerCase();
|
||||
if (query.length === 0) {
|
||||
return plants;
|
||||
}
|
||||
|
||||
return plants.filter((plant) => getPlantSearchText(plant, plantTaxa).includes(query));
|
||||
}, [plantSearchQuery, plantTaxa, plants]);
|
||||
|
||||
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 catalog records to plant objects without changing the catalogs.'}</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>
|
||||
<SummaryStrip
|
||||
ariaLabel="Plant management summary"
|
||||
action={(
|
||||
<div className="summary-actions">
|
||||
<SummaryActionButton onClick={onNewPlant}>New</SummaryActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<p>{error ?? 'Create plants and attach catalog records to each plant.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<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>
|
||||
{!isNewPlant ? (
|
||||
<>
|
||||
<CatalogFilterSection
|
||||
value={plantSearchQuery}
|
||||
placeholder="Search plants"
|
||||
onChange={setPlantSearchQuery}
|
||||
/>
|
||||
|
||||
<section className="work-panel" aria-label="Plants">
|
||||
<div className="plant-list">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{!isLoading && plants.length > 0 && filteredPlants.length === 0 ? (
|
||||
<p className="empty-state">No plants match this search.</p>
|
||||
) : null}
|
||||
|
||||
{filteredPlants.map((plant) => (
|
||||
<article className="plant-row" key={plant.id}>
|
||||
<div>
|
||||
<h3>{plant.nickname}</h3>
|
||||
<p>
|
||||
{plant.birthday ? `Birthday ${plant.birthday}` : 'No birthday'}
|
||||
{plant.taxon ? ` - ${plant.taxon}` : ''}
|
||||
{plant.location ? ` - ${plant.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" onClick={() => onSelectPlant(String(plant.id))}>
|
||||
Manage
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${plant.nickname}`} onClick={() => onDeletePlant(plant)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isPlantEditorOpen ? (
|
||||
<section className="work-panel" aria-labelledby="plant-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id="plant-editor-heading">{activePlantName ?? 'New plant'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancelPlant}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={form.nickname}
|
||||
onChange={(event) => onPlantFieldChange('nickname', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Birthday
|
||||
<input
|
||||
type="date"
|
||||
value={form.birthday}
|
||||
onChange={(event) => onPlantFieldChange('birthday', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Taxon
|
||||
<select
|
||||
value={form.taxonId}
|
||||
onChange={(event) => onPlantFieldChange('taxonId', event.target.value)}
|
||||
>
|
||||
<option value="">No taxon</option>
|
||||
{plantTaxa.map((taxon) => (
|
||||
<option key={taxon.id} value={taxon.id}>
|
||||
{formatTaxon(taxon)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Location
|
||||
<select
|
||||
value={form.locationId}
|
||||
onChange={(event) => onPlantFieldChange('locationId', event.target.value)}
|
||||
>
|
||||
<option value="">No location</option>
|
||||
{plantLocations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selectedPlant ? (
|
||||
<div className="plant-detail-grid">
|
||||
<section className="detail-section detail-section-wide" aria-labelledby="plant-management-life">
|
||||
<>
|
||||
<section className="work-panel" aria-labelledby="plant-management-flags">
|
||||
<div className="detail-section-heading">
|
||||
<Check size={17} />
|
||||
<h3 id="plant-management-life">Plant History</h3>
|
||||
</div>
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Birthday</span>
|
||||
<strong>{selectedPlant.birthday ?? 'Not set'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="detail-section" aria-labelledby="plant-management-taxa">
|
||||
<div className="detail-section-heading">
|
||||
<Tags size={17} />
|
||||
<h3 id="plant-management-taxa">Plant Taxa</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>
|
||||
{selectedTaxon ? (
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Common name</span>
|
||||
<strong>{selectedTaxon.name}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Botanical name</span>
|
||||
<strong>{formatTaxon(selectedTaxon)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-state">No taxon assigned.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="detail-section" aria-labelledby="plant-management-placement">
|
||||
<div className="detail-section-heading">
|
||||
<MapPin size={17} />
|
||||
<h3 id="plant-management-placement">Location</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>
|
||||
{plantLocations.map((location) => (
|
||||
<option key={location.id} value={location.id}>
|
||||
{location.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{selectedLocation ? (
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Location</span>
|
||||
<strong>{selectedLocation.name}</strong>
|
||||
</div>
|
||||
{selectedLocation.notes ? (
|
||||
<div className="meta-wide">
|
||||
<span>Notes</span>
|
||||
<strong>{selectedLocation.notes}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-state">No location assigned.</p>
|
||||
)}
|
||||
</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="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Active</span>
|
||||
<strong>{activeFlags.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Resolved</span>
|
||||
<strong>{resolvedFlags.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flag-assignment-form">
|
||||
<label>
|
||||
@@ -236,7 +230,6 @@ export function PlantManagementView({
|
||||
disabled={isSaving || !plantFlagForm.plantFlagDefinitionId}
|
||||
onClick={onAssignFlag}
|
||||
>
|
||||
<Save size={16} />
|
||||
Attach flag
|
||||
</button>
|
||||
</div>
|
||||
@@ -265,7 +258,7 @@ export function PlantManagementView({
|
||||
Resolve
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Remove ${flag.name}`} disabled={isSaving} onClick={() => onRemoveFlag(flag)}>
|
||||
<Trash2 size={17} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,12 +286,109 @@ export function PlantManagementView({
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="work-panel" aria-labelledby="plant-management-timeline">
|
||||
<div className="detail-section-heading">
|
||||
<h3 id="plant-management-timeline">Timeline</h3>
|
||||
</div>
|
||||
<PlantTimeline plant={selectedPlant} />
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isPlantEditorOpen ? (
|
||||
<section className="work-panel plant-editor-actions" aria-label="Plant edit actions">
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSavePlant}>
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancelPlant}>
|
||||
Cancel
|
||||
</button>
|
||||
{selectedPlant ? (
|
||||
<button className="small-action danger" type="button" disabled={isSaving} onClick={() => onDeletePlant(selectedPlant)}>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlantSearchText(plant: Plant, plantTaxa: PlantTaxon[]) {
|
||||
const taxon = plantTaxa.find((item) => item.id === plant.taxonId);
|
||||
return [
|
||||
plant.nickname,
|
||||
plant.birthday,
|
||||
plant.location,
|
||||
plant.taxon,
|
||||
...plant.groups.map((group) => group.name),
|
||||
taxon?.name,
|
||||
taxon?.genus,
|
||||
taxon?.species,
|
||||
taxon?.cultivar,
|
||||
taxon?.variety,
|
||||
taxon?.authority,
|
||||
taxon?.family,
|
||||
taxon?.commonName,
|
||||
taxon ? formatTaxon(taxon) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function PlantTimeline({ plant }: { plant: Plant }) {
|
||||
const timelineItems = [
|
||||
...(plant.birthday ? [{
|
||||
date: plant.birthday,
|
||||
title: 'Birthday',
|
||||
detail: `${plant.nickname} joined the collection.`,
|
||||
}] : []),
|
||||
...plant.actionLogs.map((log) => ({
|
||||
date: log.performedOn,
|
||||
title: log.action,
|
||||
detail: formatLogDetail(log),
|
||||
})),
|
||||
].sort((left, right) => right.date.localeCompare(left.date));
|
||||
|
||||
if (timelineItems.length === 0) {
|
||||
return <p className="empty-state">No timeline entries yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timeline-list">
|
||||
{timelineItems.map((item, index) => (
|
||||
<article className="timeline-item" key={`${item.date}-${item.title}-${index}`}>
|
||||
<time>{item.date}</time>
|
||||
<div>
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLogDetail(log: Plant['actionLogs'][number]) {
|
||||
const resources = log.resources.map((resource) => {
|
||||
const amount = resource.quantity === null
|
||||
? ''
|
||||
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
||||
return `${resource.name}${amount}`;
|
||||
});
|
||||
const parts = [
|
||||
log.notes,
|
||||
resources.length > 0 ? resources.join(', ') : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(' - ') : 'Care logged.';
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
const [year, month, day] = date.split('-');
|
||||
if (!year || !month || !day) {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import type { Plant } from '../domain';
|
||||
import type { PlantFormState } from '../form-state';
|
||||
|
||||
type PlantsViewProps = {
|
||||
activePlantName?: string;
|
||||
error: string | null;
|
||||
form: PlantFormState;
|
||||
isLoading: boolean;
|
||||
isPlantEditorOpen: boolean;
|
||||
isSaving: boolean;
|
||||
plants: Plant[];
|
||||
selectedPlant?: Plant;
|
||||
onCancel: () => void;
|
||||
onCloseDetail: () => void;
|
||||
onDelete: (plant: Plant) => void;
|
||||
onEdit: (plant: Plant) => void;
|
||||
onFieldChange: (field: keyof PlantFormState, value: string) => void;
|
||||
onNew: () => void;
|
||||
onOpenDetail: (plant: Plant) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export function PlantsView({
|
||||
activePlantName,
|
||||
error,
|
||||
form,
|
||||
isLoading,
|
||||
isPlantEditorOpen,
|
||||
isSaving,
|
||||
plants,
|
||||
selectedPlant,
|
||||
onCancel,
|
||||
onCloseDetail,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onFieldChange,
|
||||
onNew,
|
||||
onOpenDetail,
|
||||
onSave,
|
||||
}: PlantsViewProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="plants-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant inventory</p>
|
||||
<h2 id="plants-summary-heading">
|
||||
{isLoading ? 'Loading plants' : `${plants.length} plants tracked`}
|
||||
</h2>
|
||||
<p>{error ?? 'Create and maintain plant objects.'}</p>
|
||||
</div>
|
||||
<div className="summary-actions">
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New plant
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{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>
|
||||
</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.nickname}
|
||||
onChange={(event) => onFieldChange('nickname', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Birthday
|
||||
<input
|
||||
type="date"
|
||||
value={form.birthday}
|
||||
onChange={(event) => onFieldChange('birthday', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<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}
|
||||
|
||||
{selectedPlant && !isPlantEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="plant-detail-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Plant detail</p>
|
||||
<h2 id="plant-detail-heading">{selectedPlant.nickname}</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedPlant.nickname}`} onClick={() => onEdit(selectedPlant)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label="Close plant detail" onClick={onCloseDetail}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Birthday</span>
|
||||
<strong>{selectedPlant.birthday ?? 'Not set'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Location</span>
|
||||
<strong>{selectedPlant.location}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Taxon</span>
|
||||
<strong>{selectedPlant.taxon}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Next care</span>
|
||||
<strong>{selectedPlant.nextCare}</strong>
|
||||
</div>
|
||||
<div className="meta-wide">
|
||||
<span>Groups</span>
|
||||
<strong>{selectedPlant.groups.length > 0 ? selectedPlant.groups.map((group) => group.name).join(', ') : 'None'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section-heading">
|
||||
<h3>Timeline</h3>
|
||||
</div>
|
||||
<PlantTimeline plant={selectedPlant} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="plants-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="plants-list-heading">All Plants</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && plants.length === 0 ? (
|
||||
<p className="empty-state">No plants yet.</p>
|
||||
) : null}
|
||||
|
||||
{plants.map((plant) => (
|
||||
<article className="plant-row" key={plant.id}>
|
||||
<div>
|
||||
<h3>{plant.nickname}</h3>
|
||||
<p>
|
||||
{plant.birthday ? `Birthday ${plant.birthday}` : 'No birthday'}
|
||||
{plant.groups.length > 0 ? ` - ${plant.groups.map((group) => group.name).join(', ')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`View ${plant.nickname}`} onClick={() => onOpenDetail(plant)}>
|
||||
<Eye size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${plant.nickname}`} onClick={() => onEdit(plant)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${plant.nickname}`} onClick={() => onDelete(plant)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlantTimeline({ plant }: { plant: Plant }) {
|
||||
const timelineItems = [
|
||||
...(plant.birthday ? [{
|
||||
date: plant.birthday,
|
||||
title: 'Birthday',
|
||||
detail: `${plant.nickname} joined the collection.`,
|
||||
}] : []),
|
||||
...plant.actionLogs.map((log) => ({
|
||||
date: log.performedOn,
|
||||
title: log.action,
|
||||
detail: formatLogDetail(log),
|
||||
})),
|
||||
].sort((left, right) => right.date.localeCompare(left.date));
|
||||
|
||||
if (timelineItems.length === 0) {
|
||||
return <p className="empty-state">No timeline entries yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="timeline-list">
|
||||
{timelineItems.map((item, index) => (
|
||||
<article className="timeline-item" key={`${item.date}-${item.title}-${index}`}>
|
||||
<time>{item.date}</time>
|
||||
<div>
|
||||
<h4>{item.title}</h4>
|
||||
<p>{item.detail}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLogDetail(log: Plant['actionLogs'][number]) {
|
||||
const resources = log.resources.map((resource) => {
|
||||
const amount = resource.quantity === null
|
||||
? ''
|
||||
: ` (${resource.quantity}${resource.unit ? ` ${resource.unit}` : ''})`;
|
||||
return `${resource.name}${amount}`;
|
||||
});
|
||||
const parts = [
|
||||
log.notes,
|
||||
resources.length > 0 ? resources.join(', ') : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(' - ') : 'Care logged.';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionResource, Recipe } from '../domain';
|
||||
import type { RecipeFormState } from '../form-state';
|
||||
import { CatalogFilterSection, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type RecipesViewProps = {
|
||||
activeRecipeName?: string;
|
||||
@@ -46,6 +46,19 @@ export function RecipesView({
|
||||
onSave,
|
||||
}: RecipesViewProps) {
|
||||
const [calculatorAmounts, setCalculatorAmounts] = useState<Record<number, string>>({});
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredRecipes = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return recipes;
|
||||
}
|
||||
|
||||
return recipes.filter((recipe) =>
|
||||
`${recipe.name} ${formatMeasurementMode(recipe.measurementMode)} ${formatRecipeSummary(recipe)}`
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [filterQuery, recipes]);
|
||||
|
||||
function updateComponent(index: number, nextComponent: RecipeFormState['components'][number]) {
|
||||
onFieldChange(
|
||||
@@ -55,12 +68,37 @@ export function RecipesView({
|
||||
}
|
||||
|
||||
function addComponent() {
|
||||
const shouldSetAsBase = form.measurementMode === 'bakers_percent'
|
||||
&& getBakersBaseIndex(form.components) === -1;
|
||||
|
||||
onFieldChange('components', [
|
||||
...form.components,
|
||||
{ actionResourceId: '', quantity: '', unit: '', notes: '' },
|
||||
{ actionResourceId: '', quantity: shouldSetAsBase ? '100' : '', unit: '', notes: '' },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateMeasurementMode(nextMode: RecipeFormState['measurementMode']) {
|
||||
if (nextMode === 'bakers_percent' && form.components.length > 0 && getBakersBaseIndex(form.components) === -1) {
|
||||
onFieldChange(
|
||||
'components',
|
||||
form.components.map((component, index) => index === 0 ? { ...component, quantity: '100' } : component),
|
||||
);
|
||||
}
|
||||
|
||||
onFieldChange('measurementMode', nextMode);
|
||||
}
|
||||
|
||||
function setBakersBase(componentIndex: number) {
|
||||
onFieldChange(
|
||||
'components',
|
||||
form.components.map((component, index) => index === componentIndex
|
||||
? { ...component, quantity: '100' }
|
||||
: component.quantity.trim() === '100'
|
||||
? { ...component, quantity: '' }
|
||||
: component),
|
||||
);
|
||||
}
|
||||
|
||||
function hasIncompleteComponents() {
|
||||
if (form.components.some((component) => !component.actionResourceId)) {
|
||||
return true;
|
||||
@@ -70,15 +108,20 @@ export function RecipesView({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.components.some((component) => !component.quantity.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (form.measurementMode === 'total_percent') {
|
||||
if (form.components.some((component) => !component.quantity.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return getPercentTotal(form) !== 100;
|
||||
}
|
||||
|
||||
return !form.components.some((component) => Number(component.quantity) === 100);
|
||||
const baseIndex = getBakersBaseIndex(form.components);
|
||||
|
||||
return baseIndex === -1
|
||||
|| form.components.some((component, index) =>
|
||||
index !== baseIndex && (!component.quantity.trim() || Number(component.quantity) === 100)
|
||||
);
|
||||
}
|
||||
|
||||
const isPercentRecipe = form.measurementMode !== 'quantity';
|
||||
@@ -89,41 +132,59 @@ export function RecipesView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="recipes-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Recipe catalog</p>
|
||||
<h2 id="recipes-summary-heading">
|
||||
{isLoading ? 'Loading recipes' : `${recipes.length} recipes`}
|
||||
</h2>
|
||||
<p>{error ?? 'Build reusable mixes and solutions, then create the resource they produce.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New recipe
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Recipes summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Build reusable mixes and solutions, then create the resource they produce.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search recipes"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Recipes"
|
||||
emptyMessage={recipes.length === 0 ? 'No recipes yet.' : 'No recipes match this search.'}
|
||||
getKey={(recipe) => recipe.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredRecipes}
|
||||
renderActions={(recipe) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${recipe.name}`}
|
||||
editLabel={`Edit ${recipe.name}`}
|
||||
viewLabel={`View ${recipe.name}`}
|
||||
onDelete={() => onDelete(recipe)}
|
||||
onEdit={() => onEdit(recipe)}
|
||||
onView={() => onOpenDetail(recipe)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(recipe) => (
|
||||
<>
|
||||
<h3>{recipe.name}</h3>
|
||||
<p>{formatMeasurementMode(recipe.measurementMode)} - {formatRecipeSummary(recipe)}</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedRecipe && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="recipe-detail-heading">
|
||||
<section className="work-panel" aria-labelledby="recipe-detail-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Recipe detail</p>
|
||||
<h2 id="recipe-detail-heading">{selectedRecipe.name}</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${selectedRecipe.name}`} onClick={() => onEdit(selectedRecipe)}>
|
||||
<Edit3 size={17} />
|
||||
Edit
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label="Close recipe detail" onClick={onCloseDetail}>
|
||||
<X size={18} />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plant-detail-meta compact-meta">
|
||||
<div>
|
||||
<span>Type</span>
|
||||
<strong>{selectedRecipe.type}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Mode</span>
|
||||
<strong>{formatMeasurementMode(selectedRecipe.measurementMode)}</strong>
|
||||
@@ -153,14 +214,13 @@ export function RecipesView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="recipe-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="recipe-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeRecipeName ? 'Editing' : 'New recipe'}</p>
|
||||
<h2 id="recipe-editor-heading">{activeRecipeName ?? 'Recipe details'}</h2>
|
||||
<h2 id="recipe-editor-heading">{activeRecipeName ?? 'New recipe'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancel}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -172,23 +232,11 @@ export function RecipesView({
|
||||
onChange={(event) => onFieldChange('name', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(event) => onFieldChange('type', event.target.value)}
|
||||
>
|
||||
<option value="Soil mixture">Soil mixture</option>
|
||||
<option value="Fertilizer solution">Fertilizer solution</option>
|
||||
<option value="Treatment">Treatment</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Mode
|
||||
<select
|
||||
value={form.measurementMode}
|
||||
onChange={(event) => onFieldChange('measurementMode', event.target.value as RecipeFormState['measurementMode'])}
|
||||
onChange={(event) => updateMeasurementMode(event.target.value as RecipeFormState['measurementMode'])}
|
||||
>
|
||||
<option value="quantity">Quantity</option>
|
||||
<option value="total_percent">Percent of total</option>
|
||||
@@ -208,9 +256,10 @@ export function RecipesView({
|
||||
{form.components.length === 0 ? (
|
||||
<p className="empty-state">No components configured.</p>
|
||||
) : (
|
||||
<div className={`activity-resource-header recipe-resource-header ${isPercentRecipe ? 'recipe-resource-row-percent' : ''}`} aria-hidden="true">
|
||||
<div className={`activity-resource-header recipe-resource-header ${getRecipeResourceRowClass(form.measurementMode)}`} aria-hidden="true">
|
||||
{form.measurementMode === 'bakers_percent' ? <span>Base</span> : null}
|
||||
<span>Resource</span>
|
||||
<span>{amountHeading}</span>
|
||||
{isPercentRecipe ? <span>{amountHeading}</span> : <span>{amountHeading}</span>}
|
||||
{isPercentRecipe ? null : <span>Unit</span>}
|
||||
<span>Notes</span>
|
||||
<span />
|
||||
@@ -223,9 +272,22 @@ export function RecipesView({
|
||||
.filter((_, index) => index !== componentIndex)
|
||||
.map((item) => item.actionResourceId),
|
||||
);
|
||||
const baseIndex = getBakersBaseIndex(form.components);
|
||||
const isBakersBase = form.measurementMode === 'bakers_percent' && componentIndex === baseIndex;
|
||||
|
||||
return (
|
||||
<div className={`activity-resource-row recipe-resource-row ${isPercentRecipe ? 'recipe-resource-row-percent' : ''}`} key={`${component.actionResourceId}-${componentIndex}`}>
|
||||
<div className={`activity-resource-row recipe-resource-row ${getRecipeResourceRowClass(form.measurementMode)}`} key={`${component.actionResourceId}-${componentIndex}`}>
|
||||
{form.measurementMode === 'bakers_percent' ? (
|
||||
<label className="recipe-base-radio">
|
||||
<input
|
||||
checked={isBakersBase}
|
||||
type="radio"
|
||||
name="recipe-base-component"
|
||||
onChange={() => setBakersBase(componentIndex)}
|
||||
/>
|
||||
<span className="sr-only">Base component</span>
|
||||
</label>
|
||||
) : null}
|
||||
<select
|
||||
aria-label="Component resource"
|
||||
value={component.actionResourceId}
|
||||
@@ -245,10 +307,11 @@ export function RecipesView({
|
||||
</select>
|
||||
<input
|
||||
aria-label={amountHeading}
|
||||
disabled={isBakersBase}
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={component.quantity}
|
||||
value={isBakersBase ? '100' : component.quantity}
|
||||
onChange={(event) => updateComponent(
|
||||
componentIndex,
|
||||
{ ...component, quantity: event.target.value },
|
||||
@@ -281,7 +344,7 @@ export function RecipesView({
|
||||
form.components.filter((_, index) => index !== componentIndex),
|
||||
)}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -293,7 +356,6 @@ export function RecipesView({
|
||||
disabled={resources.length === 0}
|
||||
onClick={addComponent}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add component
|
||||
</button>
|
||||
{form.measurementMode === 'total_percent' ? (
|
||||
@@ -311,8 +373,7 @@ export function RecipesView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving || !form.outputResourceName.trim() || form.components.length === 0 || hasIncompleteComponents()} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save recipe'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -321,37 +382,6 @@ export function RecipesView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="recipes-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="recipes-list-heading">All Recipes</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && recipes.length === 0 ? (
|
||||
<p className="empty-state">No recipes yet.</p>
|
||||
) : null}
|
||||
|
||||
{recipes.map((recipe) => (
|
||||
<article className="plant-row" key={recipe.id}>
|
||||
<div>
|
||||
<h3>{recipe.name}</h3>
|
||||
<p>{recipe.type} - {formatMeasurementMode(recipe.measurementMode)} - {formatRecipeSummary(recipe)}</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={`View ${recipe.name}`} onClick={() => onOpenDetail(recipe)}>
|
||||
<Eye size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label={`Edit ${recipe.name}`} onClick={() => onEdit(recipe)}>
|
||||
<Edit3 size={17} />
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${recipe.name}`} onClick={() => onDelete(recipe)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -547,6 +577,21 @@ function getAmountHeading(measurementMode: RecipeFormState['measurementMode']) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRecipeResourceRowClass(measurementMode: RecipeFormState['measurementMode']) {
|
||||
switch (measurementMode) {
|
||||
case 'bakers_percent':
|
||||
return 'recipe-resource-row-base';
|
||||
case 'total_percent':
|
||||
return 'recipe-resource-row-percent';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getBakersBaseIndex(components: RecipeFormState['components']) {
|
||||
return components.findIndex((component) => Number(component.quantity) === 100);
|
||||
}
|
||||
|
||||
function getPercentTotal(form: RecipeFormState) {
|
||||
return form.components.reduce((total, component) => total + Number(component.quantity || 0), 0);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Edit3, Eye, Plus, Save, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionResource } from '../domain';
|
||||
import type { ResourceFormState } from '../form-state';
|
||||
import { CatalogFilterSection, ClosePanelButton, DetailActions, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type ResourcesViewProps = {
|
||||
activeResourceName?: string;
|
||||
@@ -39,37 +40,73 @@ export function ResourcesView({
|
||||
onSave,
|
||||
resources,
|
||||
}: ResourcesViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredResources = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
return resources.filter((resource) =>
|
||||
`${resource.name} ${resource.notes ?? ''} ${resource.producedByRecipe?.name ?? ''}`.toLowerCase().includes(query)
|
||||
);
|
||||
}, [filterQuery, resources]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="resources-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Resource library</p>
|
||||
<h2 id="resources-summary-heading">
|
||||
{isLoading ? 'Loading resources' : `${resources.length} resources`}
|
||||
</h2>
|
||||
<p>{error ?? 'Configure materials, products, tools, and containers used during care.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New resource
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Resources summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Configure materials, products, tools, and containers used during care.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search resources"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Resources"
|
||||
emptyMessage={resources.length === 0 ? 'No resources yet.' : 'No resources match this search.'}
|
||||
getKey={(resource) => resource.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredResources}
|
||||
renderActions={(resource) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${resource.name}`}
|
||||
editLabel={`Edit ${resource.name}`}
|
||||
viewLabel={`View ${resource.name}`}
|
||||
onDelete={() => onDelete(resource)}
|
||||
onEdit={() => onEdit(resource)}
|
||||
onView={() => onOpenDetail(resource)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(resource) => (
|
||||
<>
|
||||
<h3>{resource.name}</h3>
|
||||
<p>
|
||||
{resource.producedByRecipe
|
||||
? `Produced by ${resource.producedByRecipe.name}`
|
||||
: resource.notes ?? 'No notes'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedResource && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="resource-detail-heading">
|
||||
<section className="work-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>
|
||||
<DetailActions
|
||||
closeLabel="Close resource detail"
|
||||
editLabel={`Edit ${selectedResource.name}`}
|
||||
onClose={onCloseDetail}
|
||||
onEdit={() => onEdit(selectedResource)}
|
||||
/>
|
||||
</div>
|
||||
<div className="plant-detail-meta">
|
||||
<div>
|
||||
@@ -87,15 +124,12 @@ export function ResourcesView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="resource-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="resource-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeResourceName ? 'Editing' : 'New resource'}</p>
|
||||
<h2 id="resource-editor-heading">{activeResourceName ?? 'Resource details'}</h2>
|
||||
<h2 id="resource-editor-heading">{activeResourceName ?? 'New resource'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<ClosePanelButton onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="plant-form">
|
||||
@@ -117,8 +151,7 @@ export function ResourcesView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save resource'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -127,41 +160,6 @@ export function ResourcesView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="resources-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="resources-list-heading">All Resources</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && resources.length === 0 ? (
|
||||
<p className="empty-state">No resources yet.</p>
|
||||
) : null}
|
||||
|
||||
{resources.map((resource) => (
|
||||
<article className="plant-row" key={resource.id}>
|
||||
<div>
|
||||
<h3>{resource.name}</h3>
|
||||
<p>
|
||||
{resource.producedByRecipe
|
||||
? `Produced by ${resource.producedByRecipe.name}`
|
||||
: resource.notes ?? 'No notes'}
|
||||
</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>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${resource.name}`} onClick={() => onDelete(resource)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CalendarClock, Save, Trash2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CareActivity, Plant, PlantGroup } from '../domain';
|
||||
import type { BulkScheduleFormState } from '../form-state';
|
||||
import { SummaryStrip } from './Ui';
|
||||
|
||||
type SchedulesViewProps = {
|
||||
activities: CareActivity[];
|
||||
@@ -65,24 +65,16 @@ export function SchedulesView({
|
||||
|
||||
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' : 'Scheduler'}
|
||||
</h2>
|
||||
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
||||
</div>
|
||||
</section>
|
||||
<SummaryStrip ariaLabel="Schedules summary">
|
||||
<p>{error ?? 'Review current schedules, choose target plants, and apply care intervals.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="editor-panel" aria-labelledby="bulk-schedule-heading">
|
||||
<section className="work-panel" aria-labelledby="bulk-schedule-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Schedule rule</p>
|
||||
<h2 id="bulk-schedule-heading">Apply activity interval</h2>
|
||||
</div>
|
||||
<div className="schedule-count">
|
||||
<CalendarClock size={16} />
|
||||
<span>{selectedGroup ? `${selectedGroup.name}: ${selectedPlants.length} plants` : `${selectedPlants.length} selected`}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,10 +253,9 @@ export function SchedulesView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="section schedule-targets" aria-labelledby="bulk-schedule-plants-heading">
|
||||
<div className="work-panel schedule-targets" aria-labelledby="bulk-schedule-plants-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Targets</p>
|
||||
<h2 id="bulk-schedule-plants-heading">Plants</h2>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
@@ -341,7 +332,6 @@ export function SchedulesView({
|
||||
setSelectedGroupId('');
|
||||
}}
|
||||
>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : selectedGroup ? `Apply to ${selectedGroup.name}` : `Apply to ${form.plantIds.length} plants`}
|
||||
</button>
|
||||
<button
|
||||
@@ -353,16 +343,14 @@ export function SchedulesView({
|
||||
setSelectedGroupId('');
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Remove from {form.plantIds.length} plants
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" aria-labelledby="current-schedules-heading">
|
||||
<section className="work-panel" aria-labelledby="current-schedules-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Current state</p>
|
||||
<h2 id="current-schedules-heading">Selected Plant Schedules</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Download, Edit3, Eye, Plus, Save, Search, Trash2, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { PlantInfoSearchResult, PlantTaxon } from '../domain';
|
||||
import type { TaxonFormState } from '../form-state';
|
||||
import { formatTaxon } from '../form-state';
|
||||
import { CatalogFilterSection, EntityList, RecordActions, SummaryActionButton, SummaryStrip } from './Ui';
|
||||
|
||||
type TaxaViewProps = {
|
||||
activeTaxonName?: string;
|
||||
@@ -64,26 +65,57 @@ export function TaxaView({
|
||||
onSearch,
|
||||
onSearchQueryChange,
|
||||
}: TaxaViewProps) {
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const filteredTaxa = useMemo(() => {
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return taxa;
|
||||
}
|
||||
|
||||
return taxa.filter((taxon) =>
|
||||
`${formatTaxon(taxon)} ${taxon.commonName ?? ''} ${taxon.genus ?? ''} ${taxon.species ?? ''} ${taxon.cultivar ?? ''} ${taxon.variety ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
);
|
||||
}, [filterQuery, taxa]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="summary-panel" aria-labelledby="taxa-summary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Taxon library</p>
|
||||
<h2 id="taxa-summary-heading">
|
||||
{isLoading ? 'Loading taxa' : `${taxa.length} taxa available`}
|
||||
</h2>
|
||||
<p>{error ?? 'Create and maintain the plant identities used by your collection.'}</p>
|
||||
</div>
|
||||
<button className="primary-action" type="button" onClick={onNew}>
|
||||
<Plus size={18} />
|
||||
New taxon
|
||||
</button>
|
||||
</section>
|
||||
<SummaryStrip
|
||||
ariaLabel="Taxa summary"
|
||||
action={<SummaryActionButton onClick={onNew}>New</SummaryActionButton>}
|
||||
>
|
||||
<p>{error ?? 'Create and maintain the plant identities used by your collection.'}</p>
|
||||
</SummaryStrip>
|
||||
|
||||
<section className="editor-panel" aria-labelledby="taxa-search-heading">
|
||||
<CatalogFilterSection
|
||||
value={filterQuery}
|
||||
placeholder="Search taxa"
|
||||
onChange={setFilterQuery}
|
||||
/>
|
||||
|
||||
<EntityList
|
||||
ariaLabel="Taxa"
|
||||
emptyMessage={taxa.length === 0 ? 'No taxa yet.' : 'No taxa match this search.'}
|
||||
getKey={(taxon) => taxon.id}
|
||||
isLoading={isLoading}
|
||||
items={filteredTaxa}
|
||||
renderActions={(taxon) => (
|
||||
<RecordActions
|
||||
deleteLabel={`Delete ${taxon.name}`}
|
||||
editLabel={`Edit ${taxon.name}`}
|
||||
viewLabel={`View ${taxon.name}`}
|
||||
onDelete={() => onDelete(taxon)}
|
||||
onEdit={() => onEdit(taxon)}
|
||||
onView={() => onOpenDetail(taxon)}
|
||||
/>
|
||||
)}
|
||||
renderContent={(taxon) => <h3>{formatTaxon(taxon)}</h3>}
|
||||
/>
|
||||
|
||||
<section className="work-panel" aria-labelledby="taxa-search-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Offline lookup</p>
|
||||
<h2 id="taxa-search-heading">Search plant info</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +136,6 @@ export function TaxaView({
|
||||
/>
|
||||
</label>
|
||||
<button className="primary-action" type="button" disabled={isSearching} onClick={() => onSearch()}>
|
||||
<Search size={18} />
|
||||
{isSearching ? 'Searching' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -157,11 +188,9 @@ export function TaxaView({
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<button className="small-action" type="button" onClick={() => onPrefillResult(result)}>
|
||||
<Edit3 size={16} />
|
||||
Prefill
|
||||
</button>
|
||||
<button className="small-action" type="button" disabled={isSaving} onClick={() => onImportResult(result)}>
|
||||
<Download size={16} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
@@ -174,18 +203,17 @@ export function TaxaView({
|
||||
</section>
|
||||
|
||||
{selectedTaxon && !isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="taxon-detail-heading">
|
||||
<section className="work-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} />
|
||||
Edit
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label="Close taxon detail" onClick={onCloseDetail}>
|
||||
<X size={18} />
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,14 +255,13 @@ export function TaxaView({
|
||||
) : null}
|
||||
|
||||
{isEditorOpen ? (
|
||||
<section className="editor-panel" aria-labelledby="taxon-editor-heading">
|
||||
<section className="work-panel" aria-labelledby="taxon-editor-heading">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{activeTaxonName ? 'Editing' : 'New taxon'}</p>
|
||||
<h2 id="taxon-editor-heading">{activeTaxonName ?? 'Taxon details'}</h2>
|
||||
<h2 id="taxon-editor-heading">{activeTaxonName ?? 'New taxon'}</h2>
|
||||
</div>
|
||||
<button className="icon-button compact" type="button" aria-label="Clear form" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onCancel}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -299,8 +326,7 @@ export function TaxaView({
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="primary-action" type="button" disabled={isSaving} onClick={onSave}>
|
||||
<Save size={18} />
|
||||
{isSaving ? 'Saving' : 'Save taxon'}
|
||||
{isSaving ? 'Saving' : 'Save'}
|
||||
</button>
|
||||
<button className="text-button" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
@@ -309,36 +335,6 @@ export function TaxaView({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="section" aria-labelledby="taxa-list-heading">
|
||||
<div className="section-heading">
|
||||
<h2 id="taxa-list-heading">All Taxa</h2>
|
||||
</div>
|
||||
|
||||
<div className="plant-list">
|
||||
{!isLoading && taxa.length === 0 ? (
|
||||
<p className="empty-state">No taxa yet.</p>
|
||||
) : null}
|
||||
|
||||
{taxa.map((taxon) => (
|
||||
<article className="plant-row" key={taxon.id}>
|
||||
<div>
|
||||
<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>
|
||||
<button className="icon-button compact danger" type="button" aria-label={`Delete ${taxon.name}`} onClick={() => onDelete(taxon)}>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type SummaryStripProps = {
|
||||
action?: ReactNode;
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function SummaryStrip({ action, ariaLabel, children }: SummaryStripProps) {
|
||||
return (
|
||||
<section className="summary-panel" aria-label={ariaLabel}>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
{action}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type EntityListProps<Item> = {
|
||||
ariaLabel: string;
|
||||
emptyMessage: string;
|
||||
isLoading: boolean;
|
||||
items: Item[];
|
||||
getKey: (item: Item) => string | number;
|
||||
renderActions?: (item: Item) => ReactNode;
|
||||
renderContent: (item: Item) => ReactNode;
|
||||
};
|
||||
|
||||
export function EntityList<Item>({
|
||||
ariaLabel,
|
||||
emptyMessage,
|
||||
getKey,
|
||||
isLoading,
|
||||
items,
|
||||
renderActions,
|
||||
renderContent,
|
||||
}: EntityListProps<Item>) {
|
||||
return (
|
||||
<section className="work-panel" aria-label={ariaLabel}>
|
||||
<div className="plant-list">
|
||||
{!isLoading && items.length === 0 ? (
|
||||
<p className="empty-state">{emptyMessage}</p>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => (
|
||||
<article className="plant-row" key={getKey(item)}>
|
||||
<div>
|
||||
{renderContent(item)}
|
||||
</div>
|
||||
{renderActions ? (
|
||||
<div className="row-actions">
|
||||
{renderActions(item)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type CatalogFilterSectionProps = {
|
||||
label?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function CatalogFilterSection({
|
||||
label = 'Search',
|
||||
onChange,
|
||||
placeholder = 'Search catalog',
|
||||
value,
|
||||
}: CatalogFilterSectionProps) {
|
||||
return (
|
||||
<section className="work-panel" aria-label="Catalog filter">
|
||||
<div className="search-row">
|
||||
<label>
|
||||
{label}
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type SummaryActionButtonProps = {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SummaryActionButton({ children, onClick }: SummaryActionButtonProps) {
|
||||
return (
|
||||
<button className="primary-action" type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailActionsProps = {
|
||||
editLabel: string;
|
||||
closeLabel: string;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export function DetailActions({ closeLabel, editLabel, onClose, onEdit }: DetailActionsProps) {
|
||||
return (
|
||||
<div className="row-actions">
|
||||
<button className="icon-button compact" type="button" aria-label={editLabel} onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="icon-button compact" type="button" aria-label={closeLabel} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RecordActionsProps = {
|
||||
deleteLabel: string;
|
||||
editLabel: string;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
onView?: () => void;
|
||||
viewLabel?: string;
|
||||
};
|
||||
|
||||
export function RecordActions({
|
||||
deleteLabel,
|
||||
editLabel,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onView,
|
||||
viewLabel,
|
||||
}: RecordActionsProps) {
|
||||
return (
|
||||
<>
|
||||
{onView ? (
|
||||
<button className="icon-button compact" type="button" aria-label={viewLabel} onClick={onView}>
|
||||
View
|
||||
</button>
|
||||
) : null}
|
||||
<button className="icon-button compact" type="button" aria-label={editLabel} onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="icon-button compact danger" type="button" aria-label={deleteLabel} onClick={onDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ClosePanelButtonProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function ClosePanelButton({ onClick }: ClosePanelButtonProps) {
|
||||
return (
|
||||
<button className="icon-button compact" type="button" aria-label="Close panel" onClick={onClick}>
|
||||
Close
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -139,14 +139,12 @@ export type ActionResourceSummary = {
|
||||
export type RecipeSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
measurementMode: RecipeMeasurementMode;
|
||||
};
|
||||
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
measurementMode: RecipeMeasurementMode;
|
||||
outputResource: ActionResourceSummary | null;
|
||||
components: RecipeComponent[];
|
||||
@@ -311,7 +309,6 @@ export type CareActivityActionResourcePayload = {
|
||||
|
||||
export type RecipePayload = {
|
||||
name: string;
|
||||
type: string;
|
||||
measurementMode: RecipeMeasurementMode;
|
||||
outputResourceName: string | null;
|
||||
components: RecipeComponentPayload[];
|
||||
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
export const emptyPlantForm = {
|
||||
nickname: '',
|
||||
birthday: '',
|
||||
taxonId: '',
|
||||
locationId: '',
|
||||
};
|
||||
|
||||
export const emptyTaxonForm = {
|
||||
@@ -73,7 +75,6 @@ export const emptyActivityForm = {
|
||||
|
||||
export const emptyRecipeForm = {
|
||||
name: '',
|
||||
type: 'Soil mixture',
|
||||
measurementMode: 'quantity' as RecipeMeasurementMode,
|
||||
outputResourceName: '',
|
||||
components: [] as RecipeComponentFormState[],
|
||||
@@ -101,7 +102,7 @@ export type RecipeComponentFormState = {
|
||||
|
||||
export const emptyFlagDefinitionForm = {
|
||||
name: '',
|
||||
color: '#f2f2f2',
|
||||
color: '#F1F2F4',
|
||||
};
|
||||
|
||||
export const emptyPlantFlagForm = {
|
||||
@@ -135,12 +136,14 @@ export type RecipeFormState = typeof emptyRecipeForm;
|
||||
export type FlagDefinitionFormState = typeof emptyFlagDefinitionForm;
|
||||
export type PlantFlagFormState = typeof emptyPlantFlagForm;
|
||||
export type BulkScheduleFormState = typeof emptyBulkScheduleForm;
|
||||
export type View = 'home' | 'plants' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||
export type View = 'home' | 'plant-management' | 'schedules' | 'taxa' | 'locations' | 'groups' | 'actions' | 'resources' | 'recipes' | 'activities' | 'flags' | 'import-export';
|
||||
|
||||
export function toPlantForm(plant: Plant): PlantFormState {
|
||||
return {
|
||||
nickname: plant.nickname,
|
||||
birthday: plant.birthday ?? '',
|
||||
taxonId: plant.taxonId === null ? '' : String(plant.taxonId),
|
||||
locationId: plant.locationId === null ? '' : String(plant.locationId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,8 +151,8 @@ export function toPlantPayload(form: PlantFormState): PlantPayload {
|
||||
return {
|
||||
nickname: form.nickname.trim(),
|
||||
birthday: form.birthday || null,
|
||||
taxonId: null,
|
||||
locationId: null,
|
||||
taxonId: form.taxonId ? Number(form.taxonId) : null,
|
||||
locationId: form.locationId ? Number(form.locationId) : null,
|
||||
careSchedules: null,
|
||||
};
|
||||
}
|
||||
@@ -292,7 +295,6 @@ export function toActivityPayload(form: ActivityFormState): CareActivityPayload
|
||||
export function toRecipeForm(recipe: Recipe): RecipeFormState {
|
||||
return {
|
||||
name: recipe.name,
|
||||
type: recipe.type,
|
||||
measurementMode: recipe.measurementMode,
|
||||
outputResourceName: recipe.outputResource?.name ?? '',
|
||||
components: recipe.components.map((component) => ({
|
||||
@@ -308,7 +310,6 @@ export function toRecipeForm(recipe: Recipe): RecipeFormState {
|
||||
export function toRecipePayload(form: RecipeFormState): RecipePayload {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
type: form.type.trim(),
|
||||
measurementMode: form.measurementMode,
|
||||
outputResourceName: form.outputResourceName.trim() || null,
|
||||
components: form.components.map((component) => ({
|
||||
|
||||
@@ -180,7 +180,6 @@ p {
|
||||
background: var(--side);
|
||||
}
|
||||
|
||||
.catalog-sidebar h2,
|
||||
.catalog-help h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #24342c;
|
||||
@@ -283,21 +282,6 @@ p {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.catalog-page-title {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.catalog-page-title h1 {
|
||||
margin: 0 0 6px;
|
||||
color: #24342c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -306,15 +290,8 @@ p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.work-panel,
|
||||
.editor-panel,
|
||||
.plant-card,
|
||||
.plant-row,
|
||||
@@ -331,12 +308,6 @@ p {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid var(--link);
|
||||
}
|
||||
|
||||
.summary-panel h2 {
|
||||
margin-bottom: 3px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.summary-panel p {
|
||||
@@ -352,12 +323,15 @@ p {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-section,
|
||||
.section,
|
||||
.work-panel,
|
||||
.editor-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.work-panel,
|
||||
.editor-panel {
|
||||
padding: 12px;
|
||||
background: #fbfaf4;
|
||||
@@ -404,6 +378,10 @@ p {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.small-action.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
min-width: 30px;
|
||||
@@ -806,7 +784,7 @@ dd {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plant-form input,
|
||||
.plant-form input:not([type='radio']):not([type='checkbox']),
|
||||
.plant-form select,
|
||||
.plant-form textarea,
|
||||
.search-row input,
|
||||
@@ -855,12 +833,12 @@ dd {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.preserve-lines {
|
||||
white-space: pre-wrap;
|
||||
.plant-editor-actions {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.plant-form input[type='color'] {
|
||||
padding: 2px;
|
||||
.preserve-lines {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.plant-form .toggle-field,
|
||||
@@ -998,6 +976,24 @@ dd {
|
||||
grid-template-columns: minmax(160px, 1.2fr) minmax(70px, 90px) minmax(140px, 1fr) auto;
|
||||
}
|
||||
|
||||
.recipe-resource-row-base {
|
||||
grid-template-columns: minmax(48px, 60px) minmax(160px, 1.2fr) minmax(70px, 90px) minmax(140px, 1fr) auto;
|
||||
}
|
||||
|
||||
.recipe-base-radio {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recipe-base-radio input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-resource-header,
|
||||
.log-resource-row {
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(70px, 90px) minmax(80px, 110px) auto;
|
||||
@@ -1041,8 +1037,8 @@ dd {
|
||||
gap: 2px;
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
border-left: 3px solid var(--link);
|
||||
border-radius: 0 6px 6px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #f3efe3;
|
||||
}
|
||||
|
||||
@@ -1219,7 +1215,7 @@ dd {
|
||||
}
|
||||
|
||||
.plant-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -1375,6 +1371,62 @@ dd {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.flag-theme-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.flag-theme-picker legend {
|
||||
margin-bottom: 5px;
|
||||
color: #4f5f54;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flag-theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.flag-theme-option {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #fffdf7;
|
||||
color: #24342c;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.flag-theme-option:has(input:checked) {
|
||||
border-color: #8f806d;
|
||||
background: #f2efe6;
|
||||
}
|
||||
|
||||
.flag-theme-option input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.flag-theme-swatch {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #777;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.flag-assignment-form {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
@@ -1490,10 +1542,6 @@ dd {
|
||||
flex: 0 0 78px;
|
||||
}
|
||||
|
||||
.catalog-page-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.plant-row,
|
||||
.task-row,
|
||||
|
||||
@@ -141,13 +141,7 @@ export function useAppActions({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const payload = editors.editingPlantId === null
|
||||
? toPlantPayload(editors.form)
|
||||
: {
|
||||
...toPlantPayload(editors.form),
|
||||
taxonId: editors.activePlant?.taxonId ?? null,
|
||||
locationId: editors.activePlant?.locationId ?? null,
|
||||
};
|
||||
const payload = toPlantPayload(editors.form);
|
||||
if (editors.editingPlantId === null) {
|
||||
await createPlant(payload);
|
||||
} else {
|
||||
@@ -162,39 +156,6 @@ export function useAppActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function updateManagedPlant(nextValues: {
|
||||
taxonId?: number | null;
|
||||
locationId?: number | null;
|
||||
}) {
|
||||
if (!editors.selectedPlant) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updatePlant(editors.selectedPlant.id, {
|
||||
nickname: editors.selectedPlant.nickname,
|
||||
birthday: editors.selectedPlant.birthday,
|
||||
taxonId: nextValues.taxonId === undefined ? editors.selectedPlant.taxonId : nextValues.taxonId,
|
||||
locationId: nextValues.locationId === undefined ? editors.selectedPlant.locationId : nextValues.locationId,
|
||||
careSchedules: null,
|
||||
});
|
||||
await loadPlants();
|
||||
} catch {
|
||||
setError('Could not update the plant assignment.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setManagedPlantTaxon(taxonId: string) {
|
||||
void updateManagedPlant({ taxonId: taxonId ? Number(taxonId) : null });
|
||||
}
|
||||
|
||||
function setManagedPlantLocation(locationId: string) {
|
||||
void updateManagedPlant({ locationId: locationId ? Number(locationId) : null });
|
||||
}
|
||||
|
||||
async function removePlant(plant: Plant) {
|
||||
const confirmed = window.confirm(`Delete ${plant.nickname}? This also removes its care log.`);
|
||||
if (!confirmed) {
|
||||
@@ -537,8 +498,8 @@ export function useAppActions({
|
||||
}
|
||||
|
||||
async function saveRecipe() {
|
||||
if (!editors.recipeForm.name.trim() || !editors.recipeForm.type.trim() || !editors.recipeForm.outputResourceName.trim() || editors.recipeForm.components.length === 0) {
|
||||
setError('Recipe name, type, produced resource, and at least one component are required.');
|
||||
if (!editors.recipeForm.name.trim() || !editors.recipeForm.outputResourceName.trim() || editors.recipeForm.components.length === 0) {
|
||||
setError('Recipe name, produced resource, and at least one component are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -847,8 +808,6 @@ export function useAppActions({
|
||||
saveTaxon,
|
||||
searchTaxonInfo,
|
||||
previewCatalogImportFile,
|
||||
setManagedPlantLocation,
|
||||
setManagedPlantTaxon,
|
||||
setPlantInfoQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
setSelectedPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setIsPlantEditorOpen(true);
|
||||
setView('plants');
|
||||
setView('plant-management');
|
||||
}
|
||||
|
||||
function openPlantDetail(plant: Plant) {
|
||||
@@ -180,25 +180,19 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
}
|
||||
|
||||
function startEditingPlant(plant: Plant) {
|
||||
setSelectedPlantId(null);
|
||||
setSelectedPlantId(plant.id);
|
||||
setEditingPlantId(plant.id);
|
||||
setForm(toPlantForm(plant));
|
||||
setIsPlantEditorOpen(true);
|
||||
setView('plants');
|
||||
setView('plant-management');
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingPlantId(null);
|
||||
setSelectedPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setIsPlantEditorOpen(false);
|
||||
}
|
||||
|
||||
function openPlantReadOnlyDetail(plant: Plant) {
|
||||
setSelectedPlantId(plant.id);
|
||||
setEditingPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setIsPlantEditorOpen(false);
|
||||
setView('plants');
|
||||
setPlantFlagForm(emptyPlantFlagForm);
|
||||
}
|
||||
|
||||
function startAddingTaxon() {
|
||||
@@ -384,8 +378,22 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
}
|
||||
|
||||
function selectManagedPlant(plantId: string) {
|
||||
setSelectedPlantId(plantId ? Number(plantId) : null);
|
||||
const plant = data.plants.find((item) => item.id === Number(plantId));
|
||||
if (!plant) {
|
||||
setSelectedPlantId(null);
|
||||
setEditingPlantId(null);
|
||||
setForm(emptyPlantForm);
|
||||
setIsPlantEditorOpen(false);
|
||||
setPlantFlagForm(emptyPlantFlagForm);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPlantId(plant.id);
|
||||
setEditingPlantId(plant.id);
|
||||
setForm(toPlantForm(plant));
|
||||
setIsPlantEditorOpen(true);
|
||||
setPlantFlagForm(emptyPlantFlagForm);
|
||||
setView('plant-management');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -432,7 +440,6 @@ export function useAppEditors(data: AppEditorData, setView: (view: View) => void
|
||||
isTaxonEditorOpen,
|
||||
locationForm,
|
||||
openPlantDetail,
|
||||
openPlantReadOnlyDetail,
|
||||
plantFlagForm,
|
||||
plantGroupForm,
|
||||
recipeForm,
|
||||
|
||||
@@ -99,7 +99,6 @@ namespace plant_manager
|
||||
|
||||
public record SaveRecipeRequest(
|
||||
string Name,
|
||||
string Type,
|
||||
string? MeasurementMode,
|
||||
string? OutputResourceName,
|
||||
IReadOnlyList<SaveRecipeComponentRequest>? Components,
|
||||
@@ -316,11 +315,10 @@ namespace plant_manager
|
||||
public record RecipeSummaryDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string Type,
|
||||
string MeasurementMode)
|
||||
{
|
||||
public static RecipeSummaryDto FromRecipe(Recipe recipe) =>
|
||||
new(recipe.Id, recipe.Name, recipe.Type, recipe.MeasurementMode);
|
||||
new(recipe.Id, recipe.Name, recipe.MeasurementMode);
|
||||
}
|
||||
|
||||
public record CareActivityDto(
|
||||
@@ -365,7 +363,6 @@ namespace plant_manager
|
||||
public record RecipeDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string Type,
|
||||
string MeasurementMode,
|
||||
ActionResourceSummaryDto? OutputResource,
|
||||
IReadOnlyList<RecipeComponentDto> Components,
|
||||
@@ -375,7 +372,6 @@ namespace plant_manager
|
||||
new(
|
||||
recipe.Id,
|
||||
recipe.Name,
|
||||
recipe.Type,
|
||||
recipe.MeasurementMode,
|
||||
recipe.OutputResource is null
|
||||
? null
|
||||
|
||||
@@ -251,7 +251,6 @@ namespace plant_manager.Data
|
||||
entity.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
entity.Property(e => e.Name).HasMaxLength(120).IsRequired();
|
||||
entity.Property(e => e.Type).HasMaxLength(80).IsRequired();
|
||||
entity.Property(e => e.MeasurementMode).HasMaxLength(40).IsRequired();
|
||||
entity.Property(e => e.Notes).HasMaxLength(1000);
|
||||
entity.HasIndex(e => e.Name).IsUnique();
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using plant_manager.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260522001415_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionNameSnapshot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("PerformedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId");
|
||||
|
||||
b.ToTable("ActionLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.Property<int>("ActionLogId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ActionLogId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("TEXT");
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActivities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
|
||||
{
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CareActivityId", "CareActionId");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId", "SortOrder")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActivityActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
|
||||
{
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CareActivityId", "CareActionId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("CareActivityActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("LocationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TaxonId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("TaxonId");
|
||||
|
||||
b.ToTable("Plants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EndsAfterOccurrences")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EndsMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("EndsOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RepeatEvery")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RepeatOnDays")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RepeatUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("ScheduledFor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantFlagDefinitionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly?>("ResolvedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("StartedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlantFlagDefinitionId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn");
|
||||
|
||||
b.ToTable("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantFlagDefinitions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantLocations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Cultivar")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Species")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Variety")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("ActionLogId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("ActionLogResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionLog");
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("CareActivityActions")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("Actions")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("CareActivityActionResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("CareActivityId", "CareActionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
|
||||
b.Navigation("CareActivityAction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||
.WithMany("Plants")
|
||||
.HasForeignKey("LocationId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaxonId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Location");
|
||||
|
||||
b.Navigation("Taxon");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition")
|
||||
.WithMany("PlantFlags")
|
||||
.HasForeignKey("PlantFlagDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("Flags")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Navigation("Resources");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
|
||||
b.Navigation("Flags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
|
||||
{
|
||||
b.Navigation("Plants");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
-789
@@ -1,789 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using plant_manager.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260530144239_AddRecipesPlantTimelineAndGroups")]
|
||||
partial class AddRecipesPlantTimelineAndGroups
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActionNameSnapshot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("PerformedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId");
|
||||
|
||||
b.ToTable("ActionLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.Property<int>("ActionLogId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ActionLogId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("ActionLogResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("ActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(400)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActivities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
|
||||
{
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CareActivityId", "CareActionId");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId", "SortOrder")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CareActivityActions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
|
||||
{
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CareActivityId", "CareActionId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.ToTable("CareActivityActionResources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly?>("Birthday")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("LocationId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TaxonId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LocationId");
|
||||
|
||||
b.HasIndex("TaxonId");
|
||||
|
||||
b.ToTable("Plants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CareActivityId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EndsAfterOccurrences")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("EndsMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("EndsOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EveryDays")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RecurrenceMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RepeatEvery")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("RepeatOnDays")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RepeatUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly?>("ScheduledFor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CareActionId");
|
||||
|
||||
b.HasIndex("CareActivityId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActionId");
|
||||
|
||||
b.HasIndex("PlantId", "CareActivityId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlantFlagDefinitionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly?>("ResolvedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("StartedOn")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PlantFlagDefinitionId");
|
||||
|
||||
b.HasIndex("PlantId", "PlantFlagDefinitionId", "ResolvedOn");
|
||||
|
||||
b.ToTable("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantFlagDefinitions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantGroups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b =>
|
||||
{
|
||||
b.Property<int>("PlantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PlantGroupId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("PlantId", "PlantGroupId");
|
||||
|
||||
b.HasIndex("PlantGroupId");
|
||||
|
||||
b.ToTable("PlantGroupMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("PlantLocations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantTaxon", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Cultivar")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genus")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Species")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Variety")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PlantTaxa");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MeasurementMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("OutputResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OutputResourceId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Recipes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
|
||||
{
|
||||
b.Property<int>("RecipeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActionResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(10, 2)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(40)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("RecipeId", "ActionResourceId");
|
||||
|
||||
b.HasIndex("ActionResourceId");
|
||||
|
||||
b.HasIndex("RecipeId", "SortOrder")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RecipeComponents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("ActionLogs")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLogResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionLog", "ActionLog")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("ActionLogId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("ActionLogResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionLog");
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("CareActivityActions")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("Actions")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityActionResource", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("CareActivityActionResources")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivityAction", "CareActivityAction")
|
||||
.WithMany("Resources")
|
||||
.HasForeignKey("CareActivityId", "CareActionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
|
||||
b.Navigation("CareActivityAction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantLocation", "Location")
|
||||
.WithMany("Plants")
|
||||
.HasForeignKey("LocationId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.PlantTaxon", "Taxon")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaxonId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Location");
|
||||
|
||||
b.Navigation("Taxon");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantCareSchedule", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.CareAction", "CareAction")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.CareActivity", "CareActivity")
|
||||
.WithMany("PlantCareSchedules")
|
||||
.HasForeignKey("CareActivityId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("CareSchedules")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("CareAction");
|
||||
|
||||
b.Navigation("CareActivity");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlag", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantFlagDefinition", "Definition")
|
||||
.WithMany("PlantFlags")
|
||||
.HasForeignKey("PlantFlagDefinitionId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("Flags")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("Plant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantGroupMembership", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.PlantGroup", "PlantGroup")
|
||||
.WithMany("Memberships")
|
||||
.HasForeignKey("PlantGroupId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Plant", "Plant")
|
||||
.WithMany("GroupMemberships")
|
||||
.HasForeignKey("PlantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Plant");
|
||||
|
||||
b.Navigation("PlantGroup");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "OutputResource")
|
||||
.WithOne("ProducedByRecipe")
|
||||
.HasForeignKey("plant_manager.Data.Models.Recipe", "OutputResourceId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("OutputResource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.RecipeComponent", b =>
|
||||
{
|
||||
b.HasOne("plant_manager.Data.Models.ActionResource", "ActionResource")
|
||||
.WithMany("RecipeComponents")
|
||||
.HasForeignKey("ActionResourceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("plant_manager.Data.Models.Recipe", "Recipe")
|
||||
.WithMany("Components")
|
||||
.HasForeignKey("RecipeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ActionResource");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionLog", b =>
|
||||
{
|
||||
b.Navigation("Resources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.ActionResource", b =>
|
||||
{
|
||||
b.Navigation("ActionLogResources");
|
||||
|
||||
b.Navigation("CareActivityActionResources");
|
||||
|
||||
b.Navigation("ProducedByRecipe");
|
||||
|
||||
b.Navigation("RecipeComponents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareAction", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareActivityActions");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivity", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("Actions");
|
||||
|
||||
b.Navigation("PlantCareSchedules");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.CareActivityAction", b =>
|
||||
{
|
||||
b.Navigation("Resources");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Plant", b =>
|
||||
{
|
||||
b.Navigation("ActionLogs");
|
||||
|
||||
b.Navigation("CareSchedules");
|
||||
|
||||
b.Navigation("Flags");
|
||||
|
||||
b.Navigation("GroupMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantFlagDefinition", b =>
|
||||
{
|
||||
b.Navigation("PlantFlags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantGroup", b =>
|
||||
{
|
||||
b.Navigation("Memberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.PlantLocation", b =>
|
||||
{
|
||||
b.Navigation("Plants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("plant_manager.Data.Models.Recipe", b =>
|
||||
{
|
||||
b.Navigation("Components");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRecipesPlantTimelineAndGroups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "Birthday",
|
||||
table: "Plants",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Recipes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
|
||||
MeasurementMode = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
OutputResourceId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Recipes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Recipes_ActionResources_OutputResourceId",
|
||||
column: x => x.OutputResourceId,
|
||||
principalTable: "ActionResources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantGroupMemberships",
|
||||
columns: table => new
|
||||
{
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PlantGroupId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantGroupMemberships", x => new { x.PlantId, x.PlantGroupId });
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantGroupMemberships_PlantGroups_PlantGroupId",
|
||||
column: x => x.PlantGroupId,
|
||||
principalTable: "PlantGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantGroupMemberships_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecipeComponents",
|
||||
columns: table => new
|
||||
{
|
||||
RecipeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActionResourceId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 10, scale: 2, nullable: true),
|
||||
Unit = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RecipeComponents", x => new { x.RecipeId, x.ActionResourceId });
|
||||
table.ForeignKey(
|
||||
name: "FK_RecipeComponents_ActionResources_ActionResourceId",
|
||||
column: x => x.ActionResourceId,
|
||||
principalTable: "ActionResources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_RecipeComponents_Recipes_RecipeId",
|
||||
column: x => x.RecipeId,
|
||||
principalTable: "Recipes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantGroupMemberships_PlantGroupId",
|
||||
table: "PlantGroupMemberships",
|
||||
column: "PlantGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantGroups_Name",
|
||||
table: "PlantGroups",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeComponents_ActionResourceId",
|
||||
table: "RecipeComponents",
|
||||
column: "ActionResourceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeComponents_RecipeId_SortOrder",
|
||||
table: "RecipeComponents",
|
||||
columns: new[] { "RecipeId", "SortOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_Name",
|
||||
table: "Recipes",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_OutputResourceId",
|
||||
table: "Recipes",
|
||||
column: "OutputResourceId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantGroupMemberships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RecipeComponents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantGroups");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Recipes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Birthday",
|
||||
table: "Plants");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGbifPlantTaxonMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CommonName",
|
||||
table: "PlantTaxa",
|
||||
type: "TEXT",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalId",
|
||||
table: "PlantTaxa",
|
||||
type: "TEXT",
|
||||
maxLength: 80,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalSource",
|
||||
table: "PlantTaxa",
|
||||
type: "TEXT",
|
||||
maxLength: 40,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Family",
|
||||
table: "PlantTaxa",
|
||||
type: "TEXT",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
||||
table: "PlantTaxa",
|
||||
columns: new[] { "ExternalSource", "ExternalId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
||||
table: "PlantTaxa");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CommonName",
|
||||
table: "PlantTaxa");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalId",
|
||||
table: "PlantTaxa");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalSource",
|
||||
table: "PlantTaxa");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Family",
|
||||
table: "PlantTaxa");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-7
@@ -11,8 +11,8 @@ using plant_manager.Data;
|
||||
namespace plant_manager.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260604231106_AddGbifPlantTaxonMetadata")]
|
||||
partial class AddGbifPlantTaxonMetadata
|
||||
[Migration("20260612235128_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -488,11 +488,6 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int?>("OutputResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
+148
-4
@@ -67,6 +67,20 @@ namespace plant_manager.Data.Migrations
|
||||
table.PrimaryKey("PK_PlantFlagDefinitions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantLocations",
|
||||
columns: table => new
|
||||
@@ -92,13 +106,39 @@ namespace plant_manager.Data.Migrations
|
||||
Species = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Cultivar = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Variety = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true)
|
||||
Authority = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
Family = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
CommonName = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
|
||||
ExternalSource = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
ExternalId = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantTaxa", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Recipes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
MeasurementMode = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
|
||||
OutputResourceId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Recipes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Recipes_ActionResources_OutputResourceId",
|
||||
column: x => x.OutputResourceId,
|
||||
principalTable: "ActionResources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CareActivityActions",
|
||||
columns: table => new
|
||||
@@ -132,7 +172,8 @@ namespace plant_manager.Data.Migrations
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
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)
|
||||
Nickname = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
|
||||
Birthday = table.Column<DateOnly>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
@@ -151,6 +192,34 @@ namespace plant_manager.Data.Migrations
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecipeComponents",
|
||||
columns: table => new
|
||||
{
|
||||
RecipeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActionResourceId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 10, scale: 2, nullable: true),
|
||||
Unit = table.Column<string>(type: "TEXT", maxLength: 40, nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RecipeComponents", x => new { x.RecipeId, x.ActionResourceId });
|
||||
table.ForeignKey(
|
||||
name: "FK_RecipeComponents_ActionResources_ActionResourceId",
|
||||
column: x => x.ActionResourceId,
|
||||
principalTable: "ActionResources",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_RecipeComponents_Recipes_RecipeId",
|
||||
column: x => x.RecipeId,
|
||||
principalTable: "Recipes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CareActivityActionResources",
|
||||
columns: table => new
|
||||
@@ -286,6 +355,30 @@ namespace plant_manager.Data.Migrations
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlantGroupMemberships",
|
||||
columns: table => new
|
||||
{
|
||||
PlantId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PlantGroupId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlantGroupMemberships", x => new { x.PlantId, x.PlantGroupId });
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantGroupMemberships_PlantGroups_PlantGroupId",
|
||||
column: x => x.PlantGroupId,
|
||||
principalTable: "PlantGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PlantGroupMemberships_Plants_PlantId",
|
||||
column: x => x.PlantId,
|
||||
principalTable: "Plants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ActionLogResources",
|
||||
columns: table => new
|
||||
@@ -403,6 +496,17 @@ namespace plant_manager.Data.Migrations
|
||||
table: "PlantFlags",
|
||||
columns: new[] { "PlantId", "PlantFlagDefinitionId", "ResolvedOn" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantGroupMemberships_PlantGroupId",
|
||||
table: "PlantGroupMemberships",
|
||||
column: "PlantGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantGroups_Name",
|
||||
table: "PlantGroups",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantLocations_Name",
|
||||
table: "PlantLocations",
|
||||
@@ -418,6 +522,34 @@ namespace plant_manager.Data.Migrations
|
||||
name: "IX_Plants_TaxonId",
|
||||
table: "Plants",
|
||||
column: "TaxonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlantTaxa_ExternalSource_ExternalId",
|
||||
table: "PlantTaxa",
|
||||
columns: new[] { "ExternalSource", "ExternalId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeComponents_ActionResourceId",
|
||||
table: "RecipeComponents",
|
||||
column: "ActionResourceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeComponents_RecipeId_SortOrder",
|
||||
table: "RecipeComponents",
|
||||
columns: new[] { "RecipeId", "SortOrder" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_Name",
|
||||
table: "Recipes",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_OutputResourceId",
|
||||
table: "Recipes",
|
||||
column: "OutputResourceId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -436,10 +568,13 @@ namespace plant_manager.Data.Migrations
|
||||
name: "PlantFlags");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionLogs");
|
||||
name: "PlantGroupMemberships");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionResources");
|
||||
name: "RecipeComponents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CareActivityActions");
|
||||
@@ -447,6 +582,12 @@ namespace plant_manager.Data.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantFlagDefinitions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantGroups");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Recipes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Plants");
|
||||
|
||||
@@ -456,6 +597,9 @@ namespace plant_manager.Data.Migrations
|
||||
migrationBuilder.DropTable(
|
||||
name: "CareActivities");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ActionResources");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlantLocations");
|
||||
|
||||
@@ -485,11 +485,6 @@ namespace plant_manager.Data.Migrations
|
||||
b.Property<int?>("OutputResourceId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace plant_manager.Data.Models
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string MeasurementMode { get; set; } = "quantity";
|
||||
public int? OutputResourceId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@@ -385,7 +385,6 @@ namespace plant_manager.Endpoints
|
||||
{
|
||||
recipe.Id,
|
||||
recipe.Name,
|
||||
recipe.Type,
|
||||
recipe.MeasurementMode,
|
||||
recipe.OutputResource == null ? "" : recipe.OutputResource.Name,
|
||||
string.Join(", ", recipe.Components
|
||||
@@ -398,7 +397,6 @@ namespace plant_manager.Endpoints
|
||||
AddSheet(workbook, "Recipes", [
|
||||
"ID",
|
||||
"Name",
|
||||
"Type",
|
||||
"Measurement Mode",
|
||||
"Output Resource",
|
||||
"Components",
|
||||
|
||||
@@ -39,7 +39,6 @@ namespace plant_manager.Endpoints
|
||||
var recipe = new Recipe
|
||||
{
|
||||
Name = name,
|
||||
Type = request.Type.Trim(),
|
||||
MeasurementMode = validation.MeasurementMode,
|
||||
OutputResource = validation.OutputResourceName is null
|
||||
? null
|
||||
@@ -84,7 +83,6 @@ namespace plant_manager.Endpoints
|
||||
}
|
||||
|
||||
recipe.Name = name;
|
||||
recipe.Type = request.Type.Trim();
|
||||
recipe.MeasurementMode = validation.MeasurementMode;
|
||||
if (validation.OutputResourceName is null)
|
||||
{
|
||||
@@ -136,11 +134,6 @@ namespace plant_manager.Endpoints
|
||||
return ([], "quantity", null, "Recipe name is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Type))
|
||||
{
|
||||
return ([], "quantity", null, "Recipe type is required.");
|
||||
}
|
||||
|
||||
var measurementMode = NormalizeMeasurementMode(request.MeasurementMode);
|
||||
if (measurementMode is null)
|
||||
{
|
||||
@@ -198,9 +191,9 @@ namespace plant_manager.Endpoints
|
||||
}
|
||||
|
||||
if (measurementMode == "bakers_percent"
|
||||
&& !requestedComponents.Any(component => component.Quantity == 100))
|
||||
&& requestedComponents.Count(component => component.Quantity == 100) != 1)
|
||||
{
|
||||
return ([], measurementMode, outputResourceName, "Baker's percent recipes need one base component at 100%.");
|
||||
return ([], measurementMode, outputResourceName, "Baker's percent recipes need exactly one base component at 100%.");
|
||||
}
|
||||
|
||||
var resourceIds = requestedComponents
|
||||
|
||||
Reference in New Issue
Block a user