diff --git a/web/src/components/plants/DeleteSeedLotModal.tsx b/web/src/components/plants/DeleteSeedLotModal.tsx new file mode 100644 index 0000000..5d7dc00 --- /dev/null +++ b/web/src/components/plants/DeleteSeedLotModal.tsx @@ -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(null) + + return ( + +
+

+ {formatQuantity(lot.quantity)} {lot.unit} + {lot.vendor ? ` from ${lot.vendor}` : ''} + {lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}. +

+

+ Anything planted from it stays exactly where it is — it just stops being attributed to this purchase. +

+ {error && {error}} +
+ + +
+
+
+ ) +} diff --git a/web/src/components/plants/PlantCard.tsx b/web/src/components/plants/PlantCard.tsx index fe56d6b..2a8d771 100644 --- a/web/src/components/plants/PlantCard.tsx +++ b/web/src/components/plants/PlantCard.tsx @@ -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 (
@@ -38,6 +55,12 @@ export function PlantCard({

{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing

+ {(plant.vendor || plant.sourceUrl) && ( +

+ {plant.vendor && {plant.vendor}} + +

+ )} {plant.notes &&

{plant.notes}

}
-
+ {showLots && ( +
+ +
+ )} + +
+ {/* 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. */} + diff --git a/web/src/components/plants/PlantFormModal.tsx b/web/src/components/plants/PlantFormModal.tsx index 70faae3..2072617 100644 --- a/web/src/components/plants/PlantFormModal.tsx +++ b/web/src/components/plants/PlantFormModal.tsx @@ -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(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. */} +
+ setVendor(e.target.value)} + /> + setSourceUrl(e.target.value)} + /> +
+