Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
249 lines
8.5 KiB
TypeScript
249 lines
8.5 KiB
TypeScript
import { useState, 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 { TextArea } from '@/components/ui/TextArea'
|
|
import { TextField } from '@/components/ui/TextField'
|
|
import { errorMessage } from '@/lib/api'
|
|
import {
|
|
conflictSeedLot,
|
|
LOT_UNITS,
|
|
safeExternalUrl,
|
|
useCreateSeedLot,
|
|
useUpdateSeedLot,
|
|
type LotUnit,
|
|
type SeedLot,
|
|
} from '@/lib/seedLots'
|
|
import type { Plant } from '@/lib/plants'
|
|
|
|
/**
|
|
* Record a purchase, or correct one. Everything except quantity and unit is
|
|
* optional — the point is to make writing it down cheap enough to bother with,
|
|
* and a form that demands a SKU and a lot code gets skipped.
|
|
*/
|
|
export function SeedLotModal({
|
|
plant,
|
|
lot,
|
|
onClose,
|
|
}: {
|
|
plant: Plant
|
|
/** Editing an existing lot, or undefined to record a new one. */
|
|
lot?: SeedLot
|
|
onClose: () => void
|
|
}) {
|
|
const isEdit = !!lot
|
|
const create = useCreateSeedLot()
|
|
const update = useUpdateSeedLot()
|
|
const pending = create.isPending || update.isPending
|
|
|
|
// A new lot inherits the plant's vendor and source link, since the usual case
|
|
// is buying the variety you already recorded from the place you recorded it.
|
|
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
|
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
|
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
|
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
|
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
|
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
|
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
|
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
|
const [sku, setSku] = useState(lot?.sku ?? '')
|
|
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
|
const [notes, setNotes] = useState(lot?.notes ?? '')
|
|
const [version, setVersion] = useState(lot?.version ?? 0)
|
|
const [conflict, setConflict] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function onSubmit(e: FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setConflict(null)
|
|
|
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
|
if (!Number.isFinite(qty) || qty < 0) {
|
|
setError('Quantity must be a number, or left blank.')
|
|
return
|
|
}
|
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
|
setError('The source link needs to be a full http:// or https:// address.')
|
|
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)
|
|
}
|
|
let germinationPct: number | null = null
|
|
if (germination.trim()) {
|
|
const g = Number(germination)
|
|
if (!Number.isFinite(g) || g < 0 || g > 100) {
|
|
setError('Germination is a percentage between 0 and 100.')
|
|
return
|
|
}
|
|
germinationPct = g
|
|
}
|
|
|
|
const input = {
|
|
plantId: plant.id,
|
|
vendor: vendor.trim(),
|
|
sourceUrl: sourceUrl.trim(),
|
|
sku: sku.trim(),
|
|
lotCode: lotCode.trim(),
|
|
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
|
packedForYear: year,
|
|
quantity: qty,
|
|
unit,
|
|
costCents,
|
|
germinationPct,
|
|
notes: notes.trim(),
|
|
}
|
|
|
|
try {
|
|
if (isEdit) {
|
|
await update.mutateAsync({ id: lot.id, version, ...input })
|
|
} else {
|
|
await create.mutateAsync(input)
|
|
}
|
|
onClose()
|
|
} catch (err) {
|
|
const current = conflictSeedLot(err)
|
|
if (current) {
|
|
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
|
|
// re-save applies, rather than making them retype everything — the same
|
|
// contract every other version-guarded form here honours.
|
|
setVersion(current.version)
|
|
setVendor(current.vendor)
|
|
setSourceUrl(current.sourceUrl)
|
|
setQuantity(String(current.quantity))
|
|
setUnit(current.unit)
|
|
setPurchasedAt(current.purchasedAt ?? '')
|
|
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
|
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
|
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
|
setSku(current.sku)
|
|
setLotCode(current.lotCode)
|
|
setNotes(current.notes)
|
|
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
|
|
return
|
|
}
|
|
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
|
|
|
<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={unit}
|
|
onChange={(e) => setUnit(e.target.value as LotUnit)}
|
|
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
|
<TextField
|
|
label="Source link"
|
|
name="sourceUrl"
|
|
type="url"
|
|
inputMode="url"
|
|
placeholder="https://…"
|
|
value={sourceUrl}
|
|
onChange={(e) => setSourceUrl(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<TextField
|
|
label="Purchased"
|
|
name="purchasedAt"
|
|
type="date"
|
|
value={purchasedAt}
|
|
onChange={(e) => setPurchasedAt(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="Cost"
|
|
name="cost"
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="4.99"
|
|
value={cost}
|
|
onChange={(e) => setCost(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label="Germination %"
|
|
name="germination"
|
|
type="number"
|
|
inputMode="decimal"
|
|
step="any"
|
|
min="0"
|
|
max="100"
|
|
value={germination}
|
|
onChange={(e) => setGermination(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="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
|
</div>
|
|
|
|
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
|
|
{error && <Alert>{error}</Alert>}
|
|
|
|
<div className="mt-1 flex justify-end gap-2">
|
|
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={pending}>
|
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|