import { useRef, useState, type ChangeEvent, type DragEvent, type FormEvent } from 'react' import { Alert } from '@/components/ui/Alert' import { Button } from '@/components/ui/Button' import { Dialog } from '@/components/ui/Dialog' import { SelectField, TextField } from '@/components/ui/Field' import { Icon } from '@/components/ui/Icon' import { toast } from '@/components/ui/toast' import { errorMessage } from '@/lib/api' import { cn } from '@/lib/cn' import { CATEGORY_LABELS, PLANT_CATEGORIES, isBuiltin, type PlantCategory, type PlantInput } from '@/lib/plants' import { lotDefaults, newPlantDefaults, useCreateFromPacket, useScanPacket, type PacketProposal } from '@/lib/seedPacket' import { LOT_UNITS, type LotUnit } from '@/lib/seedLots' import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] })) const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label })) // 'new' is "create a new plant"; a number selects that existing candidate. type Selection = number | 'new' /** * Photograph a seed packet → the vision model reads it into fields → the user * matches it to the catalog and confirms (#81/#102). Two steps in one dialog. * The read never writes; a misread can't add anything on its own, and a wrong * auto-match would split a variety's history across duplicate rows — so the * match is always a human choice. Only offered where `capabilities.vision` is * on; a 503 is still handled in case the model is torn down in between. */ export function ScanPacketDialog({ unit, onClose }: { unit: UnitPref; onClose: () => void }) { const scan = useScanPacket() const create = useCreateFromPacket() const fileInput = useRef(null) const scanAbort = useRef(null) const [proposal, setProposal] = useState(null) const [error, setError] = useState(null) const [dragOver, setDragOver] = useState(false) const [selection, setSelection] = useState('new') const [name, setName] = useState('') const [category, setCategory] = useState('vegetable') const [spacing, setSpacing] = useState('') const [days, setDays] = useState('') const [vendor, setVendor] = useState('') const [quantity, setQuantity] = useState('') const [lotUnit, setLotUnit] = useState('packets') const [packedForYear, setPackedForYear] = useState('') const unitLabel = spacingUnitLabel(unit) const busy = scan.isPending || create.isPending function readFile(file: File | undefined) { if (!file) return setError(null) const controller = new AbortController() scanAbort.current = controller scan.mutate( { file, signal: controller.signal }, { onSuccess: (p) => { const plant = newPlantDefaults(p) const lot = lotDefaults(p.packet) setProposal(p) setSelection(p.candidates[0]?.plant.id ?? 'new') setName(plant.name) setCategory(plant.category) setSpacing(String(spacingFromCm(plant.spacingCm, unit))) setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '') setVendor(lot.vendor) setQuantity(String(lot.quantity)) setLotUnit(lot.unit) setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '') }, onError: (err) => { if ((err as Error)?.name === 'AbortError') return setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet.")) }, }, ) } function onFile(e: ChangeEvent) { const file = e.target.files?.[0] e.target.value = '' // so re-picking the same file fires change again readFile(file) } function onDrop(e: DragEvent) { e.preventDefault() setDragOver(false) readFile(e.dataTransfer.files?.[0]) } async function onConfirm(e: FormEvent) { e.preventDefault() if (!proposal) return setError(null) const qty = quantity.trim() === '' ? 0 : Number(quantity) if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.') let year: number | null = null if (packedForYear.trim()) { const y = Number(packedForYear) if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.') year = y } const lot = { vendor: vendor.trim(), sourceUrl: '', sku: proposal.packet.sku, lotCode: proposal.packet.lotCode, purchasedAt: null, packedForYear: year, quantity: qty, unit: lotUnit, costCents: null, germinationPct: null, notes: '', } let newPlant: PlantInput | undefined let plantId: number | undefined if (selection === 'new') { if (!name.trim()) return setError('Name the new plant, or pick an existing one above.') const spacingCm = cmFromSpacing(parseFloat(spacing), unit) if (!Number.isFinite(spacingCm) || spacingCm < 1) return setError(`Spacing must be at least 1 ${unitLabel}.`) let daysToMaturity: number | null = null if (days.trim()) { const d = Number(days) if (!Number.isInteger(d) || d < 1) return setError('Days to maturity is a whole number of days, or blank.') daysToMaturity = d } newPlant = { ...newPlantDefaults(proposal), name: name.trim(), category, spacingCm, daysToMaturity, vendor: vendor.trim() } } else { plantId = selection } try { const res = await create.mutateAsync({ plantId, newPlant, lot }) toast.info(res.plantIsNew ? `Added ${res.plant.name} and its seed lot.` : `Recorded a seed lot for ${res.plant.name}.`) onClose() } catch (err) { setError(errorMessage(err, 'Could not save the packet.')) } } const variety = proposal ? [proposal.packet.species, proposal.packet.variety].filter(Boolean).join(' — ') : '' return ( {!proposal ? ( <>
{ e.preventDefault() setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={onDrop} className={cn( 'flex flex-col items-center gap-2.5 rounded-lg border-2 border-dashed px-5 py-9 text-center', dragOver ? 'border-accent-400 bg-accent-100' : 'border-neutral-400', )} >
Photograph the packet — front is enough
The vision model reads it into fields. It only reads; nothing is saved until you confirm.
{scan.isPending ? (

Reading the packet… this can take a few seconds.

) : ( )}
{error && {error}}
{/* Never disabled — this is the way out of a slow scan. */}
) : (
setName(e.target.value)} /> setVendor(e.target.value)} /> setPackedForYear(e.target.value)} />
setQuantity(e.target.value)} />
Match it to your catalog — nothing is auto-created:
{proposal.candidates.map((c, i) => ( setSelection(c.plant.id)} label={`${c.plant.name} (${isBuiltin(c.plant) ? 'built-in' : 'yours'})`} sub={i === 0 ? `best match · ${c.reason}` : c.reason} /> ))} setSelection('new')} label="Create a new plant" sub="from the extracted fields" />
{selection === 'new' && (
setCategory(e.target.value as PlantCategory)} options={categoryOptions} /> setSpacing(e.target.value)} /> setDays(e.target.value)} wrapperClassName="col-span-2" />
)} {error && {error}}
)}
) } function MatchOption({ selected, onSelect, label, sub }: { selected: boolean; onSelect: () => void; label: string; sub: string }) { return ( ) }