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:
parent
811b2dca1c
commit
2303d8a29d
1 changed files with 249 additions and 45 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue