Seed-packet scan UI: camera/upload → proposal → confirm (#102) #119
@@ -43,6 +43,9 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
|
||||
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)
|
||||
@@ -73,28 +76,40 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
setError(null)
|
||||
scan.mutate(file, {
|
||||
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) : '')
|
||||
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
|
||||
|
gitea-actions
commented
🔴 Cost field persists across rescans because setCost('') is missing in onFile success handler correctness · flagged by 1 model
🪰 Gadfly · advisory 🔴 **Cost field persists across rescans because setCost('') is missing in onFile success handler**
_correctness · flagged by 1 model_
* **`web/src/components/plants/ScanPacketModal.tsx:88-95`** — The `cost` form state is never reset when a new packet is scanned. `onFile` sets `name`, `category`, `spacing`, `days`, `vendor`, `quantity`, `lotUnit`, `sku`, `lotCode`, and `packedForYear` from the new proposal, but `setCost('')` is missing. Consequently, if a user enters a cost for one packet, clicks "Rescan", and scans a different packet, the old cost value silently persists and will be submitted with the new lot. **Fix:** add `se…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// 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) => {
|
||||
|
gitea-actions
commented
🟠 Lot validation (qty/year/cost) hand-copied from SeedLotModal with no shared helper; comment claims mirroring but nothing enforces it maintainability · flagged by 4 models
🪰 Gadfly · advisory 🟠 **Lot validation (qty/year/cost) hand-copied from SeedLotModal with no shared helper; comment claims mirroring but nothing enforces it**
_maintainability · flagged by 4 models_
- **`web/src/components/plants/ScanPacketModal.tsx:105-128` — duplicated lot validation.** The `onConfirm` qty / packed-for-year / cost validation blocks are character-for-character copies of `SeedLotModal.tsx:62-88`, and the comment at line 105 explicitly says *"Lot validation mirrors SeedLotModal so the two paths accept the same things."* Verified by reading both files: the `Quantity must be a number, or left blank.` string, `costCents = Math.round(c * 100)`, and the `1900`/`2200` year range a…
<sub>🪰 Gadfly · advisory</sub>
|
||||
// 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."))
|
||||
},
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet.")),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function onConfirm(e: FormEvent) {
|
||||
@@ -224,7 +239,16 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{/* 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>
|
||||
@@ -360,7 +384,17 @@ export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: ()
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="mt-1 flex justify-between gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setProposal(null)} disabled={busy}>
|
||||
<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}>
|
||||
|
||||
@@ -65,14 +65,16 @@ export interface FromPacketBody {
|
||||
}
|
||||
|
||||
/** Scan a packet photo into a proposal. Multipart upload; the vision call can
|
||||
* take several seconds, so callers surface `isPending` as a scanning state. Not
|
||||
* cached — every photo is a fresh one-shot. */
|
||||
* 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: File): Promise<PacketProposal> => {
|
||||
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))
|
||||
return packetProposalSchema.parse(await api.postForm('/seed-lots/scan', form, { signal }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
⚪ 14 useState slots in one ~330-line component; a reducer/single review-state object would reduce the setter wall
maintainability · flagged by 1 model
🪰 Gadfly · advisory