Seed shelf UI: source links, lots, and what's left (#51) #68
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||
* data lives — and worth saying plainly that the plantings survive it, since
|
||||
* "will this wipe my garden" is the reasonable fear.
|
||||
*/
|
||||
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||
const del = useDeleteSeedLot()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-fg">
|
||||
{formatQuantity(lot.quantity)} {lot.unit}
|
||||
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||
</p>
|
||||
<p className="text-sm text-muted">
|
||||
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||
</p>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="mt-1 flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(lot.id, {
|
||||
onSuccess: onClose,
|
||||
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
|
||||
})
|
||||
}
|
||||
>
|
||||
{del.isPending ? 'Retiring…' : 'Retire lot'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { PlantIcon } from './PlantIcon'
|
||||
import { LotStateChip, SeedLotList } from './SeedLotList'
|
||||
import { SourceLink } from './SourceLink'
|
||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
/**
|
||||
@@ -11,17 +15,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
export function PlantCard({
|
||||
plant,
|
||||
unit,
|
||||
lots,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onAddLot,
|
||||
onEditLot,
|
||||
onDeleteLot,
|
||||
}: {
|
||||
plant: Plant
|
||||
unit: UnitPref
|
||||
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
||||
* card can carry seed. */
|
||||
lots: SeedLot[]
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onDuplicate: () => void
|
||||
onAddLot: () => void
|
||||
onEditLot: (lot: SeedLot) => void
|
||||
onDeleteLot: (lot: SeedLot) => void
|
||||
}) {
|
||||
const builtin = isBuiltin(plant)
|
||||
const [showLots, setShowLots] = useState(false)
|
||||
const summary = summarizeLots(lots)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
@@ -38,6 +55,12 @@ export function PlantCard({
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||
</p>
|
||||
{(plant.vendor || plant.sourceUrl) && (
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||
{plant.vendor && <span>{plant.vendor}</span>}
|
||||
<SourceLink url={plant.sourceUrl} />
|
||||
</p>
|
||||
)}
|
||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||
</div>
|
||||
<span
|
||||
@@ -46,7 +69,34 @@ export function PlantCard({
|
||||
title={plant.color}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{showLots && (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||
{/* The seed count sits with the actions rather than in the body: it's
|
||||
what you scan for down a list of twenty packets, so it wants a fixed
|
||||
place on the card. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLots((v) => !v)}
|
||||
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
|
||||
aria-expanded={showLots}
|
||||
>
|
||||
{lots.length === 0 ? (
|
||||
<span className="text-muted">No seed</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="tabular-nums">
|
||||
{formatQuantity(summary.remaining)}
|
||||
{summary.unit ? ` ${summary.unit}` : ''} left
|
||||
</span>
|
||||
<LotStateChip state={summary.state} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type PlantCategory,
|
||||
type PlantInput,
|
||||
} from '@/lib/plants'
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||
|
||||
const DEFAULT_COLOR = '#4a7c3f'
|
||||
@@ -61,6 +62,8 @@ export function PlantFormModal({
|
||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
@@ -96,6 +99,14 @@ export function PlantFormModal({
|
||||
daysToMaturity = d
|
||||
}
|
||||
|
||||
// The server refuses anything that isn't http(s) with a host, but say so here
|
||||
// rather than letting a paste of "johnnyseeds.com" come back as a generic
|
||||
// error with no hint about which field or why.
|
||||
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||
return
|
||||
}
|
||||
|
||||
const input: PlantInput = {
|
||||
name: name.trim(),
|
||||
category,
|
||||
@@ -103,6 +114,8 @@ export function PlantFormModal({
|
||||
color,
|
||||
icon: icon.trim(),
|
||||
daysToMaturity,
|
||||
sourceUrl: sourceUrl.trim(),
|
||||
vendor: vendor.trim(),
|
||||
notes: notes.trim(),
|
||||
}
|
||||
|
||||
@@ -194,6 +207,27 @@ export function PlantFormModal({
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Provenance for the variety itself. What you bought and what's left
|
||||
of it is a seed lot, added from the plant card. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="Vendor"
|
||||
name="vendor"
|
||||
placeholder="Johnny's Selected Seeds"
|
||||
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>
|
||||
|
||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { SourceLink } from './SourceLink'
|
||||
import {
|
||||
formatCost,
|
||||
formatQuantity,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
type LotState,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
|
||||
const STATE_LABEL: Record<LotState, string> = {
|
||||
over: 'over-planted',
|
||||
empty: 'empty',
|
||||
low: 'low',
|
||||
ok: 'in stock',
|
||||
unknown: 'no count',
|
||||
}
|
||||
|
||||
// Encoded in colour AND words, because this is the thing you skim down a list of
|
||||
// twenty packets deciding what to order — a bare number doesn't survive that.
|
||||
const STATE_CLASS: Record<LotState, string> = {
|
||||
// "over" is a discrepancy to look at rather than a shortage to act on, so it
|
||||
// reads as a distinct warning rather than sharing "low"'s styling.
|
||||
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
|
||||
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
||||
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||
ok: 'bg-accent/20 text-accent-strong',
|
||||
unknown: 'bg-border/60 text-muted',
|
||||
}
|
||||
|
||||
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||
STATE_CLASS[state],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{STATE_LABEL[state]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** A proportional bar for how much of a lot is left, so the state reads before
|
||||
* any of the text does. Omitted when there's no quantity to be a fraction of. */
|
||||
function RemainingBar({ lot }: { lot: SeedLot }) {
|
||||
if (lot.quantity <= 0) return null
|
||||
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
|
||||
const state = lotState(lot)
|
||||
return (
|
||||
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A plant's purchases: what came from where, and what's left of each.
|
||||
*
|
||||
* Two lots of the same variety are two separate rows with independent counts,
|
||||
* which is the whole reason inventory lives on the purchase rather than on the
|
||||
* plant (#50).
|
||||
*/
|
||||
export function SeedLotList({
|
||||
lots,
|
||||
canEdit,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
canEdit: boolean
|
||||
onAdd: () => void
|
||||
onEdit: (lot: SeedLot) => void
|
||||
onDelete: (lot: SeedLot) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{lots.length === 0 && (
|
||||
<p className="text-xs text-muted">
|
||||
No seed recorded. Add a lot to track what you bought and how much is left.
|
||||
</p>
|
||||
)}
|
||||
{lots.map((lot) => (
|
||||
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
|
||||
))}
|
||||
{canEdit && (
|
||||
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
|
||||
+ Add seed lot
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LotRow({
|
||||
lot,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
lot: SeedLot
|
||||
canEdit: boolean
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const cost = formatCost(lot.costCents)
|
||||
const unitCost = formatUnitCost(lot)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border px-2 py-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
|
||||
{lot.vendor && <span>{lot.vendor}</span>}
|
||||
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
|
||||
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
||||
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
||||
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
||||
<SourceLink url={lot.sourceUrl} />
|
||||
</p>
|
||||
<RemainingBar lot={lot} />
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex shrink-0 flex-col items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||
>
|
||||
Retire
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { safeExternalUrl } from '@/lib/seedLots'
|
||||
|
||||
/**
|
||||
* A link out to where seed came from.
|
||||
*
|
||||
* The href is a URL somebody pasted, so it is re-checked here before rendering
|
||||
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
|
||||
* but a link is rendered from whatever the client was handed, and "the backend
|
||||
* validated it" is not a reason to hand javascript: to an anchor tag.
|
||||
*
|
||||
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
|
||||
* have to remember to guard.
|
||||
*/
|
||||
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
|
||||
const safe = safeExternalUrl(url)
|
||||
if (!safe) return null
|
||||
return (
|
||||
<a
|
||||
href={safe}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||
>
|
||||
{label} ↗
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export function GardenCanvas({
|
||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const liveObject = useEditorStore((s) => s.liveObject)
|
||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||
const { fitToRect } = useViewport(svgRef)
|
||||
@@ -200,7 +201,14 @@ export function GardenCanvas({
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// the object, so a selection would be hidden until placement ends anyway).
|
||||
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
|
||||
createPlanting.mutate({
|
||||
objectId: focusedObject.id,
|
||||
plantId: armedPlant.id,
|
||||
xCm: x,
|
||||
yCm: y,
|
||||
radiusCm,
|
||||
seedLotId: armedLotId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||
|
||||
@@ -4,6 +4,16 @@ import { fieldControlClass } from '@/components/ui/field'
|
||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import {
|
||||
attributableLots,
|
||||
formatQuantity,
|
||||
lotsByPlant,
|
||||
lotState,
|
||||
summarizeLots,
|
||||
useSeedLots,
|
||||
type SeedLot,
|
||||
} from '@/lib/seedLots'
|
||||
import { LotStateChip } from '@/components/plants/SeedLotList'
|
||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||
|
||||
const RECENT_KEY = 'pansy:recent-plants'
|
||||
@@ -41,11 +51,20 @@ export function PlantPicker({
|
||||
onClose,
|
||||
unit = 'metric',
|
||||
}: {
|
||||
onSelect: (plant: Plant) => void
|
||||
// The chosen plant, and the lot it should be attributed to when that isn't
|
||||
// ambiguous. Undefined lot means "don't attribute" — which is the honest
|
||||
// answer when there are no lots, and the deliberate one when the user skips.
|
||||
onSelect: (plant: Plant, lot?: SeedLot) => void
|
||||
onClose: () => void
|
||||
unit?: UnitPref
|
||||
}) {
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
// Set when a plant with SEVERAL lots is picked: which packet did this come out
|
||||
// of? One lot auto-attributes and zero lots stays silent, because forcing that
|
||||
// question on every placement is how a nicety becomes an obstacle.
|
||||
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||
@@ -74,8 +93,21 @@ export function PlantPicker({
|
||||
}, [all, recent, query])
|
||||
|
||||
function choose(p: Plant) {
|
||||
const lots = attributableLots(lotsFor.get(p.id) ?? [])
|
||||
if (lots.length > 1) {
|
||||
setChoosingLotFor(p)
|
||||
return
|
||||
}
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p)
|
||||
// A single lot attributes even when its count says empty. The count records
|
||||
// what was written down, not a fact about the packet — dropping attribution
|
||||
// there would make a wrong number harder to correct rather than easier.
|
||||
onSelect(p, lots[0])
|
||||
}
|
||||
|
||||
function chooseLot(p: Plant, lot?: SeedLot) {
|
||||
setRecent(recordRecent(p.id))
|
||||
onSelect(p, lot)
|
||||
}
|
||||
|
||||
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||
@@ -92,11 +124,22 @@ export function PlantPicker({
|
||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
// What's left, shown where you're deciding what to plant. Silent when there
|
||||
// are no lots — most plants won't have any, and "0 left" on all of them would
|
||||
// be noise that trains you to ignore the number. summarizeLots owns the
|
||||
// unit-agreement rule; a second copy of it here would drift.
|
||||
function remainingLabel(lots: SeedLot[]): string {
|
||||
if (lots.length === 0) return ''
|
||||
const { remaining, unit } = summarizeLots(lots)
|
||||
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||
@@ -132,7 +175,41 @@ export function PlantPicker({
|
||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{choosingLotFor && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Which lot of {choosingLotFor.name}?
|
||||
</p>
|
||||
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, lot)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
|
||||
</span>
|
||||
</span>
|
||||
<LotStateChip state={lotState(lot)} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||
>
|
||||
Don't attribute to a lot
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
|
||||
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
||||
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ interface EditorState {
|
||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||
armedPlant: Plant | null
|
||||
setArmedPlant: (p: Plant | null) => void
|
||||
// Which seed lot placements should be attributed to, when the armed plant has
|
||||
// one worth naming. Cleared with the plant.
|
||||
armedLotId: number | null
|
||||
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
||||
|
||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||
@@ -98,7 +101,8 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||
|
||||
armedPlant: null,
|
||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
||||
armedLotId: null,
|
||||
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||
|
||||
liveObject: null,
|
||||
setLiveObject: (o) => set({ liveObject: o }),
|
||||
@@ -118,6 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
selectedPlantingId: null,
|
||||
focusedObjectId: null,
|
||||
armedPlant: null,
|
||||
armedLotId: null,
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
|
||||
@@ -258,6 +258,8 @@ export interface PlantingCreate {
|
||||
radiusCm: number
|
||||
count?: number | null
|
||||
label?: string | null
|
||||
/** Attributes the plop to a purchase, so that lot can report what's left. */
|
||||
seedLotId?: number
|
||||
}
|
||||
|
||||
export function useCreatePlanting(gardenId: number) {
|
||||
|
||||
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
|
||||
color: z.string(),
|
||||
icon: z.string(),
|
||||
daysToMaturity: z.number().nullable().optional(),
|
||||
// Provenance for the VARIETY — the page you'd go back to to buy it again.
|
||||
// What you actually bought, and how much is left, lives in seedLots.ts.
|
||||
sourceUrl: z.string().default(''),
|
||||
vendor: z.string().default(''),
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
@@ -75,6 +79,8 @@ export interface PlantInput {
|
||||
color: string
|
||||
icon: string
|
||||
daysToMaturity: number | null
|
||||
sourceUrl: string
|
||||
vendor: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
attributableLots,
|
||||
formatCost,
|
||||
formatQuantity,
|
||||
formatUnitCost,
|
||||
lotState,
|
||||
lotsByPlant,
|
||||
safeExternalUrl,
|
||||
summarizeLots,
|
||||
type SeedLot,
|
||||
} from './seedLots'
|
||||
|
||||
function lot(over: Partial<SeedLot> = {}): SeedLot {
|
||||
return {
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
plantId: 1,
|
||||
vendor: '',
|
||||
sourceUrl: '',
|
||||
sku: '',
|
||||
lotCode: '',
|
||||
quantity: 100,
|
||||
unit: 'seeds',
|
||||
notes: '',
|
||||
version: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
used: 0,
|
||||
remaining: 100,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('safeExternalUrl', () => {
|
||||
// The server already scheme-checks this, but a link is rendered from whatever
|
||||
// the client was handed. "The backend validated it" is not a reason to hand
|
||||
// javascript: to an anchor tag.
|
||||
it('accepts http and https', () => {
|
||||
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
|
||||
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
|
||||
})
|
||||
|
||||
it('refuses everything else', () => {
|
||||
for (const bad of [
|
||||
'',
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'file:///etc/passwd',
|
||||
'//evil.example.com',
|
||||
'/seeds/garlic',
|
||||
'not a url',
|
||||
]) {
|
||||
expect(safeExternalUrl(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotState', () => {
|
||||
it('reads the state, not just the number', () => {
|
||||
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
|
||||
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
|
||||
// Planting more than you recorded buying is real, and worth showing plainly
|
||||
// rather than clamping to zero.
|
||||
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
|
||||
})
|
||||
|
||||
it('has nothing to say about a lot with no recorded quantity', () => {
|
||||
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeLots', () => {
|
||||
it('totals remaining and reports the worst state', () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, quantity: 100, remaining: 80 }),
|
||||
lot({ id: 2, quantity: 50, remaining: 5 }),
|
||||
])
|
||||
expect(s.remaining).toBe(85)
|
||||
expect(s.unit).toBe('seeds')
|
||||
expect(s.state).toBe('low') // the one that needs attention wins
|
||||
})
|
||||
|
||||
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
|
||||
const s = summarizeLots([
|
||||
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
|
||||
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
|
||||
])
|
||||
expect(s.unit).toBeNull()
|
||||
})
|
||||
|
||||
it('says nothing for a plant with no lots', () => {
|
||||
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cost formatting', () => {
|
||||
it('renders whole currency from cents', () => {
|
||||
expect(formatCost(499)).toBe('$4.99')
|
||||
expect(formatCost(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('gives per-unit cost extra precision when it would round to nothing', () => {
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
|
||||
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
|
||||
})
|
||||
|
||||
it('has no per-unit cost without both halves', () => {
|
||||
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
|
||||
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lotsByPlant', () => {
|
||||
it('groups, and copes with nothing', () => {
|
||||
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
|
||||
expect(grouped.get(7)?.length).toBe(2)
|
||||
expect(grouped.get(9)?.length).toBe(1)
|
||||
expect(lotsByPlant(undefined).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributableLots', () => {
|
||||
// An exhausted lot is still offered: the count records what was written down,
|
||||
// not a fact about the packet, so refusing to attribute a planting because the
|
||||
// number says zero would make a wrong number harder to correct, not easier.
|
||||
it('offers exhausted lots, just not first', () => {
|
||||
const empty = lot({ id: 1, remaining: 0 })
|
||||
const full = lot({ id: 2, remaining: 100 })
|
||||
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
|
||||
expect(attributableLots([empty]).length).toBe(1)
|
||||
})
|
||||
|
||||
it("doesn't mutate its input", () => {
|
||||
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
|
||||
attributableLots(lots)
|
||||
expect(lots.map((l) => l.id)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatQuantity', () => {
|
||||
it('keeps whole counts whole and fractional ones readable', () => {
|
||||
expect(formatQuantity(100)).toBe('100')
|
||||
expect(formatQuantity(12.5)).toBe('12.5')
|
||||
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
// Seed lots (#51): what you actually bought, and what's left of it.
|
||||
//
|
||||
// A lot is one purchase of one variety. Inventory belongs to the purchase rather
|
||||
// than to the variety because the same garlic from two vendors in two years is
|
||||
// two different things — see #50 for the reasoning behind the shape.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
|
||||
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
|
||||
export type LotUnit = z.infer<typeof lotUnitSchema>
|
||||
|
||||
export const LOT_UNITS: { value: LotUnit; label: string }[] = [
|
||||
{ value: 'seeds', label: 'seeds' },
|
||||
{ value: 'grams', label: 'grams' },
|
||||
{ value: 'ounces', label: 'ounces' },
|
||||
{ value: 'packets', label: 'packets' },
|
||||
{ value: 'bulbs', label: 'bulbs' },
|
||||
{ value: 'plants', label: 'plants' },
|
||||
]
|
||||
|
||||
export const seedLotSchema = z.object({
|
||||
id: z.number(),
|
||||
ownerId: z.number(),
|
||||
plantId: z.number(),
|
||||
vendor: z.string(),
|
||||
sourceUrl: z.string(),
|
||||
sku: z.string(),
|
||||
lotCode: z.string(),
|
||||
purchasedAt: z.string().optional(),
|
||||
packedForYear: z.number().optional(),
|
||||
quantity: z.number(),
|
||||
unit: lotUnitSchema,
|
||||
costCents: z.number().optional(),
|
||||
germinationPct: z.number().optional(),
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
// Computed server-side: used is the summed effective count of active
|
||||
// plantings attributed to this lot; remaining is quantity - used, and may be
|
||||
// NEGATIVE when more was planted than the lot recorded buying.
|
||||
used: z.number(),
|
||||
remaining: z.number(),
|
||||
})
|
||||
export type SeedLot = z.infer<typeof seedLotSchema>
|
||||
|
||||
const seedLotsKey = ['seed-lots'] as const
|
||||
|
||||
export function useSeedLots() {
|
||||
return useQuery({
|
||||
queryKey: seedLotsKey,
|
||||
queryFn: async (): Promise<SeedLot[]> => z.array(seedLotSchema).parse(await api.get('/seed-lots')),
|
||||
})
|
||||
}
|
||||
|
||||
export interface SeedLotInput {
|
||||
plantId: number
|
||||
vendor: string
|
||||
sourceUrl: string
|
||||
sku: string
|
||||
lotCode: string
|
||||
purchasedAt: string | null
|
||||
packedForYear: number | null
|
||||
quantity: number
|
||||
unit: LotUnit
|
||||
costCents: number | null
|
||||
germinationPct: number | null
|
||||
notes: string
|
||||
}
|
||||
|
||||
function invalidate(qc: ReturnType<typeof useQueryClient>) {
|
||||
void qc.invalidateQueries({ queryKey: seedLotsKey })
|
||||
}
|
||||
|
||||
export function useCreateSeedLot() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: SeedLotInput): Promise<SeedLot> =>
|
||||
seedLotSchema.parse(await api.post('/seed-lots', input)),
|
||||
onSuccess: () => invalidate(qc),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSeedLot() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (vars: Partial<SeedLotInput> & { id: number; version: number }): Promise<SeedLot> => {
|
||||
const { id, ...rest } = vars
|
||||
return seedLotSchema.parse(await api.patch(`/seed-lots/${id}`, rest))
|
||||
},
|
||||
onSuccess: () => invalidate(qc),
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSeedLot() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (id: number): Promise<void> => {
|
||||
await api.delete(`/seed-lots/${id}`)
|
||||
},
|
||||
onSuccess: () => invalidate(qc),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The lots a placement could be attributed to, best first.
|
||||
*
|
||||
* Lots with seed left come first, but an exhausted one is still offered: the
|
||||
* count is a record of what you wrote down, not a fact about the packet, and
|
||||
* refusing to attribute a planting because the number says zero would make the
|
||||
* number harder to correct rather than easier.
|
||||
*/
|
||||
export function attributableLots(lots: SeedLot[]): SeedLot[] {
|
||||
return [...lots].sort((a, b) => Number(b.remaining > 0) - Number(a.remaining > 0))
|
||||
}
|
||||
|
||||
/** The current server row carried by a 409, so an edit form can rebase onto it
|
||||
* and the user can re-save rather than losing what they typed. Every other
|
||||
* entity's form does this; a lot is no different. */
|
||||
export function conflictSeedLot(err: unknown): SeedLot | null {
|
||||
if (!(err instanceof ApiError) || !err.isConflict) return null
|
||||
const parsed = seedLotSchema.safeParse((err.body as { current?: unknown })?.current)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
/** Group lots by the plant they belong to. */
|
||||
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
|
||||
const map = new Map<number, SeedLot[]>()
|
||||
for (const lot of lots ?? []) {
|
||||
const list = map.get(lot.plantId)
|
||||
if (list) list.push(lot)
|
||||
else map.set(lot.plantId, [lot])
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* How a lot is doing, as a state rather than a number — this is the thing you
|
||||
* scan for when deciding what to order, and a number alone doesn't survive being
|
||||
* skimmed down a list.
|
||||
*
|
||||
* "over" means more was planted than the lot recorded buying. That's a real
|
||||
* situation (a miscounted packet, a lot entered after the fact) and worth
|
||||
* showing plainly rather than clamping to zero and hiding the discrepancy.
|
||||
*/
|
||||
export type LotState = 'over' | 'empty' | 'low' | 'ok' | 'unknown'
|
||||
|
||||
const LOW_FRACTION = 0.2
|
||||
|
||||
export function lotState(lot: SeedLot): LotState {
|
||||
if (lot.quantity <= 0) return 'unknown' // no quantity recorded: nothing to be low on
|
||||
if (lot.remaining < 0) return 'over'
|
||||
if (lot.remaining === 0) return 'empty'
|
||||
if (lot.remaining / lot.quantity <= LOW_FRACTION) return 'low'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** The total remaining across a plant's lots, and the worst state among them —
|
||||
* what a plant card shows before you open it. */
|
||||
export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUnit | null; state: LotState } {
|
||||
if (lots.length === 0) return { remaining: 0, unit: null, state: 'unknown' }
|
||||
// Mixed units can't be summed honestly; report the majority unit's total only
|
||||
// when they all agree, else leave the unit off and let the per-lot rows speak.
|
||||
const unit = lots.every((l) => l.unit === lots[0].unit) ? lots[0].unit : null
|
||||
const remaining = lots.reduce((sum, l) => sum + l.remaining, 0)
|
||||
const order: LotState[] = ['over', 'empty', 'low', 'ok', 'unknown']
|
||||
const state = order.find((s) => lots.some((l) => lotState(l) === s)) ?? 'unknown'
|
||||
return { remaining, unit, state }
|
||||
}
|
||||
|
||||
/** Quantities are stored as REAL because grams and ounces are fractional, but a
|
||||
* whole number of seeds shouldn't render as "100.0". */
|
||||
export function formatQuantity(n: number): string {
|
||||
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/** Whole currency from cents, e.g. 499 → "$4.99". */
|
||||
export function formatCost(cents: number | undefined): string | null {
|
||||
if (cents == null) return null
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cents / 100)
|
||||
}
|
||||
|
||||
/** Cost per unit, when both are known and it says something useful. */
|
||||
export function formatUnitCost(lot: SeedLot): string | null {
|
||||
if (lot.costCents == null || lot.quantity <= 0) return null
|
||||
const per = lot.costCents / 100 / lot.quantity
|
||||
return `${new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: per < 0.1 ? 3 : 2 }).format(per)}/${singular(lot.unit)}`
|
||||
}
|
||||
|
||||
function singular(unit: LotUnit): string {
|
||||
return unit.endsWith('s') ? unit.slice(0, -1) : unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored URL is safe to render as a link.
|
||||
*
|
||||
* The server already scheme-checks this (#50), but a link is rendered from
|
||||
* whatever the client was handed — an old row, a different server version, a
|
||||
* response someone tampered with — and "the backend validated it" is not a
|
||||
* reason to hand `javascript:` to an anchor tag. Cheap to check twice.
|
||||
*/
|
||||
export function safeExternalUrl(raw: string): string | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { toEditorPlanting } from '@/lib/plantings'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import { useJournalCounts } from '@/lib/journal'
|
||||
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import { useSeedTray } from '@/lib/seedTray'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
@@ -74,6 +75,8 @@ export function GardenEditorPage() {
|
||||
const updateObject = useUpdateObject(gid)
|
||||
const ensurePlant = useEnsurePlantInFull(gid)
|
||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||
const seedLots = useSeedLots()
|
||||
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
|
||||
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
||||
// 'change' swaps the selected plop's plant.
|
||||
@@ -277,9 +280,18 @@ export function GardenEditorPage() {
|
||||
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
||||
// garden's /full payload, so make sure the chosen one is in the cache first —
|
||||
// otherwise its placed plops render without an icon/color until a refetch.
|
||||
function armPlant(plant: Plant) {
|
||||
// The Seed Tray arms a plant directly, without going through the picker, so it
|
||||
// has to do the picker's single-lot auto-attribution itself — otherwise the
|
||||
// quickest way to place a plant is the one path that silently loses which
|
||||
// packet it came from. Several lots is genuinely ambiguous, so that case stays
|
||||
// unattributed rather than guessing; the picker is where you choose.
|
||||
function armPlant(plant: Plant, lot?: SeedLot) {
|
||||
ensurePlant(plant)
|
||||
setArmedPlant(plant)
|
||||
if (lot === undefined) {
|
||||
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
|
||||
lot = lots.length === 1 ? lots[0] : undefined
|
||||
}
|
||||
setArmedPlant(plant, lot?.id ?? null)
|
||||
}
|
||||
|
||||
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
||||
@@ -294,14 +306,14 @@ export function GardenEditorPage() {
|
||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||
// pick (nothing armed, picker left open). Picking (place OR change) makes sure
|
||||
// the plant renders and drops it into this garden's tray for quick reuse.
|
||||
function onPickPlant(plant: Plant) {
|
||||
function onPickPlant(plant: Plant, lot?: SeedLot) {
|
||||
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||
ensurePlant(plant)
|
||||
addToTray(plant)
|
||||
if (picker === 'change' && selectedPlop) {
|
||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||
} else {
|
||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
||||
setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
|
||||
}
|
||||
setPicker(null)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||
import { PlantCard } from '@/components/plants/PlantCard'
|
||||
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||
import { PlantPicker } from '@/editor/PlantPicker'
|
||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||
import type { UnitPref } from '@/lib/units'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
@@ -19,6 +22,9 @@ type Dialog =
|
||||
| { kind: 'duplicate'; plant: Plant }
|
||||
| { kind: 'delete'; plant: Plant }
|
||||
| { kind: 'picker' }
|
||||
| { kind: 'addLot'; plant: Plant }
|
||||
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||
| { kind: 'deleteLot'; lot: SeedLot }
|
||||
| null
|
||||
|
||||
// There's no per-user unit preference server-side (only per-garden), so the
|
||||
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
|
||||
export function PlantsPage() {
|
||||
usePageTitle('Plants')
|
||||
const plants = usePlants()
|
||||
const seedLots = useSeedLots()
|
||||
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||
@@ -108,9 +116,13 @@ export function PlantsPage() {
|
||||
key={p.id}
|
||||
plant={p}
|
||||
unit={unit}
|
||||
lots={lots.get(p.id) ?? []}
|
||||
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
||||
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
||||
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
|
||||
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
|
||||
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
|
||||
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,6 +133,9 @@ export function PlantsPage() {
|
||||
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
||||
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
|
||||
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
|
||||
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||
{dialog?.kind === 'picker' && (
|
||||
<PlantPicker
|
||||
unit={unit}
|
||||
|
||||
Reference in New Issue
Block a user