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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+17
-8
@@ -11,18 +11,27 @@ import { API_BASE, api } from './api'
|
|||||||
import { gardenFullKey } from './objects'
|
import { gardenFullKey } from './objects'
|
||||||
import { historyKey } from './history'
|
import { historyKey } from './history'
|
||||||
|
|
||||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
// What this instance can actually do, so the UI offers only what works: `agent`
|
||||||
|
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
|
||||||
|
// default to false so a partial/older response just hides the feature rather
|
||||||
|
// than failing the whole parse.
|
||||||
|
const capabilitiesSchema = z.object({
|
||||||
|
agent: z.boolean().default(false),
|
||||||
|
vision: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
export type Capabilities = z.infer<typeof capabilitiesSchema>
|
||||||
|
|
||||||
export const capabilitiesKey = ['capabilities'] as const
|
export const capabilitiesKey = ['capabilities'] as const
|
||||||
|
|
||||||
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
/** What this instance can do right now: whether the assistant is live and whether
|
||||||
* at all — a dead button is worse than no button.
|
* seed-packet scanning will work. Without a capability the matching feature isn't
|
||||||
|
* offered at all — a dead button is worse than no button.
|
||||||
*
|
*
|
||||||
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
|
||||||
* in Settings (#79), so this must be able to change under a running page. The
|
* model on or off in Settings (#79), so this must be able to change under a
|
||||||
* settings save invalidates this key directly; the finite staleTime just means
|
* running page. The settings save invalidates this key directly; the finite
|
||||||
* another admin's change is picked up on the next focus/remount rather than
|
* staleTime just means another admin's change is picked up on the next
|
||||||
* never. */
|
* focus/remount rather than never. */
|
||||||
export function useCapabilities() {
|
export function useCapabilities() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: capabilitiesKey,
|
queryKey: capabilitiesKey,
|
||||||
|
|||||||
+13
-6
@@ -42,7 +42,8 @@ export type Params = Record<string, ParamValue>
|
|||||||
|
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
/** Request body. A `FormData` goes out as multipart (a file upload — the
|
||||||
|
* seed-packet scan); anything else is serialized as JSON. */
|
||||||
body?: unknown
|
body?: unknown
|
||||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||||
params?: Params
|
params?: Params
|
||||||
@@ -90,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
|
|||||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', body, params, signal } = opts
|
const { method = 'GET', body, params, signal } = opts
|
||||||
|
|
||||||
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
|
// FormData must go out as multipart with a browser-generated boundary, so it's
|
||||||
// surfaces as itself, not as a misleading "cannot reach the server" error.
|
// sent as-is with NO content-type header (the browser sets it, boundary
|
||||||
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
|
// included). Everything else is JSON — serialized before the try so a
|
||||||
|
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
|
||||||
|
// misleading "cannot reach the server" error.
|
||||||
|
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||||
|
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
@@ -102,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
|
|||||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
|
||||||
},
|
},
|
||||||
body: requestBody,
|
body: requestBody as BodyInit | undefined,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error)?.name === 'AbortError') throw err
|
if ((err as Error)?.name === 'AbortError') throw err
|
||||||
@@ -143,6 +148,8 @@ export const api = {
|
|||||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||||
|
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
|
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
|
||||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function filterPlants(plants: Plant[], query: string, category: CategoryF
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const plantsKey = ['plants'] as const
|
export const plantsKey = ['plants'] as const
|
||||||
|
|
||||||
export const plantsQueryOptions = queryOptions({
|
export const plantsQueryOptions = queryOptions({
|
||||||
queryKey: plantsKey,
|
queryKey: plantsKey,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const seedLotSchema = z.object({
|
|||||||
})
|
})
|
||||||
export type SeedLot = z.infer<typeof seedLotSchema>
|
export type SeedLot = z.infer<typeof seedLotSchema>
|
||||||
|
|
||||||
const seedLotsKey = ['seed-lots'] as const
|
export const seedLotsKey = ['seed-lots'] as const
|
||||||
|
|
||||||
export function useSeedLots() {
|
export function useSeedLots() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
lotDefaults,
|
||||||
|
newPlantDefaults,
|
||||||
|
packetProposalSchema,
|
||||||
|
seedPacketSchema,
|
||||||
|
PACKET_PLANT_COLOR,
|
||||||
|
PACKET_PLANT_ICON,
|
||||||
|
} from './seedPacket'
|
||||||
|
|
||||||
|
// A full proposal as the backend sends it, for the schema + prefill tests.
|
||||||
|
const proposal = {
|
||||||
|
packet: {
|
||||||
|
species: 'garlic',
|
||||||
|
variety: 'Music',
|
||||||
|
category: 'vegetable',
|
||||||
|
vendor: "Johnny's",
|
||||||
|
sku: 'G-123',
|
||||||
|
lotCode: 'L9',
|
||||||
|
packedForYear: 2026,
|
||||||
|
daysToMaturity: 90,
|
||||||
|
spacingCm: 15,
|
||||||
|
seedCount: 12,
|
||||||
|
},
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
plant: {
|
||||||
|
id: 7,
|
||||||
|
name: 'Music',
|
||||||
|
category: 'vegetable',
|
||||||
|
spacingCm: 15,
|
||||||
|
color: '#fff',
|
||||||
|
icon: '🧄',
|
||||||
|
notes: '',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
reason: 'exact name',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
suggestedName: 'Music',
|
||||||
|
suggestedCategory: 'vegetable',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('seedPacketSchema', () => {
|
||||||
|
it('fills defaults for a sparse packet (only what was printed)', () => {
|
||||||
|
// A packet where the model only read a species — everything else absent.
|
||||||
|
const p = seedPacketSchema.parse({ species: 'basil' })
|
||||||
|
expect(p.species).toBe('basil')
|
||||||
|
expect(p.variety).toBe('')
|
||||||
|
expect(p.spacingCm).toBeNull()
|
||||||
|
expect(p.seedCount).toBeNull()
|
||||||
|
expect(p.packedForYear).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('packetProposalSchema', () => {
|
||||||
|
it('parses a full proposal including candidates', () => {
|
||||||
|
const p = packetProposalSchema.parse(proposal)
|
||||||
|
expect(p.candidates).toHaveLength(1)
|
||||||
|
expect(p.candidates[0].plant.id).toBe(7)
|
||||||
|
expect(p.candidates[0].reason).toBe('exact name')
|
||||||
|
expect(p.suggestedName).toBe('Music')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults candidates to empty when absent', () => {
|
||||||
|
const p = packetProposalSchema.parse({ packet: { species: 'kale' } })
|
||||||
|
expect(p.candidates).toEqual([])
|
||||||
|
expect(p.suggestedName).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('newPlantDefaults', () => {
|
||||||
|
it('prefills name/category/spacing/days/vendor from the proposal', () => {
|
||||||
|
const np = newPlantDefaults(packetProposalSchema.parse(proposal))
|
||||||
|
expect(np.name).toBe('Music')
|
||||||
|
expect(np.category).toBe('vegetable')
|
||||||
|
expect(np.spacingCm).toBe(15)
|
||||||
|
expect(np.daysToMaturity).toBe(90)
|
||||||
|
expect(np.vendor).toBe("Johnny's")
|
||||||
|
// No icon/color on a packet — placeholders the user can change later.
|
||||||
|
expect(np.icon).toBe(PACKET_PLANT_ICON)
|
||||||
|
expect(np.color).toBe(PACKET_PLANT_COLOR)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a safe category and default spacing when the packet lacks them', () => {
|
||||||
|
const np = newPlantDefaults(
|
||||||
|
packetProposalSchema.parse({
|
||||||
|
packet: { species: 'mystery' },
|
||||||
|
suggestedName: 'mystery',
|
||||||
|
suggestedCategory: 'not-a-real-category',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// An unknown category must not leak through — CreatePlant would reject it.
|
||||||
|
expect(np.category).toBe('vegetable')
|
||||||
|
// No printed spacing → the same default the manual form uses.
|
||||||
|
expect(np.spacingCm).toBe(30)
|
||||||
|
expect(np.daysToMaturity).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lotDefaults', () => {
|
||||||
|
it('reads a seed count as "<n> seeds"', () => {
|
||||||
|
const lot = lotDefaults(seedPacketSchema.parse(proposal.packet))
|
||||||
|
expect(lot.quantity).toBe(12)
|
||||||
|
expect(lot.unit).toBe('seeds')
|
||||||
|
expect(lot.vendor).toBe("Johnny's")
|
||||||
|
expect(lot.sku).toBe('G-123')
|
||||||
|
expect(lot.lotCode).toBe('L9')
|
||||||
|
expect(lot.packedForYear).toBe(2026)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to one packet when no seed count is printed', () => {
|
||||||
|
const lot = lotDefaults(seedPacketSchema.parse({ species: 'basil' }))
|
||||||
|
expect(lot.quantity).toBe(1)
|
||||||
|
expect(lot.unit).toBe('packets')
|
||||||
|
expect(lot.packedForYear).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Seed-packet capture client (#81/#102). Two steps, deliberately separate — the
|
||||||
|
// same shape the backend enforces:
|
||||||
|
// POST /seed-lots/scan a packet photo → a PacketProposal (reads only)
|
||||||
|
// POST /seed-lots/from-packet a confirmed proposal → a plant + a seed lot
|
||||||
|
// The scan never writes; a misread can't add anything to the catalog on its own.
|
||||||
|
// Creation happens only from an explicit confirm, with exactly one of an existing
|
||||||
|
// plant (plantId) or a new variety (newPlant).
|
||||||
|
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { api } from './api'
|
||||||
|
import { PLANT_CATEGORIES, plantSchema, plantsKey, type PlantCategory, type PlantInput } from './plants'
|
||||||
|
import { seedLotSchema, seedLotsKey, type LotUnit, type SeedLotInput } from './seedLots'
|
||||||
|
|
||||||
|
// SeedPacket mirrors internal/vision.SeedPacket: the fields read off a packet.
|
||||||
|
// Every field can be empty or null because the model fills only what's actually
|
||||||
|
// printed — so each has a default and nothing here is required.
|
||||||
|
export const seedPacketSchema = z.object({
|
||||||
|
species: z.string().default(''),
|
||||||
|
variety: z.string().default(''),
|
||||||
|
category: z.string().default(''),
|
||||||
|
vendor: z.string().default(''),
|
||||||
|
sku: z.string().default(''),
|
||||||
|
lotCode: z.string().default(''),
|
||||||
|
packedForYear: z.number().nullable().default(null),
|
||||||
|
daysToMaturity: z.number().nullable().default(null),
|
||||||
|
spacingCm: z.number().nullable().default(null),
|
||||||
|
seedCount: z.number().nullable().default(null),
|
||||||
|
})
|
||||||
|
export type SeedPacket = z.infer<typeof seedPacketSchema>
|
||||||
|
|
||||||
|
// A candidate existing plant the packet might already be, with why it matched so
|
||||||
|
// the UI can show the reason next to it.
|
||||||
|
export const packetMatchSchema = z.object({ plant: plantSchema, reason: z.string() })
|
||||||
|
export type PacketMatch = z.infer<typeof packetMatchSchema>
|
||||||
|
|
||||||
|
// What a scan returns: the read fields, the candidate existing plants (best
|
||||||
|
// first; empty means "probably new"), and prefill hints for a new variety.
|
||||||
|
export const packetProposalSchema = z.object({
|
||||||
|
packet: seedPacketSchema,
|
||||||
|
candidates: z.array(packetMatchSchema).default([]),
|
||||||
|
suggestedName: z.string().default(''),
|
||||||
|
suggestedCategory: z.string().default(''),
|
||||||
|
})
|
||||||
|
export type PacketProposal = z.infer<typeof packetProposalSchema>
|
||||||
|
|
||||||
|
// What a confirm produced: the plant (new or matched) and the created lot.
|
||||||
|
export const packetResultSchema = z.object({
|
||||||
|
plant: plantSchema,
|
||||||
|
lot: seedLotSchema,
|
||||||
|
plantIsNew: z.boolean(),
|
||||||
|
})
|
||||||
|
export type PacketResult = z.infer<typeof packetResultSchema>
|
||||||
|
|
||||||
|
// The lot half of a confirm carries no plantId — the plant comes from the
|
||||||
|
// plantId/newPlant choice, and the server attributes the lot to it.
|
||||||
|
export type SeedLotFields = Omit<SeedLotInput, 'plantId'>
|
||||||
|
|
||||||
|
// A confirmed proposal: exactly one of plantId (attach to an existing plant) or
|
||||||
|
// newPlant (create a variety), plus the lot to record.
|
||||||
|
export interface FromPacketBody {
|
||||||
|
plantId?: number
|
||||||
|
newPlant?: PlantInput
|
||||||
|
lot: SeedLotFields
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a packet photo into a proposal. Multipart upload; the vision call can
|
||||||
|
* take several seconds (the server extends its deadline to 120s), so a `signal`
|
||||||
|
* can be threaded through to abort a slow/hung scan — the caller wires it to a
|
||||||
|
* Cancel button so the dialog is never a trap. Not cached — every photo is a
|
||||||
|
* fresh one-shot. */
|
||||||
|
export function useScanPacket() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ file, signal }: { file: File; signal?: AbortSignal }): Promise<PacketProposal> => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a proposal into a plant + lot. Invalidates both catalogs the new rows
|
||||||
|
* show up in. */
|
||||||
|
export function useCreateFromPacket() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: FromPacketBody): Promise<PacketResult> =>
|
||||||
|
packetResultSchema.parse(await api.post('/seed-lots/from-packet', body)),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: plantsKey })
|
||||||
|
void qc.invalidateQueries({ queryKey: seedLotsKey })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A packet has no icon or color, so a new variety created from one gets these
|
||||||
|
// placeholders; the user can set a real icon/color later from the plant card.
|
||||||
|
export const PACKET_PLANT_COLOR = '#4a7c3f'
|
||||||
|
export const PACKET_PLANT_ICON = '🌱'
|
||||||
|
// A packet without a printed spacing falls back to this (cm) — the same default
|
||||||
|
// the manual new-plant form uses.
|
||||||
|
const DEFAULT_SPACING_CM = 30
|
||||||
|
|
||||||
|
function isPlantCategory(c: string): c is PlantCategory {
|
||||||
|
return (PLANT_CATEGORIES as readonly string[]).includes(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill a new-variety form from a proposal. Name and category come from the
|
||||||
|
* proposal's suggestions (already validated server-side against the known
|
||||||
|
* categories, but re-checked here); spacing/days/vendor come off the packet.
|
||||||
|
* Color and icon aren't on a packet, so they take the placeholders above.
|
||||||
|
*/
|
||||||
|
export function newPlantDefaults(p: PacketProposal): PlantInput {
|
||||||
|
return {
|
||||||
|
name: p.suggestedName,
|
||||||
|
category: isPlantCategory(p.suggestedCategory) ? p.suggestedCategory : 'vegetable',
|
||||||
|
spacingCm: p.packet.spacingCm ?? DEFAULT_SPACING_CM,
|
||||||
|
color: PACKET_PLANT_COLOR,
|
||||||
|
icon: PACKET_PLANT_ICON,
|
||||||
|
daysToMaturity: p.packet.daysToMaturity,
|
||||||
|
sourceUrl: '',
|
||||||
|
vendor: p.packet.vendor,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefill the lot fields from the packet. A printed seed count reads naturally as
|
||||||
|
* "<n> seeds"; without one, default to a single packet — the thing you physically
|
||||||
|
* bought — which the user can correct.
|
||||||
|
*/
|
||||||
|
export function lotDefaults(p: SeedPacket): SeedLotFields {
|
||||||
|
const hasCount = p.seedCount != null && p.seedCount > 0
|
||||||
|
const unit: LotUnit = hasCount ? 'seeds' : 'packets'
|
||||||
|
return {
|
||||||
|
vendor: p.vendor,
|
||||||
|
sourceUrl: '',
|
||||||
|
sku: p.sku,
|
||||||
|
lotCode: p.lotCode,
|
||||||
|
purchasedAt: null,
|
||||||
|
packedForYear: p.packedForYear,
|
||||||
|
quantity: hasCount ? (p.seedCount as number) : 1,
|
||||||
|
unit,
|
||||||
|
costCents: null,
|
||||||
|
germinationPct: null,
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
|||||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||||
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||||
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||||
|
import { ScanPacketModal } from '@/components/plants/ScanPacketModal'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
|
import { useCapabilities } from '@/lib/agent'
|
||||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import type { UnitPref } from '@/lib/units'
|
import type { UnitPref } from '@/lib/units'
|
||||||
@@ -22,6 +24,7 @@ type Dialog =
|
|||||||
| { kind: 'duplicate'; plant: Plant }
|
| { kind: 'duplicate'; plant: Plant }
|
||||||
| { kind: 'delete'; plant: Plant }
|
| { kind: 'delete'; plant: Plant }
|
||||||
| { kind: 'picker' }
|
| { kind: 'picker' }
|
||||||
|
| { kind: 'scan' }
|
||||||
| { kind: 'addLot'; plant: Plant }
|
| { kind: 'addLot'; plant: Plant }
|
||||||
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||||
| { kind: 'deleteLot'; lot: SeedLot }
|
| { kind: 'deleteLot'; lot: SeedLot }
|
||||||
@@ -41,6 +44,7 @@ function loadUnit(): UnitPref {
|
|||||||
export function PlantsPage() {
|
export function PlantsPage() {
|
||||||
usePageTitle('Plants')
|
usePageTitle('Plants')
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
|
const capabilities = useCapabilities()
|
||||||
const seedLots = useSeedLots()
|
const seedLots = useSeedLots()
|
||||||
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||||
@@ -72,6 +76,13 @@ export function PlantsPage() {
|
|||||||
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
|
<Button variant="ghost" onClick={() => setDialog({ kind: 'picker' })}>
|
||||||
Try the picker
|
Try the picker
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Only where a vision model is configured — otherwise the scan would
|
||||||
|
404 on the vision call, so we don't offer it. */}
|
||||||
|
{capabilities.data?.vision && (
|
||||||
|
<Button variant="ghost" onClick={() => setDialog({ kind: 'scan' })}>
|
||||||
|
Scan a packet
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={toggleUnit}
|
onClick={toggleUnit}
|
||||||
@@ -136,6 +147,7 @@ export function PlantsPage() {
|
|||||||
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||||
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||||
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||||
|
{dialog?.kind === 'scan' && <ScanPacketModal unit={unit} onClose={close} />}
|
||||||
{dialog?.kind === 'picker' && (
|
{dialog?.kind === 'picker' && (
|
||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
|||||||
Reference in New Issue
Block a user