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:
SJCoetz 2026-07-30 14:07:58 +02:00
parent 6093a749eb
commit 5f039ea8cd
2 changed files with 455 additions and 113 deletions

View file

@ -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(
"",
);
});
});
});

View file

@ -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;
}
}