Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
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>
|
|
)
|
|
}
|