Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #119.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
lotDefaults,
|
||||
newPlantDefaults,
|
||||
useCreateFromPacket,
|
||||
useScanPacket,
|
||||
type PacketProposal,
|
||||
} from '@/lib/seedPacket'
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
PLANT_CATEGORIES,
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
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 variety"; a number selects that existing candidate plant.
|
||||
type Selection = number | 'new'
|
||||
|
||||
/**
|
||||
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
|
||||
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
|
||||
* review never commits blind — the model can misread, so every committed field is
|
||||
* editable and the human picks "this is an existing plant" vs "a new variety".
|
||||
*
|
||||
* Only offered where `capabilities.vision` is on (the caller gates the entry
|
||||
* point), so a scan should always be possible; a 503 is still handled in case the
|
||||
* model is torn down between the capabilities poll and the upload.
|
||||
*/
|
||||
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
|
||||
const scan = useScanPacket()
|
||||
const create = useCreateFromPacket()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
|
||||
// dialog is never a trap the user can only escape by reloading the page.
|
||||
const scanAbort = useRef<AbortController | null>(null)
|
||||
|
||||
const [proposal, setProposal] = useState<PacketProposal | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Review-phase fields, seeded from the proposal when a scan lands.
|
||||
const [selection, setSelection] = useState<Selection>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [category, setCategory] = useState<PlantCategory>('vegetable')
|
||||
const [spacing, setSpacing] = useState('')
|
||||
const [days, setDays] = useState('')
|
||||
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
|
||||
// creating one) and the lot.
|
||||
const [vendor, setVendor] = useState('')
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
|
||||
const [sku, setSku] = useState('')
|
||||
const [lotCode, setLotCode] = useState('')
|
||||
const [packedForYear, setPackedForYear] = useState('')
|
||||
const [cost, setCost] = useState('')
|
||||
|
||||
const unitLabel = spacingUnitLabel(unit)
|
||||
const busy = scan.isPending || create.isPending
|
||||
|
||||
function onFile(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
// Reset the input so re-picking the same file fires change again (e.g. after
|
||||
// an error, retrying the same photo).
|
||||
e.target.value = ''
|
||||
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)
|
||||
// Default to the top candidate when there is one — the likely case is
|
||||
// the packet is a variety already in the catalog — else create a new one.
|
||||
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)
|
||||
setSku(lot.sku)
|
||||
setLotCode(lot.lotCode)
|
||||
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
|
||||
// Cost isn't on a packet, so it's the one field not reseeded from the
|
||||
// proposal; clear it so a value typed before a Rescan doesn't linger.
|
||||
setCost('')
|
||||
},
|
||||
onError: (err) => {
|
||||
// An aborted scan is a user cancel, not a failure — and Cancel also
|
||||
// closes the dialog, so there's nothing to report.
|
||||
if ((err as Error)?.name === 'AbortError') return
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!proposal) return
|
||||
setError(null)
|
||||
|
||||
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
|
||||
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||
if (!Number.isFinite(qty) || qty < 0) {
|
||||
setError('Quantity must be a number, or left blank.')
|
||||
return
|
||||
}
|
||||
let year: number | null = null
|
||||
if (packedForYear.trim()) {
|
||||
const y = Number(packedForYear)
|
||||
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||
setError('Packed-for year should be a four-digit year.')
|
||||
return
|
||||
}
|
||||
year = y
|
||||
}
|
||||
let costCents: number | null = null
|
||||
if (cost.trim()) {
|
||||
const c = Number(cost)
|
||||
if (!Number.isFinite(c) || c < 0) {
|
||||
setError('Cost must be an amount, or left blank.')
|
||||
return
|
||||
}
|
||||
costCents = Math.round(c * 100)
|
||||
}
|
||||
|
||||
const lot = {
|
||||
vendor: vendor.trim(),
|
||||
sourceUrl: '',
|
||||
sku: sku.trim(),
|
||||
lotCode: lotCode.trim(),
|
||||
purchasedAt: null,
|
||||
packedForYear: year,
|
||||
quantity: qty,
|
||||
unit: lotUnit,
|
||||
costCents,
|
||||
germinationPct: null,
|
||||
notes: '',
|
||||
}
|
||||
|
||||
let newPlant: PlantInput | undefined
|
||||
let plantId: number | undefined
|
||||
if (selection === 'new') {
|
||||
if (!name.trim()) {
|
||||
setError('Name the new variety, or pick an existing plant above.')
|
||||
return
|
||||
}
|
||||
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
|
||||
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
|
||||
setError(`Spacing must be at least 1 ${unitLabel}.`)
|
||||
return
|
||||
}
|
||||
let daysToMaturity: number | null = null
|
||||
if (days.trim()) {
|
||||
const d = Number(days)
|
||||
if (!Number.isInteger(d) || d < 1) {
|
||||
setError('Days to maturity must be a whole number of days, or left blank.')
|
||||
return
|
||||
}
|
||||
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.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
|
||||
{!proposal ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Take a photo of the front of a seed packet and pansy reads the details off it — you review and
|
||||
confirm before anything is saved.
|
||||
</p>
|
||||
|
||||
{/* A hidden input is triggered by the buttons below. `capture` hints a
|
||||
phone to open the camera; on desktop it's ignored and both buttons
|
||||
open a file chooser. */}
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{scan.isPending ? (
|
||||
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
|
||||
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||
Reading the packet… this can take a few seconds.
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Take or choose a photo
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
{/* Not disabled while scanning — this is the way out of a slow scan.
|
||||
Aborting a settled/absent request is a harmless no-op. */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
scanAbort.current?.abort()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onConfirm} className="flex flex-col gap-4">
|
||||
<ReadFields proposal={proposal} unit={unit} />
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-sm font-medium text-fg">This packet is…</legend>
|
||||
{proposal.candidates.map((c) => (
|
||||
<label
|
||||
key={c.plant.id}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === c.plant.id}
|
||||
onChange={() => setSelection(c.plant.id)}
|
||||
/>
|
||||
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
|
||||
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
|
||||
<span className="text-xs text-muted">{c.reason}</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
|
||||
<input
|
||||
type="radio"
|
||||
name="packet-selection"
|
||||
checked={selection === 'new'}
|
||||
onChange={() => setSelection('new')}
|
||||
/>
|
||||
<span className="flex-1 font-medium text-fg">
|
||||
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{selection === 'new' && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
|
||||
<TextField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint="You can set an icon and color later from the plant card."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
label="Category"
|
||||
name="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as PlantCategory)}
|
||||
options={categoryOptions}
|
||||
/>
|
||||
<TextField
|
||||
label={`Spacing (${unitLabel})`}
|
||||
name="spacing"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="1"
|
||||
required
|
||||
value={spacing}
|
||||
onChange={(e) => setSpacing(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Days to maturity (optional)"
|
||||
name="days"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="1"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The seed lot — what you bought — recorded against whichever plant. */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-fg">Seed lot</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Quantity"
|
||||
name="quantity"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="any"
|
||||
min="0"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Unit"
|
||||
name="unit"
|
||||
value={lotUnit}
|
||||
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
|
||||
options={unitOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||
<TextField
|
||||
label="Packed for"
|
||||
name="packedForYear"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder="2026"
|
||||
value={packedForYear}
|
||||
onChange={(e) => setPackedForYear(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||
<TextField
|
||||
label="Cost"
|
||||
name="cost"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="4.99"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// Clear the review-phase error too, or it would show on the
|
||||
// capture screen we're returning to.
|
||||
setError(null)
|
||||
setProposal(null)
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Rescan
|
||||
</Button>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** A compact read-only summary of what the model pulled off the packet, so the
|
||||
* user can see the extraction at a glance while they confirm. Only fields that
|
||||
* came back are shown. */
|
||||
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
|
||||
const p = proposal.packet
|
||||
const rows: [string, string][] = []
|
||||
if (p.species) rows.push(['Species', p.species])
|
||||
if (p.variety) rows.push(['Variety', p.variety])
|
||||
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
|
||||
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
|
||||
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
|
||||
if (rows.length === 0) return null
|
||||
return (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
|
||||
{rows.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<dt className="text-muted">{k}</dt>
|
||||
<dd className="text-fg">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user