misc improvements

This commit is contained in:
2026-06-12 23:53:21 +00:00
parent fadad33648
commit 65fa730d3d
35 changed files with 1300 additions and 2867 deletions
-10
View File
@@ -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",
-1
View File
@@ -10,7 +10,6 @@
"lint": "eslint ."
},
"dependencies": {
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
+8 -97
View File
@@ -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>
</>
);
}
+91 -83
View File
@@ -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>
</>
);
}
+51 -50
View File
@@ -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>
</>
);
}
+7 -19
View File
@@ -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.';
}
+131 -86
View File
@@ -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>
+56 -60
View File
@@ -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>
</>
);
}
+170
View File
@@ -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>
);
}
-3
View File
@@ -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[];
+8 -7
View File
@@ -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) => ({
+90 -42
View File
@@ -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,
+3 -44
View File
@@ -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,
};
}
+20 -13
View File
@@ -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,
+1 -5
View File
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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")
@@ -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")
-1
View File
@@ -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",
+2 -9
View File
@@ -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