WFDASH-WEBAPP/src/components/stock-orders/quick-draft-modal.tsx
SJCoetz dfae2d0c8b feat(stock-quick-draft): no-history supplier dropdown fallback
Co-authored-by: Orca <help@stably.ai>
2026-07-30 23:18:48 +02:00

664 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ModalShell } from "@/components/ui/modal-shell";
import type {
StockItemSearchResult,
SupplierHistoryEntry,
} from "@/lib/server/stock-quick-draft-service";
interface DraftSummary {
id: string;
purchase_number: string;
supplier: string;
supplier_name: string;
lines: {
id: string;
description: string;
quantity: number;
cost_price: number;
}[];
}
/**
* Quick-Draft Modal — item-first stock-order entry on /stock-orders.
*
* See docs/superpowers/specs/2026-07-30-stock-quick-draft-design.md
* for the full design.
*
* Data flow:
* - search & history hit /api/v1/stock-quick-draft/{search,supplier-history}
* - "Add & next" hits /api/v1/stock-quick-draft/add-line
* - cards region deletes via /api/v1/stock-quick-draft/line/[lineId]
*
* ModalShell in this codebase uses a prop-based pattern (eyebrow /
* title / description / children / footer) rather than exposing
* Header / Body / Footer sub-components, so the input lives as a
* child at the top of the dialog. The eyebrow surfaces the
* "Stock Order Reference" label above the input in production.
*/
export interface QuickDraftModalProps {
open: boolean;
onOpenChange: (next: boolean) => void;
}
function formatMonthYear(date: Date): string {
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
/**
* Fallback supplier picker used when there is no purchase history for
* the picked stock item (or in new-SKU mode, where there's no item at
* all). Renders a full `<select>` dropdown of every supplier, plus a
* loading hint while the list is fetched.
*/
function NoHistorySupplierPicker({
suppliersLoading,
allSuppliers,
supplierChoice,
onPick,
}: {
suppliersLoading: boolean;
allSuppliers: { id: string; name: string }[];
supplierChoice: string | null;
onPick: (supplierId: string) => void;
}) {
return (
<div className="space-y-1">
<Label htmlFor="quick-draft-supplier-select">Supplier</Label>
<select
id="quick-draft-supplier-select"
data-testid="quick-draft-supplier-select"
value={supplierChoice ?? ""}
onChange={(e) => onPick(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
disabled={suppliersLoading && allSuppliers.length === 0}
>
<option value="">— pick a supplier —</option>
{allSuppliers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<div className="text-xs text-muted-foreground">
{suppliersLoading && allSuppliers.length === 0
? "Loading suppliers…"
: "No prior history for this item — pick any supplier."}
</div>
</div>
);
}
export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
const [defaultReference] = useState(
() => `Stock order entry — ${formatMonthYear(new Date())}`,
);
// Search state.
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<StockItemSearchResult[]>(
[],
);
const [selectedItem, setSelectedItem] =
useState<StockItemSearchResult | null>(null);
const [history, setHistory] = useState<SupplierHistoryEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [supplierChoice, setSupplierChoice] = useState<string | null>(null);
// Fallback supplier list when there is no purchase history for the
// selected item (or in new-SKU mode, where there's no item yet).
const [allSuppliers, setAllSuppliers] = useState<
{ id: string; name: string }[]
>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false);
const [qty, setQty] = useState("");
const [cost, setCost] = useState("");
// New-SKU toggle state (named for readability; plan uses isNewSku/newName/newSku)
const [newSkuMode, setNewSkuMode] = useState(false);
const [newSkuName, setNewSkuName] = useState("");
const [newSkuSku, setNewSkuSku] = useState("");
const [submitting, setSubmitting] = useState(false);
// Cards state.
const [drafts, setDrafts] = useState<DraftSummary[]>([]);
const [cardsLoading, setCardsLoading] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const parsedQty = Number.parseFloat(qty);
const parsedCost = Number.parseFloat(cost);
const newSkuValid =
newSkuName.trim().length > 0 && newSkuSku.trim().length > 0;
const canSubmit =
!submitting &&
supplierChoice !== null &&
Number.isFinite(parsedQty) &&
parsedQty > 0 &&
Number.isFinite(parsedCost) &&
parsedCost >= 0 &&
(newSkuMode ? newSkuValid : selectedItem !== null);
// Cards fetch — refetched when modal opens, after each successful add, and after remove.
const fetchCards = useCallback(async () => {
setCardsLoading(true);
try {
const res = await fetch("/api/v1/stock-quick-draft/drafts", {});
const data = (await res.json()) as {
ok: boolean;
items?: DraftSummary[];
message?: string;
};
if (data.ok && Array.isArray(data.items)) {
// Only keep items that look like drafts (have a lines array). Other
// callers may share the same fetch mock in tests; defensive filter
// avoids rendering their payloads as draft cards.
const validDrafts = data.items.filter(
(d): d is DraftSummary => Array.isArray(d.lines),
);
setDrafts(validDrafts);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load drafts");
} finally {
setCardsLoading(false);
}
}, []);
useEffect(() => {
if (open) fetchCards();
}, [open, fetchCards]);
// Debounced search.
useEffect(() => {
if (searchQuery.trim().length < 2) {
setSearchResults([]);
return;
}
let cancelled = false;
const handle = setTimeout(async () => {
try {
const res = await fetch(
`/api/v1/stock-quick-draft/search?q=${encodeURIComponent(
searchQuery.trim(),
)}&limit=10`,
);
if (!res.ok) {
if (!cancelled) toast.error(`Search failed (HTTP ${res.status})`);
return;
}
const data = (await res.json()) as {
ok: boolean;
message?: string;
items?: StockItemSearchResult[];
};
if (cancelled) return;
if (!data.ok) {
toast.error(data.message ?? "Search failed");
return;
}
if (data.items) setSearchResults(data.items);
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "Search failed");
}
}
}, 200);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [searchQuery]);
// Fetch the full supplier list whenever we land in a state with no
// history-driven supplier choice (no history rows for the picked
// item, or new-SKU mode where there is no item at all). Only fires
// once per modal session — the list is cached in `allSuppliers`.
useEffect(() => {
// Only fetch the full supplier list when we don't have a
// history-driven supplier choice to show.
if (!selectedItem && !newSkuMode) return;
if (historyLoading) return; // Wait for history fetch to settle first
if (history.length > 0) return; // History already gives us the choices
if (allSuppliers.length > 0) return; // Already cached
let cancelled = false;
setSuppliersLoading(true);
(async () => {
try {
const res = await fetch("/api/v1/stock-quick-draft/suppliers");
const data = (await res.json()) as {
ok: boolean;
items?: { id: string; name: string }[];
message?: string;
};
if (!cancelled && data.ok && data.items) setAllSuppliers(data.items);
} catch (err) {
if (!cancelled)
toast.error(
err instanceof Error ? err.message : "Failed to load suppliers",
);
} finally {
if (!cancelled) setSuppliersLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [
selectedItem,
newSkuMode,
historyLoading,
history.length,
allSuppliers.length,
]);
const onPickItem = async (item: StockItemSearchResult) => {
setSelectedItem(item);
setSupplierChoice(null);
setHistoryLoading(true);
setHistory([]);
try {
const res = await fetch(
`/api/v1/stock-quick-draft/supplier-history?stockItemId=${encodeURIComponent(item.id)}`,
);
if (!res.ok) {
toast.error(`Failed to load supplier history (HTTP ${res.status})`);
return;
}
const data = (await res.json()) as {
ok: boolean;
message?: string;
history?: SupplierHistoryEntry[];
};
if (!data.ok) {
toast.error(data.message ?? "Failed to load supplier history");
return;
}
if (data.history) setHistory(data.history);
} catch (err) {
toast.error(err instanceof Error ? err.message : "History fetch failed");
} finally {
setHistoryLoading(false);
}
};
const onAddAndNext = async () => {
if (submitting) return; // closure-time guard against double-click races
if (!canSubmit) return;
setSubmitting(true);
try {
const body: Record<string, unknown> = {
supplierId: supplierChoice,
quantity: parsedQty,
costPrice: parsedCost,
};
if (newSkuMode) {
body.newStockItem = {
name: newSkuName.trim(),
sku: newSkuSku.trim(),
};
} else {
body.stockItemId = selectedItem?.id;
}
const res = await fetch("/api/v1/stock-quick-draft/add-line", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as {
ok: boolean;
purchaseNumber?: string;
message?: string;
};
if (!res.ok || !data.ok) {
toast.error(data.message ?? "Could not add line");
return;
}
toast.success(`Added to PO ${data.purchaseNumber}`);
setQty("");
setCost("");
setNewSkuName("");
setNewSkuSku("");
setLastSavedAt(new Date());
await fetchCards();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Network error");
} finally {
setSubmitting(false);
}
};
const onRemoveLine = async (lineId: string) => {
try {
const res = await fetch(
`/api/v1/stock-quick-draft/line/${encodeURIComponent(lineId)}`,
{ method: "DELETE" },
);
const data = (await res.json()) as {
ok: boolean;
message?: string;
};
if (!res.ok || !data.ok) {
toast.error(data.message ?? "Could not remove line");
return;
}
toast.success("Line removed");
await fetchCards();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Network error");
}
};
return (
<ModalShell.Dialog
open={open}
onOpenChange={onOpenChange}
size="xl"
eyebrow="Stock Order Reference"
title={<span className="sr-only">Stock Order Reference</span>}
footer={
<div
className="flex items-center justify-between border-t pt-3"
data-testid="region-footer"
>
<div className="text-xs text-muted-foreground">
{(() => {
const lineCount = drafts.reduce(
(s, d) => s + d.lines.length,
0,
);
const supplierCount = drafts.length;
const savedAgo = lastSavedAt
? `${Math.max(
1,
Math.round(
(Date.now() - lastSavedAt.getTime()) / 60_000,
),
)}m ago`
: "—";
return `${lineCount} line${lineCount === 1 ? "" : "s"} · ${supplierCount} supplier${supplierCount === 1 ? "" : "s"} · last saved ${savedAgo}`;
})()}
</div>
<div className="flex gap-2">
<Button
type="button"
data-testid="quick-draft-save-close"
variant="outline"
onClick={() => onOpenChange(false)}
>
Save &amp; close
</Button>
<Button
type="button"
data-testid="quick-draft-finalize"
variant="default"
onClick={() => onOpenChange(false)}
>
Finalize
</Button>
</div>
</div>
}
>
<div className="space-y-1.5">
<Input
data-testid="stock-order-reference"
defaultValue={defaultReference}
className="h-9 text-base font-semibold"
/>
</div>
<div className="space-y-3">
{!newSkuMode && (
<>
<Input
data-testid="quick-draft-search"
placeholder="Search stock item (e.g. '115 cutting disc')"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchResults.length > 0 && (
<div className="rounded-md border divide-y">
{searchResults.map((item) => (
<button
key={item.id}
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-muted/40"
onClick={() => onPickItem(item)}
>
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
SKU: {item.sku}
</div>
</button>
))}
</div>
)}
</>
)}
{selectedItem && !newSkuMode && (
<div className="rounded-md border bg-muted/20 p-3 space-y-3">
<div className="text-sm font-medium">{selectedItem.name}</div>
{historyLoading && (
<div className="text-xs text-muted-foreground">
Loading supplier history…
</div>
)}
{!historyLoading && history.length === 0 && (
<NoHistorySupplierPicker
suppliersLoading={suppliersLoading}
allSuppliers={allSuppliers}
supplierChoice={supplierChoice}
onPick={setSupplierChoice}
/>
)}
{!historyLoading && history.length > 0 && (
<div className="text-xs space-y-1" role="radiogroup">
{history.map((entry) => (
<label
key={entry.supplierId}
className="flex cursor-pointer items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<input
type="radio"
name="quick-draft-supplier"
value={entry.supplierId}
checked={supplierChoice === entry.supplierId}
onChange={() => setSupplierChoice(entry.supplierId)}
className="size-4 accent-primary"
/>
<span>{entry.supplierName}</span>
</span>
<span className="text-muted-foreground">
last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
</span>
</label>
))}
</div>
)}
</div>
)}
{(selectedItem || newSkuMode) && (
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="quick-draft-qty">Qty</Label>
<Input
id="quick-draft-qty"
data-testid="quick-draft-qty"
type="number"
inputMode="numeric"
min="0"
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
</div>
<div>
<Label htmlFor="quick-draft-cost">Cost</Label>
<Input
id="quick-draft-cost"
data-testid="quick-draft-cost"
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
)}
{!newSkuMode ? (
<button
type="button"
data-testid="quick-draft-toggle-new-sku"
className="text-xs underline text-muted-foreground"
onClick={() => setNewSkuMode(true)}
>
+ New SKU not in stock yet
</button>
) : (
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
<div>
<Label htmlFor="quick-draft-new-sku-name">Name</Label>
<Input
id="quick-draft-new-sku-name"
data-testid="quick-draft-new-sku-name"
value={newSkuName}
onChange={(e) => setNewSkuName(e.target.value)}
/>
</div>
<div>
<Label htmlFor="quick-draft-new-sku-sku">SKU</Label>
<Input
id="quick-draft-new-sku-sku"
data-testid="quick-draft-new-sku-sku"
value={newSkuSku}
onChange={(e) => setNewSkuSku(e.target.value)}
/>
</div>
{historyLoading && (
<div className="text-xs text-muted-foreground">
Loading supplier history…
</div>
)}
{!historyLoading && history.length > 0 && (
<div className="text-xs space-y-1" role="radiogroup">
{history.map((entry) => (
<label
key={entry.supplierId}
className="flex cursor-pointer items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<input
type="radio"
name="quick-draft-supplier"
value={entry.supplierId}
checked={supplierChoice === entry.supplierId}
onChange={() => setSupplierChoice(entry.supplierId)}
className="size-4 accent-primary"
/>
<span>{entry.supplierName}</span>
</span>
<span className="text-muted-foreground">
last paid: <strong>R {entry.lastCost.toFixed(2)}</strong>
</span>
</label>
))}
</div>
)}
{!historyLoading && history.length === 0 && (
<NoHistorySupplierPicker
suppliersLoading={suppliersLoading}
allSuppliers={allSuppliers}
supplierChoice={supplierChoice}
onPick={setSupplierChoice}
/>
)}
<button
type="button"
className="text-xs underline text-muted-foreground"
onClick={() => setNewSkuMode(false)}
>
Cancel — search existing SKUs
</button>
</div>
)}
<Button
type="button"
data-testid="quick-draft-add"
disabled={!canSubmit}
onClick={onAddAndNext}
className="w-full"
>
Add &amp; next
</Button>
</div>
<div className="space-y-3 border-t pt-3" data-testid="region-cards">
{cardsLoading && drafts.length === 0 && (
<div className="text-xs text-muted-foreground">
Loading drafts…
</div>
)}
{!cardsLoading && drafts.length === 0 && (
<div className="text-xs text-muted-foreground">
No drafts yet. Add an item above to start a draft PO.
</div>
)}
{drafts.map((draft) => {
const total = draft.lines.reduce(
(sum, l) => sum + l.quantity * l.cost_price,
0,
);
return (
<div key={draft.id} className="rounded-md border p-3 space-y-2">
<div className="flex justify-between items-center">
<div className="font-medium">{draft.supplier_name}</div>
<div className="text-xs text-muted-foreground">
{draft.purchase_number}
</div>
</div>
<div className="text-xs text-muted-foreground">
Estimated total:{" "}
<strong>R {total.toFixed(2)}</strong>
</div>
<div className="divide-y">
{draft.lines.map((line) => (
<div
key={line.id}
className="flex justify-between items-center py-1 text-sm"
>
<span>{line.description}</span>
<span className="flex items-center gap-3 text-xs">
<span>
{line.quantity} × R {line.cost_price.toFixed(2)}
</span>
<button
type="button"
aria-label="Remove line"
onClick={() => onRemoveLine(line.id)}
className="text-warn hover:underline"
>
Remove
</button>
</span>
</div>
))}
</div>
</div>
);
})}
</div>
</ModalShell.Dialog>
);
}