feat(sprint-modal): lock selling, add PO qty, wire AI suggestion
The OrderSprintProcessModal was trying to do too much and made operators do too much. The buying flow on this screen is now: - Selling comes from the order and is fixed. Rendered as a read-only dashed-bordered cell with a "From order · not editable" hint — the price the customer was quoted is still visible so the operator can see the margin they're pricing at. - Markup field removed (it only existed to drive the cost↔selling recalc). Markup is now computed from selling/cost at save time so downstream margin reports stay consistent. - New PO Qty input (defaults to order_qty, falls back to quantity) written to order_qty. Lets buyers override for partial fills. - "Use AI suggestion" button next to the AI Insights header reads ai_item_insights.top_historical_matches[0] and applies supplier_id (resolved to the actual supplier name in the dropdown) + cost_price. Disabled when no AI matches; warns if the AI's supplier_id isn't in the suppliers list instead of silently dropping it. Visual refresh while here: backdrop-blur overlay, info-tinted AI section so it reads as distinct from the deterministic history, soft borders (border-border/60), success-coloured Process button with shadow, gradient header, hover transitions, sticky footer with status hint. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6093a749eb
commit
5f039ea8cd
2 changed files with 455 additions and 113 deletions
|
|
@ -56,15 +56,17 @@ const baseItem = {
|
|||
id: "item-1",
|
||||
description: "HSS drill bit",
|
||||
quantity: 2,
|
||||
order_qty: 10,
|
||||
selling_price: 50,
|
||||
supplier: "",
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
sku: "",
|
||||
notes: "",
|
||||
category: "",
|
||||
po_description: "",
|
||||
item_status: "pending",
|
||||
ai_item_insights: null,
|
||||
};
|
||||
|
||||
const suppliers = [
|
||||
|
|
@ -210,4 +212,144 @@ describe("OrderSprintProcessModal", () => {
|
|||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("locks the selling price as read-only (no input is rendered)", () => {
|
||||
renderModal();
|
||||
// No <input> for selling — the selling value is rendered as a
|
||||
// styled div. Operators can't change what came in from the order.
|
||||
expect(screen.queryByLabelText(/selling \(zar\)/i)).toBeNull();
|
||||
// The locked value still surfaces so the operator sees margin.
|
||||
expect(screen.getByText(/R50\.00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults qty to order_qty and writes it to order_qty on save", async () => {
|
||||
renderModal();
|
||||
// Default value comes from item.order_qty (10), not quantity (2).
|
||||
expect(
|
||||
(screen.getByLabelText(/po qty/i) as HTMLInputElement).value,
|
||||
).toBe("10");
|
||||
// Override and save — verify order_qty is in the payload.
|
||||
fireEvent.change(screen.getByLabelText(/po qty/i), {
|
||||
target: { value: "8" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Supplier"), {
|
||||
target: { value: "sup-1" },
|
||||
});
|
||||
await setField("Cost (ZAR)", "25");
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Description the supplier sees on the PO"),
|
||||
{ target: { value: "10mm HSS drill bit" } },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(updateMock.mock.calls[0]![1]).toMatchObject({
|
||||
order_qty: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to quantity when order_qty is missing", () => {
|
||||
renderModal({ item: { ...baseItem, order_qty: undefined, quantity: 5 } });
|
||||
expect(
|
||||
(screen.getByLabelText(/po qty/i) as HTMLInputElement).value,
|
||||
).toBe("5");
|
||||
});
|
||||
|
||||
it("computes markup from cost + selling at save time", async () => {
|
||||
// baseItem.selling_price = 50. Cost = 25 → markup = ((50-25)/25)*100 = 100.
|
||||
renderModal();
|
||||
fireEvent.change(screen.getByLabelText("Supplier"), {
|
||||
target: { value: "sup-1" },
|
||||
});
|
||||
await setField("Cost (ZAR)", "25");
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("Description the supplier sees on the PO"),
|
||||
{ target: { value: "ok" } },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /process item/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(updateMock.mock.calls[0]![1]).toMatchObject({
|
||||
markup: 100,
|
||||
selling_price: 50,
|
||||
cost_price: 25,
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Use AI suggestion' button", () => {
|
||||
it("is disabled when ai_item_insights has no top_historical_matches", () => {
|
||||
renderModal({ item: { ...baseItem, ai_item_insights: { procurement_summary: "hi" } } });
|
||||
const btn = screen.getByRole("button", { name: /use ai suggestion/i });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it("applies supplier (resolved by name) and cost from the AI's top match", async () => {
|
||||
renderModal({
|
||||
item: {
|
||||
...baseItem,
|
||||
ai_item_insights: {
|
||||
procurement_summary: "x".repeat(60),
|
||||
top_historical_matches: [
|
||||
{
|
||||
cost_price: 42,
|
||||
matched_description: "INGCO LASER DISTANCE METER 0.05-85M",
|
||||
matched_item_id: "abc",
|
||||
selling_price: 50,
|
||||
supplier_id: "sup-1", // resolves to "Steel Co"
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /use ai suggestion/i }),
|
||||
);
|
||||
|
||||
// Supplier dropdown now reads "Steel Co" (sup-1's name).
|
||||
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
|
||||
"sup-1",
|
||||
);
|
||||
// Cost populated from AI's cost_price.
|
||||
expect(
|
||||
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
|
||||
).toBe("42");
|
||||
});
|
||||
|
||||
it("warns when the AI's supplier_id doesn't exist in the suppliers list", async () => {
|
||||
renderModal({
|
||||
item: {
|
||||
...baseItem,
|
||||
ai_item_insights: {
|
||||
procurement_summary: "x".repeat(60),
|
||||
top_historical_matches: [
|
||||
{
|
||||
cost_price: 42,
|
||||
supplier_id: "ghost-supplier",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /use ai suggestion/i }),
|
||||
);
|
||||
|
||||
// Cost still populated, supplier stays empty, the toast warns
|
||||
// about the bad ID rather than silently dropping it.
|
||||
expect(
|
||||
(screen.getByLabelText(/cost \(zar\)/i) as HTMLInputElement).value,
|
||||
).toBe("42");
|
||||
expect((screen.getByLabelText("Supplier") as HTMLSelectElement).value).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,13 +6,14 @@ import {
|
|||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
DollarSign,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Lock,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Truck,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -44,6 +45,7 @@ type OrderSprintItem = {
|
|||
id: string;
|
||||
description?: string;
|
||||
quantity?: number | null;
|
||||
order_qty?: number | null;
|
||||
supplier?: string;
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
|
|
@ -56,15 +58,24 @@ type OrderSprintItem = {
|
|||
ai_item_insights?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
interface AiTopMatch {
|
||||
cost_price?: number;
|
||||
matched_description?: string;
|
||||
matched_item_id?: string;
|
||||
selling_price?: number;
|
||||
supplier_id?: string;
|
||||
}
|
||||
|
||||
export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProcessed }: Props) {
|
||||
// Selling comes from the order and is fixed — never edited here. We
|
||||
// capture it on mount via `item.selling_price` so the operator sees
|
||||
// the price the customer was quoted.
|
||||
const [supplierId, setSupplierId] = useState(item?.supplier || "");
|
||||
const [cost, setCost] = useState(item?.cost_price || 0);
|
||||
const [markup, setMarkup] = useState(item?.markup || 0);
|
||||
const [selling, setSelling] = useState(item?.selling_price || 0);
|
||||
// PO description — what the supplier sees on the PO. Mirrors the
|
||||
// canonical /orders/[id]/process gate: an item only transitions
|
||||
// pending → processed when supplier + cost + po_description are all
|
||||
// set, so the buyer-tasks Needs PO tab can find it.
|
||||
// qty that goes onto the PO. Defaults to order_qty (the customer's
|
||||
// requested qty from the original quote); the operator can adjust
|
||||
// for partial fills.
|
||||
const [qty, setQty] = useState(item?.order_qty ?? item?.quantity ?? 1);
|
||||
const [poDescription, setPoDescription] = useState(item?.po_description || item?.description || "");
|
||||
const [notes, setNotes] = useState(item?.notes || "");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
|
@ -76,6 +87,7 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
const [loadingSuppliers, setLoadingSuppliers] = useState(false);
|
||||
|
||||
const aiInsights = useMemo(() => item?.ai_item_insights ?? null, [item]);
|
||||
const selling = item?.selling_price ?? 0;
|
||||
|
||||
// Load order history and available suppliers when modal opens
|
||||
useEffect(() => {
|
||||
|
|
@ -158,53 +170,63 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
return () => { cancelled = true; };
|
||||
}, [open, item, suppliers]);
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = (value: string | number, label: string) => {
|
||||
navigator.clipboard.writeText(String(value));
|
||||
toast.success(`Copied ${label}`);
|
||||
};
|
||||
|
||||
// Pricing recompute
|
||||
const onCostChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setCost(value);
|
||||
setSelling(round2(value * (1 + markup / 100)));
|
||||
}, [markup]);
|
||||
|
||||
const onSellingChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setSelling(value);
|
||||
setCost(markup > -100 ? round2(value / (1 + markup / 100)) : 0);
|
||||
}, [markup]);
|
||||
|
||||
const onMarkupChange = useCallback((raw: string) => {
|
||||
const value = Number(raw) || 0;
|
||||
setMarkup(value);
|
||||
if (cost > 0) setSelling(round2(cost * (1 + value / 100)));
|
||||
}, [cost]);
|
||||
|
||||
// Apply supplier from history
|
||||
const applySupplier = (s: AvailableSupplier) => {
|
||||
setSupplierId(s.id);
|
||||
if (s.lastCost) {
|
||||
setCost(s.lastCost);
|
||||
setSelling(round2(s.lastCost * (1 + markup / 100)));
|
||||
}
|
||||
toast.success(`Applied supplier: ${s.name}`);
|
||||
};
|
||||
|
||||
// Apply from order history
|
||||
// Apply from order history — copy cost + selling as-is (selling is
|
||||
// already fixed on the order, so we keep the historical selling;
|
||||
// we only refresh cost if the item doesn't have one yet).
|
||||
const applyFromHistory = (h: OrderHistoryItem) => {
|
||||
const supplier = suppliers.find((s) => s.id === h.supplier);
|
||||
if (supplier) setSupplierId(supplier.id);
|
||||
if (h.costPrice) {
|
||||
setCost(h.costPrice);
|
||||
if (h.sellingPrice != null) setSelling(h.sellingPrice);
|
||||
if (h.markup != null) setMarkup(h.markup);
|
||||
}
|
||||
if (h.costPrice) setCost(h.costPrice);
|
||||
toast.success(`Applied from order ${h.orderNumber}`);
|
||||
};
|
||||
|
||||
// "Use AI suggestion" — read the top match from ai_item_insights and
|
||||
// set supplier (resolving the ID to the actual supplier name) + cost.
|
||||
const applyAiSuggestion = useCallback(() => {
|
||||
if (!aiInsights) {
|
||||
toast.error("No AI insights available for this item");
|
||||
return;
|
||||
}
|
||||
const matches = aiInsights.top_historical_matches;
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
toast.error("AI didn't return any supplier matches");
|
||||
return;
|
||||
}
|
||||
const top = matches[0] as AiTopMatch;
|
||||
const aiSupplierId = top.supplier_id;
|
||||
const aiCost = top.cost_price;
|
||||
|
||||
let resolvedName: string | null = null;
|
||||
if (aiSupplierId) {
|
||||
const supplier = suppliers.find((s) => s.id === aiSupplierId);
|
||||
if (supplier) {
|
||||
setSupplierId(supplier.id);
|
||||
resolvedName = supplier.suppliername || supplier.id;
|
||||
} else {
|
||||
// AI referenced a supplier ID that no longer exists — surface
|
||||
// the bad ID so the operator can see it instead of silently
|
||||
// dropping the suggestion.
|
||||
toast.warning(`AI suggested supplier ${aiSupplierId} but it's not in your suppliers list`);
|
||||
}
|
||||
}
|
||||
if (typeof aiCost === "number" && aiCost > 0) setCost(aiCost);
|
||||
|
||||
if (resolvedName || aiCost) {
|
||||
toast.success(
|
||||
`AI suggestion applied${resolvedName ? `: ${resolvedName}` : ""}${aiCost ? ` @ R${aiCost.toFixed(2)}` : ""}`,
|
||||
);
|
||||
}
|
||||
}, [aiInsights, suppliers]);
|
||||
|
||||
// Process
|
||||
const handleProcess = async () => {
|
||||
if (!item) return;
|
||||
|
|
@ -233,11 +255,20 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
? "processed"
|
||||
: currentStatus;
|
||||
|
||||
// markup is derived from the now-locked selling vs the editable
|
||||
// cost. We still write it so downstream reports (margin, etc.)
|
||||
// stay consistent.
|
||||
const computedMarkup =
|
||||
cost > 0 && selling > 0
|
||||
? round2(((selling - cost) / cost) * 100)
|
||||
: 0;
|
||||
|
||||
await pb.collection("order_items").update(item.id, {
|
||||
supplier: supplierId,
|
||||
cost_price: round2(cost),
|
||||
selling_price: round2(selling),
|
||||
markup: round2(markup),
|
||||
markup: computedMarkup,
|
||||
order_qty: qty,
|
||||
po_description: trimmedPoDescription,
|
||||
notes: notes,
|
||||
item_status: nextStatus,
|
||||
|
|
@ -261,49 +292,108 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
|
||||
if (!open || !item) return null;
|
||||
|
||||
const aiHasMatches =
|
||||
Array.isArray(aiInsights?.top_historical_matches) &&
|
||||
(aiInsights.top_historical_matches as unknown[]).length > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog" aria-modal="true" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl border-border/60">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b bg-gradient-to-b from-muted/40 to-transparent">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
<ShoppingCart className="h-4 w-4 text-primary" />
|
||||
Process Order Item
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-sm font-medium">{item.description || "(no description)"}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
|
||||
{/* AI Insights */}
|
||||
<div className="space-y-2">
|
||||
<button type="button" onClick={() => setAiInsightsExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
|
||||
{aiInsightsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
<Sparkles className="h-4 w-4 text-info" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-info">AI Insights</span>
|
||||
</button>
|
||||
<CardContent className="flex-1 overflow-y-auto space-y-5 p-5">
|
||||
{/* AI Insights — tinted section so it reads as distinct from
|
||||
the deterministic history below it. */}
|
||||
<section className="rounded-lg border border-info/20 bg-info/5 p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAiInsightsExpanded(v => !v)}
|
||||
aria-expanded={aiInsightsExpanded}
|
||||
className="flex items-center gap-2 text-left"
|
||||
>
|
||||
{aiInsightsExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-info/70" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-info/70" />
|
||||
)}
|
||||
<Sparkles className="h-4 w-4 text-info" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-info">
|
||||
AI Insights
|
||||
</span>
|
||||
{aiHasMatches && (
|
||||
<span className="ml-1 rounded-full bg-info/15 px-1.5 py-0.5 text-[10px] font-medium text-info">
|
||||
suggestion available
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={applyAiSuggestion}
|
||||
disabled={!aiHasMatches}
|
||||
className="border-info/30 text-info hover:bg-info/10 hover:text-info"
|
||||
title={aiHasMatches ? "Pull supplier + cost from AI's top match" : "No AI matches to apply"}
|
||||
>
|
||||
<Wand2 className="h-3 w-3 mr-1" />
|
||||
Use AI suggestion
|
||||
</Button>
|
||||
</div>
|
||||
{aiInsightsExpanded && (
|
||||
<div className="pl-6">{aiInsights ? <AiItemHistoryPopover insights={aiInsights} /> : <AiInsightsEmptyState title="No AI analysis" message="Process without AI suggestions." />}</div>
|
||||
<div className="pl-6">
|
||||
{aiInsights ? (
|
||||
<AiItemHistoryPopover insights={aiInsights} />
|
||||
) : (
|
||||
<AiInsightsEmptyState
|
||||
title="No AI analysis"
|
||||
message="Process without AI suggestions."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Order History */}
|
||||
<div className="space-y-2">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">Recent Orders ({orderHistory.length})</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Recent Orders ({orderHistory.length})
|
||||
</span>
|
||||
</div>
|
||||
{loadingHistory && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
{loadingHistory && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{orderHistory.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{orderHistory.slice(0, 5).map((h) => (
|
||||
<div key={h.id} className="flex items-center gap-3 p-2 rounded-lg border bg-muted/30 hover:bg-muted/50">
|
||||
<div
|
||||
key={h.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 bg-muted/20 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{h.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
|
||||
|
|
@ -312,89 +402,173 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
<span>R{h.costPrice?.toFixed(2) || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>R{h.sellingPrice?.toFixed(2) || "—"}</span>
|
||||
<span>·</span>
|
||||
<span>{h.markup}%</span>
|
||||
{h.markup != null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{h.markup}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="xs" variant="outline" onClick={() => applyFromHistory(h)}>Apply</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => applyFromHistory(h)}
|
||||
className="border-border/60"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : !loadingHistory && <p className="text-xs text-muted-foreground italic">No order history found.</p>}
|
||||
</div>
|
||||
) : (
|
||||
!loadingHistory && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No order history found.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Available Suppliers */}
|
||||
<div className="space-y-2">
|
||||
<button type="button" onClick={() => setSuppliersExpanded(v => !v)} className="flex items-center gap-2 w-full text-left">
|
||||
{suppliersExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
<section className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSuppliersExpanded(v => !v)}
|
||||
aria-expanded={suppliersExpanded}
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
>
|
||||
{suppliersExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Truck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">Available Suppliers ({availableSuppliers.length})</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Available Suppliers ({availableSuppliers.length})
|
||||
</span>
|
||||
</button>
|
||||
{suppliersExpanded && (
|
||||
<div className="pl-6">
|
||||
{loadingSuppliers ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" />Searching...</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Searching…
|
||||
</div>
|
||||
) : availableSuppliers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{availableSuppliers.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3 p-2 rounded-lg border hover:bg-muted/30">
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-lg border border-border/60 hover:bg-muted/30 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<Building2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium">{s.name}</div>
|
||||
{s.lastCost && <div className="text-[10px] text-muted-foreground">Last cost: R{s.lastCost.toFixed(2)}{s.brand && ` · ${s.brand}`}</div>}
|
||||
{s.lastCost && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Last cost: R{s.lastCost.toFixed(2)}
|
||||
{s.brand && ` · ${s.brand}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="xs" variant="outline" onClick={() => applySupplier(s)}>Use</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => applySupplier(s)}
|
||||
className="border-border/60"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-xs text-muted-foreground italic">No suppliers found in price list.</p>}
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No suppliers found in price list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="order-sprint-cost" className="text-xs">Cost (ZAR)</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(cost, "cost")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<Input id="order-sprint-cost" type="number" value={cost || ""} onChange={(e) => onCostChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
|
||||
{/* Pricing — qty + cost + selling (read-only). Selling is
|
||||
locked because it comes from the order; we still surface
|
||||
it so the operator sees the margin they're pricing. */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="order-sprint-qty" className="text-xs">
|
||||
PO Qty
|
||||
</Label>
|
||||
<Input
|
||||
id="order-sprint-qty"
|
||||
type="number"
|
||||
min={1}
|
||||
value={qty || ""}
|
||||
onChange={(e) => setQty(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="h-9 tabular-nums"
|
||||
placeholder="1"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
From order: {item.order_qty ?? item.quantity ?? 1}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="order-sprint-markup" className="text-xs">Markup %</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(markup, "markup")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<Input id="order-sprint-markup" type="number" value={markup || ""} onChange={(e) => onMarkupChange(e.target.value)} className="h-9 tabular-nums" placeholder="20" />
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="order-sprint-cost" className="text-xs">
|
||||
Cost (ZAR)
|
||||
</Label>
|
||||
<Input
|
||||
id="order-sprint-cost"
|
||||
type="number"
|
||||
value={cost || ""}
|
||||
onChange={(e) => setCost(Number(e.target.value) || 0)}
|
||||
className="h-9 tabular-nums"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="order-sprint-selling" className="text-xs">Selling (ZAR)</Label>
|
||||
<button type="button" onClick={() => copyToClipboard(selling, "selling")} className="text-muted-foreground hover:text-foreground"><Copy className="h-3 w-3" /></button>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs flex items-center gap-1">
|
||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||
Selling (ZAR)
|
||||
</Label>
|
||||
<div
|
||||
className="flex h-9 items-center rounded-md border border-dashed border-border/60 bg-muted/30 px-3 text-sm tabular-nums text-foreground"
|
||||
aria-label="Selling price (from order, read-only)"
|
||||
title="Selling comes from the order and is not editable here"
|
||||
>
|
||||
{selling > 0 ? `R${selling.toFixed(2)}` : <span className="text-muted-foreground italic">not set</span>}
|
||||
</div>
|
||||
<Input id="order-sprint-selling" type="number" value={selling || ""} onChange={(e) => onSellingChange(e.target.value)} className="h-9 tabular-nums" placeholder="0.00" />
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Info className="h-2.5 w-2.5" />
|
||||
From order · not editable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Supplier */}
|
||||
<div className="space-y-1.5">
|
||||
<section className="space-y-1.5">
|
||||
<Label className="text-xs">Supplier</Label>
|
||||
<SupplierSearch value={supplierId} onValueChange={setSupplierId} suppliers={suppliers} placeholder="Pick the supplier..." ariaLabel="Supplier" />
|
||||
</div>
|
||||
<SupplierSearch
|
||||
value={supplierId}
|
||||
onValueChange={setSupplierId}
|
||||
suppliers={suppliers}
|
||||
placeholder="Pick the supplier…"
|
||||
ariaLabel="Supplier"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* PO description — what the supplier sees on the PO. Defaults
|
||||
to the order description so the buyer-tasks pending →
|
||||
processed transition fires for the common no-op case.
|
||||
Mirrors the canonical /orders/[id]/process gate. */}
|
||||
<div className="space-y-1.5">
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">PO description</Label>
|
||||
{poDescription.trim() !== (item.description || "").trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPoDescription(item.description || "")}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
↺ reset to order description
|
||||
</button>
|
||||
|
|
@ -407,21 +581,47 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
|
|||
className="text-sm"
|
||||
placeholder="Description the supplier sees on the PO"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-1.5">
|
||||
<section className="space-y-1.5">
|
||||
<Label className="text-xs">Notes</Label>
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="text-sm" placeholder="Internal notes..." />
|
||||
</div>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
placeholder="Internal notes…"
|
||||
/>
|
||||
</section>
|
||||
</CardContent>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose} disabled={processing}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleProcess} disabled={processing}>
|
||||
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
Process Item
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-5 py-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{item.item_status === "pending"
|
||||
? "Save marks this item processed and surfaces it on the buyer-tasks Needs PO tab."
|
||||
: `Current status: ${item.item_status ?? "pending"}. Save keeps it there.`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleProcess}
|
||||
disabled={processing}
|
||||
className="bg-success text-success-foreground hover:bg-success/90 shadow-sm"
|
||||
>
|
||||
{processing && <Loader2 className="h-3 w-3 mr-1 animate-spin" />}
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
Process Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -446,4 +646,4 @@ interface AvailableSupplier {
|
|||
lastCost?: number;
|
||||
brand?: string;
|
||||
timesBought?: number;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue