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]>
150 lines
5.9 KiB
TypeScript
150 lines
5.9 KiB
TypeScript
// 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: '',
|
|
}
|
|
}
|