diff --git a/web/src/components/plants/CategoryChips.tsx b/web/src/components/plants/CategoryChips.tsx
new file mode 100644
index 0000000..4ea0a0f
--- /dev/null
+++ b/web/src/components/plants/CategoryChips.tsx
@@ -0,0 +1,37 @@
+import { cn } from '@/lib/cn'
+import { CATEGORY_LABELS, PLANT_CATEGORIES, type CategoryFilter } from '@/lib/plants'
+
+/**
+ * Horizontal, scrollable "All + each category" chip row. Shared by the /plants
+ * page and the PlantPicker so both filter the catalog identically.
+ */
+export function CategoryChips({
+ value,
+ onChange,
+ size = 'md',
+}: {
+ value: CategoryFilter
+ onChange: (c: CategoryFilter) => void
+ size?: 'sm' | 'md'
+}) {
+ const chip = (v: CategoryFilter, label: string) => (
+
+ )
+ return (
+
+ {chip('all', 'All')}
+ {PLANT_CATEGORIES.map((c) => chip(c, CATEGORY_LABELS[c]))}
+
+ )
+}
diff --git a/web/src/components/plants/DeletePlantModal.tsx b/web/src/components/plants/DeletePlantModal.tsx
new file mode 100644
index 0000000..69e1798
--- /dev/null
+++ b/web/src/components/plants/DeletePlantModal.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react'
+import { Modal } from '@/components/ui/Modal'
+import { Alert } from '@/components/ui/Alert'
+import { Button } from '@/components/ui/Button'
+import { errorMessage } from '@/lib/api'
+import { useDeletePlant, type Plant } from '@/lib/plants'
+
+/**
+ * Confirmation dialog for deleting a custom plant. A plant still used by
+ * plantings is refused by the server (409 PLANT_IN_USE); we surface that
+ * message inline rather than pretending it worked.
+ */
+export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
+ const deletion = useDeletePlant()
+ const [error, setError] = useState(null)
+
+ async function onConfirm() {
+ setError(null)
+ try {
+ await deletion.mutateAsync(plant.id)
+ onClose()
+ } catch (err) {
+ setError(errorMessage(err, 'Could not delete the plant.'))
+ }
+ }
+
+ return (
+
+
+
+ Delete {plant.name} from your catalog? This can't be
+ undone.
+
+ {error &&
{error}}
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx
new file mode 100644
index 0000000..c772e9d
--- /dev/null
+++ b/web/src/components/plants/PlantCard.tsx
@@ -0,0 +1,72 @@
+import { PlantIcon } from './PlantIcon'
+import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
+import { formatSpacing, type UnitPref } from '@/lib/units'
+
+const actionClass =
+ 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
+ 'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40'
+const dangerClass =
+ 'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
+ 'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400'
+
+/**
+ * One catalog plant as a card: icon tile tinted with the plant's color, name,
+ * category + mature spacing (unit-aware), and actions. Built-ins are badged and
+ * offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
+ */
+export function PlantCard({
+ plant,
+ unit,
+ onEdit,
+ onDelete,
+ onDuplicate,
+}: {
+ plant: Plant
+ unit: UnitPref
+ onEdit: () => void
+ onDelete: () => void
+ onDuplicate: () => void
+}) {
+ const builtin = isBuiltin(plant)
+ return (
+
+
+
+
+
+
{plant.name}
+ {builtin && (
+
+ Built-in
+
+ )}
+
+
+ {CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
+
+ {plant.notes &&
{plant.notes}
}
+
+
+
+
+
+ {!builtin && (
+
+ )}
+ {!builtin && (
+
+ )}
+
+
+ )
+}
diff --git a/web/src/components/plants/PlantFormModal.tsx b/web/src/components/plants/PlantFormModal.tsx
new file mode 100644
index 0000000..70faae3
--- /dev/null
+++ b/web/src/components/plants/PlantFormModal.tsx
@@ -0,0 +1,212 @@
+import { useState, type FormEvent } from 'react'
+import { Modal } from '@/components/ui/Modal'
+import { Alert } from '@/components/ui/Alert'
+import { Button } from '@/components/ui/Button'
+import { Select } from '@/components/ui/Select'
+import { TextArea } from '@/components/ui/TextArea'
+import { TextField } from '@/components/ui/TextField'
+import { errorMessage } from '@/lib/api'
+import {
+ CATEGORY_LABELS,
+ PLANT_CATEGORIES,
+ conflictPlant,
+ useCreatePlant,
+ useUpdatePlant,
+ type Plant,
+ type PlantCategory,
+ type PlantInput,
+} from '@/lib/plants'
+import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
+
+const DEFAULT_COLOR = '#4a7c3f'
+const DEFAULT_ICON = '🌱'
+
+const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
+
+/** Expand a #rgb shorthand to #rrggbb so the native color input renders it. */
+function expandHex(color: string): string {
+ if (/^#[0-9a-fA-F]{3}$/.test(color)) {
+ const [, r, g, b] = color
+ return `#${r}${r}${g}${g}${b}${b}`
+ }
+ return /^#[0-9a-fA-F]{6}$/.test(color) ? color : DEFAULT_COLOR
+}
+
+/**
+ * Create or edit a custom plant. `plant` puts it in edit mode; `template` (a
+ * built-in or another plant, used by "Duplicate") pre-fills a fresh create. A
+ * 409 rebases the form onto the server's current row. Spacing is entered in the
+ * page's unit and converted to centimeters for the API.
+ */
+export function PlantFormModal({
+ plant,
+ template,
+ unit,
+ onClose,
+}: {
+ plant?: Plant
+ template?: Plant
+ unit: UnitPref
+ onClose: () => void
+}) {
+ const isEdit = !!plant
+ const source = plant ?? template
+ const create = useCreatePlant()
+ const update = useUpdatePlant()
+ const pending = create.isPending || update.isPending
+
+ const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
+ const [category, setCategory] = useState(source?.category ?? 'vegetable')
+ const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
+ const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
+ const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
+ const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
+ const [notes, setNotes] = useState(source?.notes ?? '')
+ const [version, setVersion] = useState(plant?.version ?? 0)
+ const [conflict, setConflict] = useState(null)
+ const [formError, setFormError] = useState(null)
+ const unitLabel = spacingUnitLabel(unit)
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault()
+ setFormError(null)
+ setConflict(null)
+
+ if (!name.trim()) {
+ setFormError('Enter a name for the plant.')
+ return
+ }
+ if (!icon.trim()) {
+ setFormError('Pick an emoji icon.')
+ return
+ }
+ const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
+ if (!Number.isFinite(spacingCm) || spacingCm < 1) {
+ setFormError(`Spacing must be at least 1 ${unitLabel}.`)
+ return
+ }
+ let daysToMaturity: number | null = null
+ if (days.trim()) {
+ // Number() (not parseInt) so "1.5" is rejected, not silently truncated to 1.
+ const d = Number(days)
+ if (!Number.isInteger(d) || d < 1) {
+ setFormError('Days to maturity must be a whole number of days, or left blank.')
+ return
+ }
+ daysToMaturity = d
+ }
+
+ const input: PlantInput = {
+ name: name.trim(),
+ category,
+ spacingCm,
+ color,
+ icon: icon.trim(),
+ daysToMaturity,
+ notes: notes.trim(),
+ }
+
+ try {
+ if (isEdit) {
+ await update.mutateAsync({ id: plant.id, ...input, version })
+ } else {
+ await create.mutateAsync(input)
+ }
+ onClose()
+ } catch (err) {
+ const current = conflictPlant(err)
+ if (current) {
+ // Someone else changed this plant: rebase onto the fresh row so a re-save
+ // applies against the current version.
+ setVersion(current.version)
+ setName(current.name)
+ setCategory(current.category)
+ setSpacing(String(spacingFromCm(current.spacingCm, unit)))
+ setColor(expandHex(current.color))
+ setIcon(current.icon)
+ setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
+ setNotes(current.notes)
+ setConflict('This plant changed elsewhere. The latest values are shown — review and save again.')
+ return
+ }
+ setFormError(errorMessage(err, isEdit ? 'Could not save changes.' : 'Could not create the plant.'))
+ }
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/web/src/components/plants/PlantIcon.tsx b/web/src/components/plants/PlantIcon.tsx
new file mode 100644
index 0000000..2239039
--- /dev/null
+++ b/web/src/components/plants/PlantIcon.tsx
@@ -0,0 +1,18 @@
+import { cn } from '@/lib/cn'
+
+/**
+ * A plant's emoji on a tile tinted with its own color (via color-mix, so any
+ * valid CSS color works). Shared by PlantCard and the PlantPicker rows. Size and
+ * shape come from `className`.
+ */
+export function PlantIcon({ color, icon, className }: { color: string; icon: string; className?: string }) {
+ return (
+
+ {icon}
+
+ )
+}
diff --git a/web/src/editor/PlantPicker.tsx b/web/src/editor/PlantPicker.tsx
new file mode 100644
index 0000000..3e86f8c
--- /dev/null
+++ b/web/src/editor/PlantPicker.tsx
@@ -0,0 +1,156 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { cn } from '@/lib/cn'
+import { fieldControlClass } from '@/components/ui/field'
+import { CategoryChips } from '@/components/plants/CategoryChips'
+import { PlantIcon } from '@/components/plants/PlantIcon'
+import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
+import { formatSpacing, type UnitPref } from '@/lib/units'
+
+const RECENT_KEY = 'pansy:recent-plants'
+const RECENT_MAX = 8
+
+/** Recently-picked plant ids, most-recent first, from localStorage. */
+function loadRecent(): number[] {
+ try {
+ const v: unknown = JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]')
+ return Array.isArray(v) ? v.filter((n): n is number => typeof n === 'number') : []
+ } catch {
+ return []
+ }
+}
+
+/** Prepend id (dedup, capped) and persist; returns the new list. */
+function recordRecent(id: number): number[] {
+ const next = [id, ...loadRecent().filter((n) => n !== id)].slice(0, RECENT_MAX)
+ try {
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next))
+ } catch {
+ // Recents are a nicety; ignore quota/availability failures.
+ }
+ return next
+}
+
+/**
+ * Reusable plant chooser: a search-first list with a recently-used shortcut and
+ * category filter, rendered as a bottom sheet on mobile / centered panel on
+ * desktop. `onSelect` fires with the chosen plant (and records it as recent).
+ * The plops editor (#15) opens this when placing plants; /plants demos it.
+ */
+export function PlantPicker({
+ onSelect,
+ onClose,
+ unit = 'metric',
+}: {
+ onSelect: (plant: Plant) => void
+ onClose: () => void
+ unit?: UnitPref
+}) {
+ const plants = usePlants()
+ const [query, setQuery] = useState('')
+ const [category, setCategory] = useState('all')
+ const [recent, setRecent] = useState(() => loadRecent())
+ const searchRef = useRef(null)
+ const onCloseRef = useRef(onClose)
+ onCloseRef.current = onClose
+
+ useEffect(() => {
+ searchRef.current?.focus()
+ function onKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') onCloseRef.current()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [])
+
+ const all = useMemo(() => plants.data ?? [], [plants.data])
+
+ const filtered = useMemo(() => filterPlants(all, query, category), [all, query, category])
+
+ // Recents show only when not actively searching, and only rows still present.
+ const recentPlants = useMemo(() => {
+ if (query.trim() !== '') return []
+ const byId = new Map(all.map((p) => [p.id, p]))
+ return recent.map((id) => byId.get(id)).filter((p): p is Plant => !!p)
+ }, [all, recent, query])
+
+ function choose(p: Plant) {
+ setRecent(recordRecent(p.id))
+ onSelect(p)
+ }
+
+ // A plant option row. keyPrefix namespaces the key so a plant appearing in
+ // both the Recent and All sections doesn't collide on a duplicate React key.
+ const row = (p: Plant, keyPrefix: string) => (
+
+ )
+
+ return (
+ e.target === e.currentTarget && onClose()}
+ >
+
+
+ setQuery(e.target.value)}
+ placeholder="Search plants…"
+ aria-label="Search plants"
+ className={cn(fieldControlClass, 'min-w-0 flex-1')}
+ />
+
+
+
+
+
+
+
+
+ {plants.isPending &&
Loading plants…
}
+ {plants.isError &&
Couldn't load plants.
}
+
+ {recentPlants.length > 0 && (
+ <>
+
Recent
+ {recentPlants.map((p) => row(p, 'recent'))}
+
All plants
+ >
+ )}
+
+ {filtered.map((p) => row(p, 'all'))}
+
+ {plants.isSuccess && filtered.length === 0 && (
+
No plants match your search.
+ )}
+
+
+
+ )
+}
diff --git a/web/src/lib/plants.ts b/web/src/lib/plants.ts
new file mode 100644
index 0000000..52f885c
--- /dev/null
+++ b/web/src/lib/plants.ts
@@ -0,0 +1,122 @@
+// Plant catalog data layer: zod-validated shapes for /api/v1/plants plus the
+// react-query hooks the /plants page and PlantPicker use. Built-ins (ownerId
+// null) are seeded and read-only; a user's own rows are editable. "Cloning" a
+// built-in is just a create of a copy (no special endpoint).
+
+import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { z } from 'zod'
+import { ApiError, api } from './api'
+
+export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
+export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
+
+export const CATEGORY_LABELS: Record = {
+ vegetable: 'Vegetable',
+ herb: 'Herb',
+ flower: 'Flower',
+ fruit: 'Fruit',
+ tree_shrub: 'Tree / shrub',
+ cover: 'Cover crop',
+}
+
+const plantCategorySchema = z.enum(PLANT_CATEGORIES)
+
+export const plantSchema = z.object({
+ id: z.number(),
+ // Absent (built-in) or a user id. The API omits ownerId for built-ins.
+ ownerId: z.number().nullable().optional(),
+ name: z.string(),
+ category: plantCategorySchema,
+ spacingCm: z.number(),
+ color: z.string(),
+ icon: z.string(),
+ daysToMaturity: z.number().nullable().optional(),
+ notes: z.string(),
+ version: z.number(),
+ createdAt: z.string(),
+ updatedAt: z.string(),
+})
+export type Plant = z.infer
+
+/** A built-in (seeded, read-only) plant has no owner. */
+export function isBuiltin(p: Plant): boolean {
+ return p.ownerId == null
+}
+
+/** Category filter value: a specific category, or 'all'. Shared by the catalog
+ * page and the PlantPicker so their chip rows and filtering stay in sync. */
+export type CategoryFilter = PlantCategory | 'all'
+
+/** Filter a plant list by a name query (case-insensitive substring) and
+ * category. The single source of truth for both the /plants grid and the
+ * PlantPicker list. */
+export function filterPlants(plants: Plant[], query: string, category: CategoryFilter): Plant[] {
+ const q = query.trim().toLowerCase()
+ return plants.filter(
+ (p) => (category === 'all' || p.category === category) && (q === '' || p.name.toLowerCase().includes(q)),
+ )
+}
+
+const plantsKey = ['plants'] as const
+
+export const plantsQueryOptions = queryOptions({
+ queryKey: plantsKey,
+ queryFn: async (): Promise => z.array(plantSchema).parse(await api.get('/plants')),
+})
+
+export function usePlants() {
+ return useQuery(plantsQueryOptions)
+}
+
+export interface PlantInput {
+ name: string
+ category: PlantCategory
+ spacingCm: number
+ color: string
+ icon: string
+ daysToMaturity: number | null
+ notes: string
+}
+
+export function useCreatePlant() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: async (input: PlantInput): Promise => plantSchema.parse(await api.post('/plants', input)),
+ onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
+ })
+}
+
+export interface PlantUpdate extends PlantInput {
+ id: number
+ version: number
+}
+
+export function useUpdatePlant() {
+ const qc = useQueryClient()
+ return useMutation({
+ // id/version travel with the mutation variables so a single hook serves any row.
+ mutationFn: async ({ id, ...body }: PlantUpdate): Promise =>
+ plantSchema.parse(await api.patch(`/plants/${id}`, body)),
+ onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
+ })
+}
+
+export function useDeletePlant() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: number) => api.delete(`/plants/${id}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: plantsKey }),
+ })
+}
+
+/**
+ * If err is a 409 version conflict, return the fresh server row it carries
+ * (under `current`), so a form can rebase onto it; otherwise null.
+ */
+export function conflictPlant(err: unknown): Plant | null {
+ if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
+ const parsed = plantSchema.safeParse((err.body as { current?: unknown }).current)
+ if (parsed.success) return parsed.data
+ }
+ return null
+}
diff --git a/web/src/lib/units.test.ts b/web/src/lib/units.test.ts
index e72bd35..f694565 100644
--- a/web/src/lib/units.test.ts
+++ b/web/src/lib/units.test.ts
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'
-import { cmFromFtIn, cmFromMeters, displayFromCm, formatCm, formatDimensions } from './units'
+import {
+ cmFromFtIn,
+ cmFromMeters,
+ cmFromSpacing,
+ displayFromCm,
+ formatCm,
+ formatDimensions,
+ formatSpacing,
+ spacingFromCm,
+} from './units'
describe('cm conversions', () => {
it('feet+inches → whole cm', () => {
@@ -42,3 +51,21 @@ describe('formatCm', () => {
expect(formatDimensions(122, 244, 'imperial')).toBe('4′ 0″ × 8′ 0″')
})
})
+
+describe('plant spacing (small scale)', () => {
+ it('formats cm in metric, whole inches in imperial', () => {
+ expect(formatSpacing(15, 'metric')).toBe('15 cm')
+ expect(formatSpacing(15, 'imperial')).toBe('6″') // 15/2.54 = 5.9 → 6
+ expect(formatSpacing(30, 'imperial')).toBe('12″')
+ })
+
+ it('parses an entered value back to whole cm', () => {
+ expect(cmFromSpacing(15, 'metric')).toBe(15)
+ expect(cmFromSpacing(6, 'imperial')).toBe(15) // 6in = 15.24 → 15
+ })
+
+ it('prefills an input from cm', () => {
+ expect(spacingFromCm(15, 'metric')).toBe(15)
+ expect(spacingFromCm(30, 'imperial')).toBe(11.8) // 30/2.54 = 11.81 → 11.8
+ })
+})
diff --git a/web/src/lib/units.ts b/web/src/lib/units.ts
index b7e3554..9e98b7c 100644
--- a/web/src/lib/units.ts
+++ b/web/src/lib/units.ts
@@ -68,3 +68,29 @@ export function formatDimensions(widthCm: number, heightCm: number, unit: UnitPr
export function dimensionUnitLabel(unit: UnitPref): string {
return unit === 'imperial' ? 'ft' : 'm'
}
+
+// --- Plant spacing (small scale) -------------------------------------------
+// Plant spacing is a handful of centimeters, so meters/feet read poorly here;
+// metric shows cm and imperial shows whole inches.
+
+/** Centimeters → a spacing string in the given unit (e.g. "15 cm" or "6″"). */
+export function formatSpacing(cm: number, unit: UnitPref): string {
+ if (unit === 'imperial') return `${Math.round(cm / CM_PER_INCH)}″`
+ return `${Math.round(cm)} cm`
+}
+
+/** A spacing value typed in the given unit (cm, or inches) → whole centimeters. */
+export function cmFromSpacing(value: number, unit: UnitPref): number {
+ return unit === 'imperial' ? Math.round(value * CM_PER_INCH) : Math.round(value)
+}
+
+/** Centimeters → a spacing number in the given unit, for prefilling an input.
+ * Inches to 0.1 so cm-quantization doesn't show through. */
+export function spacingFromCm(cm: number, unit: UnitPref): number {
+ return unit === 'imperial' ? Math.round((cm / CM_PER_INCH) * 10) / 10 : Math.round(cm)
+}
+
+/** The spacing input-field unit label. */
+export function spacingUnitLabel(unit: UnitPref): string {
+ return unit === 'imperial' ? 'in' : 'cm'
+}
diff --git a/web/src/pages/PlantsPage.tsx b/web/src/pages/PlantsPage.tsx
index eb6344f..648eb1f 100644
--- a/web/src/pages/PlantsPage.tsx
+++ b/web/src/pages/PlantsPage.tsx
@@ -1,5 +1,134 @@
-import { PageStub } from '@/components/PageStub'
+import { useMemo, useState } from 'react'
+import { Alert } from '@/components/ui/Alert'
+import { Button } from '@/components/ui/Button'
+import { fieldControlClass } from '@/components/ui/field'
+import { toast } from '@/components/ui/toast'
+import { CategoryChips } from '@/components/plants/CategoryChips'
+import { PlantCard } from '@/components/plants/PlantCard'
+import { PlantFormModal } from '@/components/plants/PlantFormModal'
+import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
+import { PlantPicker } from '@/editor/PlantPicker'
+import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
+import type { UnitPref } from '@/lib/units'
+
+// Which modal is open, if any. edit/duplicate/delete carry the target plant.
+type Dialog =
+ | { kind: 'create' }
+ | { kind: 'edit'; plant: Plant }
+ | { kind: 'duplicate'; plant: Plant }
+ | { kind: 'delete'; plant: Plant }
+ | { kind: 'picker' }
+ | null
+
+// There's no per-user unit preference server-side (only per-garden), so the
+// catalog page keeps its own, persisted locally.
+const UNIT_KEY = 'pansy:unit-pref'
+function loadUnit(): UnitPref {
+ try {
+ return localStorage.getItem(UNIT_KEY) === 'imperial' ? 'imperial' : 'metric'
+ } catch {
+ return 'metric'
+ }
+}
export function PlantsPage() {
- return The plant catalog arrives in issue #13.
+ const plants = usePlants()
+ const [unit, setUnit] = useState(() => loadUnit())
+ const [query, setQuery] = useState('')
+ const [category, setCategory] = useState('all')
+ const [dialog, setDialog] = useState