Seed shelf UI: source links, lots, and what's left (#51) #68
@@ -0,0 +1,51 @@
|
|||||||
|
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 { useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||||
|
import { formatQuantity } from './SeedLotList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,9 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { PlantIcon } from './PlantIcon'
|
import { PlantIcon } from './PlantIcon'
|
||||||
|
import { LotStateChip, SeedLotList, formatQuantity } from './SeedLotList'
|
||||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||||
|
import { safeExternalUrl, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,17 +14,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units'
|
|||||||
export function PlantCard({
|
export function PlantCard({
|
||||||
plant,
|
plant,
|
||||||
unit,
|
unit,
|
||||||
|
lots,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onAddLot,
|
||||||
|
onEditLot,
|
||||||
|
onDeleteLot,
|
||||||
}: {
|
}: {
|
||||||
plant: Plant
|
plant: Plant
|
||||||
unit: UnitPref
|
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
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
|
onAddLot: () => void
|
||||||
|
onEditLot: (lot: SeedLot) => void
|
||||||
|
onDeleteLot: (lot: SeedLot) => void
|
||||||
}) {
|
}) {
|
||||||
const builtin = isBuiltin(plant)
|
const builtin = isBuiltin(plant)
|
||||||
|
const [showLots, setShowLots] = useState(false)
|
||||||
|
const summary = summarizeLots(lots)
|
||||||
|
const sourceUrl = safeExternalUrl(plant.sourceUrl)
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||||
<div className="flex items-start gap-3 p-4">
|
<div className="flex items-start gap-3 p-4">
|
||||||
@@ -38,6 +54,23 @@ export function PlantCard({
|
|||||||
<p className="mt-0.5 text-sm text-muted">
|
<p className="mt-0.5 text-sm text-muted">
|
||||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||||
</p>
|
</p>
|
||||||
|
{(plant.vendor || sourceUrl) && (
|
||||||
|
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||||
|
{plant.vendor && <span>{plant.vendor}</span>}
|
||||||
|
{sourceUrl && (
|
||||||
|
<a
|
||||||
|
href={sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
// The href is a URL somebody pasted; noopener keeps it from
|
||||||
|
// getting a handle back to this window.
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
source ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -46,7 +79,34 @@ export function PlantCard({
|
|||||||
title={plant.color}
|
title={plant.color}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type PlantCategory,
|
type PlantCategory,
|
||||||
type PlantInput,
|
type PlantInput,
|
||||||
} from '@/lib/plants'
|
} from '@/lib/plants'
|
||||||
|
import { safeExternalUrl } from '@/lib/seedLots'
|
||||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#4a7c3f'
|
const DEFAULT_COLOR = '#4a7c3f'
|
||||||
@@ -61,6 +62,8 @@ export function PlantFormModal({
|
|||||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
||||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
||||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
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 [notes, setNotes] = useState(source?.notes ?? '')
|
||||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
@@ -96,6 +99,14 @@ export function PlantFormModal({
|
|||||||
daysToMaturity = d
|
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 = {
|
const input: PlantInput = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
category,
|
category,
|
||||||
@@ -103,6 +114,8 @@ export function PlantFormModal({
|
|||||||
color,
|
color,
|
||||||
icon: icon.trim(),
|
icon: icon.trim(),
|
||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
vendor: vendor.trim(),
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +207,27 @@ export function PlantFormModal({
|
|||||||
onChange={(e) => setDays(e.target.value)}
|
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)} />
|
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
|
||||||
{formError && <Alert>{formError}</Alert>}
|
{formError && <Alert>{formError}</Alert>}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import {
|
||||||
|
formatCost,
|
||||||
|
formatUnitCost,
|
||||||
|
lotState,
|
||||||
|
safeExternalUrl,
|
||||||
|
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: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||||
|
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 url = safeExternalUrl(lot.sourceUrl)
|
||||||
|
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>}
|
||||||
|
{url && (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
// noopener/noreferrer on every outbound link: the target is a URL
|
||||||
|
// someone pasted, and window.opener would hand it a handle back.
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
source ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
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 {
|
||||||
|
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 [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(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: lot.version, ...input })
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync(input)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ export function GardenCanvas({
|
|||||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
|
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||||
const liveObject = useEditorStore((s) => s.liveObject)
|
const liveObject = useEditorStore((s) => s.liveObject)
|
||||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||||
const { fitToRect } = useViewport(svgRef)
|
const { fitToRect } = useViewport(svgRef)
|
||||||
@@ -200,7 +201,14 @@ export function GardenCanvas({
|
|||||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||||
// the object, so a selection would be hidden until placement ends anyway).
|
// 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
|
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { fieldControlClass } from '@/components/ui/field'
|
|||||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
|
import { lotsByPlant, lotState, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
|
import { LotStateChip, formatQuantity } from '@/components/plants/SeedLotList'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
const RECENT_KEY = 'pansy:recent-plants'
|
const RECENT_KEY = 'pansy:recent-plants'
|
||||||
@@ -41,11 +43,20 @@ export function PlantPicker({
|
|||||||
onClose,
|
onClose,
|
||||||
unit = 'metric',
|
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
|
onClose: () => void
|
||||||
unit?: UnitPref
|
unit?: UnitPref
|
||||||
}) {
|
}) {
|
||||||
const plants = usePlants()
|
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 [query, setQuery] = useState('')
|
||||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||||
@@ -74,8 +85,18 @@ export function PlantPicker({
|
|||||||
}, [all, recent, query])
|
}, [all, recent, query])
|
||||||
|
|
||||||
function choose(p: Plant) {
|
function choose(p: Plant) {
|
||||||
|
const lots = (lotsFor.get(p.id) ?? []).filter((l) => l.remaining > 0)
|
||||||
|
if (lots.length > 1) {
|
||||||
|
setChoosingLotFor(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
setRecent(recordRecent(p.id))
|
setRecent(recordRecent(p.id))
|
||||||
onSelect(p)
|
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
|
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||||
@@ -92,11 +113,22 @@ export function PlantPicker({
|
|||||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||||
<span className="block text-xs text-muted">
|
<span className="block text-xs text-muted">
|
||||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||||
|
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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.
|
||||||
|
function remainingLabel(lots: SeedLot[]): string {
|
||||||
|
if (lots.length === 0) return ''
|
||||||
|
const unitName = lots.every((l) => l.unit === lots[0].unit) ? ` ${lots[0].unit}` : ''
|
||||||
|
const total = lots.reduce((sum, l) => sum + l.remaining, 0)
|
||||||
|
return ` · ${formatQuantity(total)}${unitName} left`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||||
@@ -132,7 +164,43 @@ export function PlantPicker({
|
|||||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{choosingLotFor && (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
<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>
|
||||||
|
{(lotsFor.get(choosingLotFor.id) ?? [])
|
||||||
|
.filter((l) => l.remaining > 0)
|
||||||
|
.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.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>}
|
{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
|
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||||
armedPlant: Plant | null
|
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
|
// 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.
|
// 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 }),
|
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||||
|
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
armedLotId: null,
|
||||||
|
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||||
|
|
||||||
liveObject: null,
|
liveObject: null,
|
||||||
setLiveObject: (o) => set({ liveObject: o }),
|
setLiveObject: (o) => set({ liveObject: o }),
|
||||||
@@ -118,6 +122,7 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
selectedPlantingId: null,
|
selectedPlantingId: null,
|
||||||
focusedObjectId: null,
|
focusedObjectId: null,
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
|
armedLotId: null,
|
||||||
armedKind: null,
|
armedKind: null,
|
||||||
liveObject: null,
|
liveObject: null,
|
||||||
livePlanting: null,
|
livePlanting: null,
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export interface PlantingCreate {
|
|||||||
radiusCm: number
|
radiusCm: number
|
||||||
count?: number | null
|
count?: number | null
|
||||||
label?: string | 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) {
|
export function useCreatePlanting(gardenId: number) {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
|
|||||||
color: z.string(),
|
color: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
daysToMaturity: z.number().nullable().optional(),
|
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(),
|
notes: z.string(),
|
||||||
version: z.number(),
|
version: z.number(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@@ -75,6 +79,8 @@ export interface PlantInput {
|
|||||||
color: string
|
color: string
|
||||||
icon: string
|
icon: string
|
||||||
daysToMaturity: number | null
|
daysToMaturity: number | null
|
||||||
|
sourceUrl: string
|
||||||
|
vendor: string
|
||||||
notes: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatCost,
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// 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 { 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 { toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
import { useJournalCounts } from '@/lib/journal'
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
|
import type { SeedLot } from '@/lib/seedLots'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
@@ -277,9 +278,9 @@ export function GardenEditorPage() {
|
|||||||
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
// 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 —
|
// 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.
|
// otherwise its placed plops render without an icon/color until a refetch.
|
||||||
function armPlant(plant: Plant) {
|
function armPlant(plant: Plant, lot?: SeedLot) {
|
||||||
ensurePlant(plant)
|
ensurePlant(plant)
|
||||||
setArmedPlant(plant)
|
setArmedPlant(plant, lot?.id ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
||||||
@@ -294,14 +295,14 @@ export function GardenEditorPage() {
|
|||||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
// 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
|
// 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.
|
// 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
|
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||||
ensurePlant(plant)
|
ensurePlant(plant)
|
||||||
addToTray(plant)
|
addToTray(plant)
|
||||||
if (picker === 'change' && selectedPlop) {
|
if (picker === 'change' && selectedPlop) {
|
||||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||||
} else {
|
} else {
|
||||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
|
||||||
}
|
}
|
||||||
setPicker(null)
|
setPicker(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
|
|||||||
import { PlantCard } from '@/components/plants/PlantCard'
|
import { PlantCard } from '@/components/plants/PlantCard'
|
||||||
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
||||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||||
|
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||||
|
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
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 type { UnitPref } from '@/lib/units'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ type Dialog =
|
|||||||
| { kind: 'duplicate'; plant: Plant }
|
| { kind: 'duplicate'; plant: Plant }
|
||||||
| { kind: 'delete'; plant: Plant }
|
| { kind: 'delete'; plant: Plant }
|
||||||
| { kind: 'picker' }
|
| { kind: 'picker' }
|
||||||
|
| { kind: 'addLot'; plant: Plant }
|
||||||
|
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||||
|
| { kind: 'deleteLot'; lot: SeedLot }
|
||||||
| null
|
| null
|
||||||
|
|
||||||
// There's no per-user unit preference server-side (only per-garden), so the
|
// There's no per-user unit preference server-side (only per-garden), so the
|
||||||
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
|
|||||||
export function PlantsPage() {
|
export function PlantsPage() {
|
||||||
usePageTitle('Plants')
|
usePageTitle('Plants')
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
|
const seedLots = useSeedLots()
|
||||||
|
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||||
@@ -108,9 +116,13 @@ export function PlantsPage() {
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
plant={p}
|
plant={p}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
lots={lots.get(p.id) ?? []}
|
||||||
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
||||||
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
||||||
onDuplicate={() => setDialog({ kind: 'duplicate', 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>
|
</div>
|
||||||
@@ -121,6 +133,9 @@ export function PlantsPage() {
|
|||||||
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
||||||
{dialog?.kind === 'duplicate' && <PlantFormModal template={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 === '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' && (
|
{dialog?.kind === 'picker' && (
|
||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
|||||||
Reference in New Issue
Block a user