664 lines
22 KiB
TypeScript
664 lines
22 KiB
TypeScript
"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 & 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 & 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>
|
||
);
|
||
}
|