feat(sprint-modal): show markup + order desc hint + explicit Show/Hide

Three small additions to the process modal:

- Markup cell added to the pricing grid (4 cols now: PO Qty, Cost,
  Selling, Markup). Read-only and dashed like Selling — derived from
  selling/cost so the operator sees the margin they're pricing at.
  Green when selling ≥ cost, destructive when selling < cost.

- Above the PO description textarea, the order's original item
  description is now shown as a read-only reference hint. Lets the
  operator see what the customer asked for vs what they're sending
  to the supplier without having to scroll back to the modal header.

- AI Insights toggle now has an explicit "Show" / "Hide" label next
  to the chevron so the affordance is obvious. The chevron direction
  was the only signal before; the label makes the toggle
  self-documenting.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 14:54:02 +02:00
parent b9dfec8d28
commit 20b57ce2bb
2 changed files with 80 additions and 6 deletions

View file

@ -282,6 +282,31 @@ describe("OrderSprintProcessModal", () => {
});
});
it("shows the computed markup percentage next to the locked selling", () => {
// baseItem.selling_price = 50; cost starts at 0 → markup shows '—'.
renderModal();
expect(screen.getByLabelText(/markup/i).textContent).toMatch(/—/);
// Set cost → markup should turn into "100.0%".
return setField("Cost (ZAR)", "25").then(() => {
expect(screen.getByLabelText(/markup/i).textContent).toMatch(/100\.0%/);
});
});
it("shows the order description as a read-only hint above the PO description", () => {
renderModal();
// Order description should render above the textarea (read-only).
expect(
screen.getByLabelText(/order description \(read-only\)/i),
).toBeInTheDocument();
expect(
screen.getByLabelText(/order description \(read-only\)/i),
).toHaveTextContent("HSS drill bit");
// The PO description textarea is still editable below it.
expect(
screen.getByPlaceholderText("Description the supplier sees on the PO"),
).toBeInTheDocument();
});
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" } } });

View file

@ -334,7 +334,7 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
type="button"
onClick={() => setAiInsightsExpanded(v => !v)}
aria-expanded={aiInsightsExpanded}
className="flex items-center gap-2 text-left"
className="flex items-center gap-2 text-left group/ai-toggle"
>
{aiInsightsExpanded ? (
<ChevronUp className="h-4 w-4 text-warn/70" />
@ -350,6 +350,9 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
suggestion available
</span>
)}
<span className="ml-2 text-[10px] font-medium text-warn-foreground/70 group-hover/ai-toggle:text-warn-foreground underline-offset-2 group-hover/ai-toggle:underline transition-colors">
{aiInsightsExpanded ? "Hide" : "Show"}
</span>
</button>
<Button
size="xs"
@ -495,10 +498,11 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
)}
</section>
{/* 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">
{/* Pricing — qty + cost + selling (read-only) + markup
(derived). Selling is locked because it comes from the
order; markup is derived from selling/cost so the
operator sees the margin they're pricing at. */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label htmlFor="order-sprint-qty" className="text-xs">
PO Qty
@ -546,6 +550,34 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
From order · not editable
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs flex items-center gap-1">
<Lock className="h-3 w-3 text-muted-foreground" />
Markup
</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"
aria-label="Markup (derived from cost and selling, read-only)"
title="Derived from selling vs cost"
>
{cost > 0 && selling > 0 ? (
<span
className={cn(
"font-medium",
selling >= cost ? "text-emerald-700 dark:text-emerald-400" : "text-destructive",
)}
>
{(((selling - cost) / cost) * 100).toFixed(1)}%
</span>
) : (
<span className="text-muted-foreground italic">—</span>
)}
</div>
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Info className="h-2.5 w-2.5" />
Derived · not editable
</p>
</div>
</section>
{/* Supplier */}
@ -563,7 +595,11 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
{/* 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. */}
Mirrors the canonical /orders/[id]/process gate. The
order's original item description is shown read-only
above the textarea as a reference, so the operator can
see what the customer asked for vs what they're sending
to the supplier. */}
<section className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">PO description</Label>
@ -577,6 +613,19 @@ export function OrderSprintProcessModal({ item, suppliers, open, onClose, onProc
</button>
)}
</div>
{item.description && (
<div
className="rounded-md border border-dashed border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
aria-label="Order description (read-only)"
>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Order description
</span>
<div className="mt-0.5 text-foreground/80 italic">
{item.description}
</div>
</div>
)}
<Textarea
value={poDescription}
onChange={(e) => setPoDescription(e.target.value)}