diff --git a/src/components/stock-orders/quick-draft-modal.tsx b/src/components/stock-orders/quick-draft-modal.tsx index 23a61a2..7514769 100644 --- a/src/components/stock-orders/quick-draft-modal.tsx +++ b/src/components/stock-orders/quick-draft-modal.tsx @@ -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(null); + // Edit-mode state: per-line qty/cost buffers + the line currently saving. + const [lineEdits, setLineEdits] = useState< + Record + >({}); + const [savingLineId, setSavingLineId] = useState(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 = {}; + // 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 = {}; + 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 (
{(() => { - 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) {
- {cardsLoading && drafts.length === 0 && ( + {cardsLoading && visibleDrafts.length === 0 && (
Loading drafts…
)} - {!cardsLoading && drafts.length === 0 && ( + {!cardsLoading && visibleDrafts.length === 0 && (
No drafts yet. Add an item above to start a draft PO.
)} - {drafts.map((draft) => { - const total = draft.lines.reduce( - (sum, l) => sum + l.quantity * l.cost_price, - 0, - ); - return ( -
-
-
{draft.supplier_name}
+ {visibleDrafts.map((draft) => { + const total = draft.lines.reduce( + (sum, l) => sum + l.quantity * l.cost_price, + 0, + ); + return ( +
+
+
{draft.supplier_name}
+
+ {draft.purchase_number} +
+
- {draft.purchase_number} + Estimated total:{" "} + R {total.toFixed(2)} +
+
+ {draft.lines.map((line) => { + const edit = lineEdits[line.id] ?? { + quantity: String(line.quantity), + costPrice: String(line.cost_price), + }; + const isSaving = savingLineId === line.id; + return ( +
+ + {line.description} + + {editPurchaseId ? ( + <> +
+ + + 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} + /> +
+
+ + + 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} + /> +
+ + + + ) : ( + + + {line.quantity} × R {line.cost_price.toFixed(2)} + + + + )} +
+ ); + })}
-
- Estimated total:{" "} - R {total.toFixed(2)} -
-
- {draft.lines.map((line) => ( -
- {line.description} - - - {line.quantity} × R {line.cost_price.toFixed(2)} - - - -
- ))} -
-
- ); - })} + ); + })}
);