// 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 // 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 // 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 // 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 // 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 // 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 => { 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 => 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 * " 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: '', } }