feat(stock-orders): QuickDraftModal edit mode for draft lines

Adds an optional editPurchaseId prop. When set, the modal hides all
other drafts and renders per-line qty/cost inputs + a Save button that
PATCHes /api/v1/stock-quick-draft/line/[lineId]. Remove still works.
Search + add still works to add more lines to the same draft.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-31 13:44:29 +02:00
parent 811b2dca1c
commit 2303d8a29d

View file

@ -45,6 +45,11 @@ interface DraftSummary {
export interface QuickDraftModalProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/** When set, the modal renders only this draft's lines (read-only
* summary cards) plus an inline editor per line. All other drafts'
* cards are hidden. Search + add still works so the buyer can keep
* adding new lines to this draft. */
editPurchaseId?: string;
}
function formatMonthYear(date: Date): string {
@ -95,7 +100,11 @@ function NoHistorySupplierPicker({
);
}
export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
export function QuickDraftModal({
open,
onOpenChange,
editPurchaseId,
}: QuickDraftModalProps) {
const [defaultReference] = useState(
() => `Stock order entry — ${formatMonthYear(new Date())}`,
);
@ -129,6 +138,40 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
const [cardsLoading, setCardsLoading] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
// Edit-mode state: per-line qty/cost buffers + the line currently saving.
const [lineEdits, setLineEdits] = useState<
Record<string, { quantity: string; costPrice: string }>
>({});
const [savingLineId, setSavingLineId] = useState<string | null>(null);
// Seed the per-line edit buffers from the targeted draft when it loads
// (or when the target changes). Without this the qty/cost inputs would
// be empty on first open in edit mode. Preserves dirty entries on
// existing lines so that an unrelated draft refresh (e.g. after a Save)
// doesn't wipe out in-progress edits on other rows.
useEffect(() => {
if (!editPurchaseId) return;
const draft = drafts.find((d) => d.id === editPurchaseId);
if (!draft) return;
setLineEdits((prev) => {
const next: Record<string, { quantity: string; costPrice: string }> = {};
// Preserve dirty entries for lines that still exist in the draft;
// only seed defaults for line IDs we haven't tracked yet. Stale
// entries for removed lines are discarded — that row is gone.
for (const line of draft.lines) {
if (prev[line.id]) {
next[line.id] = prev[line.id];
} else {
next[line.id] = {
quantity: String(line.quantity),
costPrice: String(line.cost_price),
};
}
}
return next;
});
}, [editPurchaseId, drafts]);
const parsedQty = Number.parseFloat(qty);
const parsedCost = Number.parseFloat(cost);
const newSkuValid =
@ -142,6 +185,13 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
parsedCost >= 0 &&
(newSkuMode ? newSkuValid : selectedItem !== null);
// In edit mode, the cards region only renders the targeted draft; the
// footer counts and empty-state check should reflect what's actually
// visible, not the full list across every supplier.
const visibleDrafts = editPurchaseId
? drafts.filter((d) => d.id === editPurchaseId)
: drafts;
// Cards fetch — refetched when modal opens, after each successful add, and after remove.
const fetchCards = useCallback(async () => {
setCardsLoading(true);
@ -359,6 +409,64 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
}
};
// Edit-mode save: PATCH only the fields that changed so the server side
// stays the source of truth and we can rely on fetchCards() to refresh
// the cards region after each save.
const onSaveLine = async (lineId: string) => {
const draft = drafts.find((d) => d.id === editPurchaseId);
if (!draft) return;
const original = draft.lines.find((l) => l.id === lineId);
const edit = lineEdits[lineId];
if (!original || !edit) return;
const parsedQty = edit.quantity.trim() === "" ? NaN : Number(edit.quantity);
const parsedCost =
edit.costPrice.trim() === "" ? NaN : Number(edit.costPrice);
if (!Number.isFinite(parsedQty) || parsedQty <= 0) {
toast.error("Quantity must be a positive number");
return;
}
if (!Number.isFinite(parsedCost) || parsedCost < 0) {
toast.error("Cost must be a non-negative number");
return;
}
const qtyUnchanged = parsedQty === original.quantity;
const costUnchanged = parsedCost === original.cost_price;
if (qtyUnchanged && costUnchanged) {
toast.info("No changes to save");
return;
}
setSavingLineId(lineId);
try {
const body: Record<string, number> = {};
if (!qtyUnchanged) body.quantity = parsedQty;
if (!costUnchanged) body.costPrice = parsedCost;
const res = await fetch(
`/api/v1/stock-quick-draft/line/${encodeURIComponent(lineId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const data = (await res.json()) as { ok: boolean; message?: string };
if (!res.ok || !data.ok) {
toast.error(data.message ?? "Could not save line");
return;
}
toast.success("Line updated");
await fetchCards();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Network error");
} finally {
setSavingLineId(null);
}
};
return (
<ModalShell.Dialog
open={open}
@ -373,11 +481,11 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
>
<div className="text-xs text-muted-foreground">
{(() => {
const lineCount = drafts.reduce(
const lineCount = visibleDrafts.reduce(
(s, d) => s + d.lines.length,
0,
);
const supplierCount = drafts.length;
const supplierCount = visibleDrafts.length;
const savedAgo = lastSavedAt
? `${Math.max(
1,
@ -581,59 +689,155 @@ export function QuickDraftModal({ open, onOpenChange }: QuickDraftModalProps) {
</div>
<div className="space-y-3 border-t pt-3" data-testid="region-cards">
{cardsLoading && drafts.length === 0 && (
{cardsLoading && visibleDrafts.length === 0 && (
<div className="text-xs text-muted-foreground">
Loading drafts…
</div>
)}
{!cardsLoading && drafts.length === 0 && (
{!cardsLoading && visibleDrafts.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>
{visibleDrafts.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">
{draft.purchase_number}
Estimated total:{" "}
<strong>R {total.toFixed(2)}</strong>
</div>
<div className="divide-y">
{draft.lines.map((line) => {
const edit = lineEdits[line.id] ?? {
quantity: String(line.quantity),
costPrice: String(line.cost_price),
};
const isSaving = savingLineId === line.id;
return (
<div
key={line.id}
className="flex flex-wrap items-center gap-2 py-2 text-sm"
data-testid="quick-draft-edit-line"
>
<span className="flex-1 min-w-[120px]">
{line.description}
</span>
{editPurchaseId ? (
<>
<div className="flex items-center gap-1">
<label
htmlFor={`edit-qty-${line.id}`}
className="text-xs text-muted-foreground"
>
Qty
</label>
<Input
id={`edit-qty-${line.id}`}
data-testid={`quick-draft-edit-qty-${line.id}`}
type="number"
inputMode="numeric"
min="0"
value={edit.quantity}
onChange={(e) =>
setLineEdits((p) => ({
...p,
[line.id]: {
...(p[line.id] ?? {
quantity: String(line.quantity),
costPrice: String(line.cost_price),
}),
quantity: e.target.value,
},
}))
}
className="h-8 w-20"
disabled={isSaving}
/>
</div>
<div className="flex items-center gap-1">
<label
htmlFor={`edit-cost-${line.id}`}
className="text-xs text-muted-foreground"
>
Cost
</label>
<Input
id={`edit-cost-${line.id}`}
data-testid={`quick-draft-edit-cost-${line.id}`}
type="number"
inputMode="decimal"
min="0"
step="0.01"
value={edit.costPrice}
onChange={(e) =>
setLineEdits((p) => ({
...p,
[line.id]: {
...(p[line.id] ?? {
quantity: String(line.quantity),
costPrice: String(line.cost_price),
}),
costPrice: e.target.value,
},
}))
}
className="h-8 w-24"
disabled={isSaving}
/>
</div>
<Button
type="button"
size="sm"
variant="outline"
data-testid={`quick-draft-save-line-${line.id}`}
disabled={isSaving}
onClick={() => onSaveLine(line.id)}
>
{isSaving ? "Saving…" : "Save"}
</Button>
<button
type="button"
aria-label="Remove line"
data-testid={`quick-draft-remove-line-${line.id}`}
onClick={() => onRemoveLine(line.id)}
disabled={isSaving}
className="text-warn hover:underline text-xs disabled:opacity-50 disabled:cursor-not-allowed"
>
Remove
</button>
</>
) : (
<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 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>
);