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,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: '',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user