WFDASH-WEBAPP/src/components/orders-review-modal.tsx
SJCoetz ba1f916102 feat(orders): show From + Subject in review modal Source of truth header
The Source of truth panel previously only showed the email subject and
body inside a collapsed-by-default toggle. Operators couldn't see who
sent the mail or what the subject was without expanding the body.

This change adds a persistent header above the collapsible body with
two always-visible lines:

  From:     jane@acme.test    (AtSign icon)
  Subject:  RFQ for 10x bolts  (Mail icon)

Sender resolution chain (in getOrderReviewData):

  1. order.email_sender  — n8n ingest snapshot of the actual From:
                           on the inbound RFQ (best signal)
  2. quote.email_sender  — covers pre-snapshot legacy orders
  3. customer.email      — last-resort fallback
  4. ''                  — renders as '(unknown sender)' placeholder

Subject already lived on the same record; the header just promotes it
out of the collapsed body so it's visible without expanding. The
existing '(no subject)' / '(no body)' placeholder pattern is matched
with a new '(unknown sender)' placeholder.

Tests: 7 new (3 modal header, 4 service sender fallback chain). 101
passing total.

Co-authored-by: Orca <help@stably.ai>
2026-08-05 16:09:53 +02:00

1438 lines
57 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
AtSign,
Check,
ChevronDown,
ChevronRight,
ExternalLink,
Loader2,
Mail,
Paperclip,
Pencil,
Plus,
Trash2,
Undo2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CustomerSearch } from "@/components/ui/customer-search";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { formatCurrency } from "@/lib/pb-helpers";
import type {
Company,
Customer,
Order,
OrderItem,
QuoteAttachment,
} from "@/lib/types";
/**
* Orders review modal — Task 4 scaffold.
*
* Renders an order's header (order_number, customer, company, rep) and
* editable line items on the left, with the source-of-truth panel on
* the right. The preview chain (email body + attachments) lands in
* Task 5; this version ships the header + items + dirty-state gate +
* save flow (with inline 409 collision error rendering).
*
* The save flow posts a single patch to `/api/v1/orders/{id}/review-save`
* — see the route for the request shape and error envelope.
*/
const round2 = (n: number) => Math.round(n * 100) / 100;
type Lock = "cost" | "selling";
export interface OrdersReviewModalProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
order: Order;
items: OrderItem[];
customers: Customer[];
customer: Customer;
company: Company | null;
quoteEmail: { sender: string; subject: string; body: string };
attachments: QuoteAttachment[];
}
interface ItemDraft {
description: string;
quantity: number | null;
selling_price: number | null;
cost_price: number | null;
markup: number | null;
lock: Lock;
/**
* True only for rows that the operator added in this session and
* which haven't yet been persisted to PB. On save, the modal posts
* these rows to the create endpoint and re-keys the draft state
* with the returned PB IDs.
*/
isNew: boolean;
/**
* True when the operator has flagged the row for removal via the
* per-row trash button. On save, the modal posts the IDs to the
* soft-flag endpoint (which sets `item_status = "removed"`). The
* row stays in the list until the save succeeds so the operator
* can undo.
*/
pendingRemoval: boolean;
}
function initialItemDraft(it: OrderItem): ItemDraft {
return {
description: it.description || "",
quantity: it.quantity,
selling_price: it.selling_price,
cost_price: it.cost_price,
markup: it.markup ?? null,
lock:
it.cost_price && it.cost_price > 0 ? "cost" : "selling",
isNew: false,
pendingRemoval: false,
};
}
/**
* Sentinel ID prefix for in-session new rows. The double-underscore
* shape makes it impossible to collide with a real PB id (alphanumeric
* only). Negative numbers are also fine but the operator-visible
* structure of "looks like a placeholder" lands better with a string.
*/
const NEW_ITEM_ID_PREFIX = "__new_";
export function OrdersReviewModal({
open,
onClose,
onSaved,
order,
items,
customers,
customer,
company,
quoteEmail,
attachments,
}: OrdersReviewModalProps) {
// Persisted baseline — what the server currently holds. Internal
// state, NOT the prop, so a partial-failure save can rebase here
// (e.g. so the dirty check treats already-saved fields as clean,
// and a retry doesn't resend rows that committed).
const [persistedOrder, setPersistedOrder] = useState(order);
const [persistedItems, setPersistedItems] = useState(items);
const [orderNumber, setOrderNumber] = useState(order.order_number);
const [customerId, setCustomerId] = useState(order.customer);
// Customer-cascade: when the operator changes the customer, the
// Company + Rep labels are derived from the selected customer's
// `expand.company` and `expand.rep` relations. The selected
// customer's `company` id is also written back to the order on save
// (along with the rep's display name). useMemo here — not useEffect
// — so the label updates during the same render as the dropdown
// change, with no extra renders.
const selectedCustomer = useMemo(
() => customers.find((c) => c.id === customerId) ?? null,
[customers, customerId],
);
const cascadeCompanyName = selectedCustomer?.expand?.company?.company ?? "";
const cascadeRepName =
selectedCustomer?.expand?.rep?.name ??
selectedCustomer?.expand?.rep?.email ??
"";
const [itemDrafts, setItemDrafts] = useState<Record<string, ItemDraft>>(
() =>
Object.fromEntries(
items.map((it) => [it.id, initialItemDraft(it)]),
),
);
// (initial drafts seeded from the prop; the effect on open re-seeds
// from persistedItems — same data, just explicit.)
const [numberError, setNumberError] = useState<string | null>(null);
const [collidingOrderId, setCollidingOrderId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Per-item failure detail from the most recent partial-failure
// save. The list of IDs is also rendered next to the items table.
const [partialFailures, setPartialFailures] = useState<
Array<{ id: string; error: string }>
>([]);
// Edit-mode gate for the items table. Items are read-only by default;
// clicking the "Edit items" button pops a confirmation dialog, and on
// confirm we flip this on. Reset to false on modal close so re-opening
// the modal always starts in read-only mode (defence in depth — parent
// already remounts via `key={order.id}`, but we don't want stale
// editing state to leak between orders if the key is ever forgotten).
const [editingItems, setEditingItems] = useState(false);
// Whether the confirmation dialog is currently shown. Distinct from
// `editingItems` because the dialog is its own modal surface.
const [confirmingEdit, setConfirmingEdit] = useState(false);
// Monotonic counter for in-session new item sentinel IDs. `useRef`
// so re-renders don't reset the counter — every Add-item click
// must yield a unique sentinel.
const newItemCounterRef = useRef(0);
// Per-row failure detail for items the server tried to add or soft-
// flag but couldn't. Mirrors `partialFailures` (updates) but for
// the new endpoints. Each entry is keyed by the row's sentinel ID
// (new) or real PB ID (remove) so the modal can correlate the
// error with the visible row.
const [addFailures, setAddFailures] = useState<
Array<{ id: string; error: string }>
>([]);
const [removeFailures, setRemoveFailures] = useState<
Array<{ id: string; error: string }>
>([]);
// Re-seed local state when the modal opens for a different order.
// Parent already remounts via `key={order.id}`, but defensively
// re-seed here too in case anyone forgets the key.
useEffect(() => {
if (!open) return;
setPersistedOrder(order);
setPersistedItems(items);
setOrderNumber(order.order_number);
setCustomerId(order.customer);
setItemDrafts(
Object.fromEntries(items.map((it) => [it.id, initialItemDraft(it)])),
);
setNumberError(null);
setCollidingOrderId(null);
setPartialFailures([]);
setAddFailures([]);
setRemoveFailures([]);
setEditingItems(false);
setConfirmingEdit(false);
newItemCounterRef.current = 0;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, order.id]);
const dirty = useMemo(() => {
if (orderNumber !== persistedOrder.order_number) return true;
if (customerId !== persistedOrder.customer) return true;
// Company cascades from the selected customer; the modal only
// writes it when the new value differs from the persisted one
// (matching the save-body guard below).
if (
selectedCustomer?.company &&
selectedCustomer.company !== persistedOrder.company
)
return true;
// Rep cascades as the rep's display name; only considered dirty
// when we have a new name AND it differs from the persisted text.
if (
cascadeRepName &&
cascadeRepName !== (persistedOrder.rep ?? "")
)
return true;
for (const it of persistedItems) {
const d = itemDrafts[it.id];
if (!d) continue;
// A row flagged for removal is dirty by definition — even if
// none of the price fields changed, the removal has to be
// persisted.
if (d.pendingRemoval) return true;
if (d.description !== (it.description || "")) return true;
if (d.quantity !== it.quantity) return true;
if (d.selling_price !== it.selling_price) return true;
if (d.cost_price !== it.cost_price) return true;
// NOTE: markup is deliberately not compared here. The save body
// only carries quantity/selling_price/cost_price/description; the
// service layer recomputes markup server-side from the
// selling/cost pair. At the review step the operator doesn't edit
// markup directly, but onSellingChange recomputes cost from
// selling+markup, so a cost change here IS captured above.
}
// Any new draft row (one with no matching persistedItem) is dirty
// by default — it must be POSTed to the create endpoint.
for (const [id, d] of Object.entries(itemDrafts)) {
if (d.isNew) return true;
// Defensive: an orphan draft (row was in itemDrafts but not in
// persistedItems) is also dirty so we don't silently drop it.
if (!persistedItems.some((it) => it.id === id)) return true;
}
return false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
orderNumber,
customerId,
selectedCustomer?.company,
cascadeRepName,
itemDrafts,
persistedOrder.id,
persistedOrder.company,
persistedOrder.rep,
]);
// Header totals — read from the live draft state so the totals
// update as the operator edits. New rows are included with whatever
// values the operator has typed in. Rows flagged for removal are
// still counted (they're still on the order until the save lands);
// if the operator changes their mind before saving and un-flags,
// the totals stay consistent.
//
// Cost is intentionally NOT surfaced as a header total — the
// review modal no longer edits cost or markup, so showing a cost
// total here would be misleading (the cost values are placeholders
// until the order moves through processing). The selling total is
// the operator's "our total" against which the quote email is
// verified.
const headerTotals = useMemo(() => {
let selling = 0;
let count = 0;
for (const it of persistedItems) {
const d = itemDrafts[it.id];
if (!d) continue;
const qty = d.quantity ?? 0;
selling += qty * (d.selling_price ?? 0);
count += 1;
}
for (const [, d] of Object.entries(itemDrafts)) {
if (!d.isNew) continue;
const qty = d.quantity ?? 0;
selling += qty * (d.selling_price ?? 0);
count += 1;
}
return { selling, count };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemDrafts, persistedItems.map((it) => it.id).join("|")]);
const updateItemDraft = useCallback(
(id: string, patch: Partial<ItemDraft>) => {
setItemDrafts((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));
},
[],
);
// Add a fresh draft row to the in-session items list. The sentinel
// id is unique per click so the row can be re-keyed on the resulting
// PB id after save. We start with all-zero numeric fields so the
// markup/selling/cost lock pattern has a defined starting state.
const addItem = useCallback(() => {
const id = `${NEW_ITEM_ID_PREFIX}${++newItemCounterRef.current}`;
setItemDrafts((prev) => ({
...prev,
[id]: {
description: "",
quantity: 1,
cost_price: 0,
markup: 0,
selling_price: 0,
lock: "cost",
isNew: true,
pendingRemoval: false,
},
}));
}, []);
// Toggle the soft-flag on a row. When the operator clicks the trash
// icon once, the row enters pending-removal state (disabled inputs,
// strikethrough) and the row id is queued for the soft-flag endpoint
// on save. Clicking again (now showing as an Undo/Restore button)
// un-flags the row.
const toggleRemoveItem = useCallback((id: string) => {
setItemDrafts((prev) => {
const cur = prev[id];
if (!cur) return prev;
return { ...prev, [id]: { ...cur, pendingRemoval: !cur.pendingRemoval } };
});
}, []);
// Selling is the only mutable price field at the review step — the
// server-side cost/markup recompute happens later, during order
// processing. We still update the cost/markup on the local draft
// (server-side compatibility) so the lock-pattern data model
// remains intact, but the UI doesn't surface those inputs.
const onSellingChange = (id: string, raw: string) => {
const value = Number(raw) || 0;
const d = itemDrafts[id];
if (!d) return;
const newCost =
(d.markup ?? 0) > -100 ? round2(value / (1 + (d.markup ?? 0) / 100)) : 0;
updateItemDraft(id, {
selling_price: value,
cost_price: newCost,
lock: "selling",
});
};
const onQuantityChange = (id: string, raw: string) => {
const value = raw === "" ? null : Number(raw);
updateItemDraft(id, { quantity: value });
};
const onDescriptionChange = (id: string, raw: string) => {
updateItemDraft(id, { description: raw });
};
const handleSave = async () => {
setSaving(true);
setNumberError(null);
setCollidingOrderId(null);
setAddFailures([]);
setRemoveFailures([]);
const body: {
order_number?: string;
customer?: string;
company?: string;
rep?: string;
items?: Array<{
id: string;
description?: string | null;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
}>;
itemsToAdd?: Array<{
description: string;
quantity: number | null;
cost_price: number;
markup: number;
selling_price: number;
}>;
itemsToRemove?: string[];
} = {};
if (orderNumber !== persistedOrder.order_number) body.order_number = orderNumber;
if (customerId !== persistedOrder.customer) body.customer = customerId;
// Cascade company from the selected customer. Skipped when the
// customer is cleared (we'd then be sending `company: ""` which PB
// would 400 on because the orders.company field is required).
if (
selectedCustomer?.company &&
selectedCustomer.company !== persistedOrder.company
) {
body.company = selectedCustomer.company;
}
// Cascade rep from the selected customer's expand.rep. Skipped
// when no rep on the new customer (so an order that previously
// had a rep keeps it).
if (
selectedCustomer?.expand?.rep &&
cascadeRepName &&
cascadeRepName !== (persistedOrder.rep ?? "")
) {
body.rep = cascadeRepName;
}
const dirtyItems = persistedItems
.map((it) => {
const d = itemDrafts[it.id];
if (!d) return null;
// Rows flagged for removal are not price-edited on this
// save — the soft-flag endpoint handles them. Skip the
// price patch so the server doesn't try to write a row it's
// about to flag.
if (d.pendingRemoval) return null;
const changed: {
id: string;
description?: string | null;
quantity?: number | null;
selling_price?: number | null;
cost_price?: number | null;
} = { id: it.id };
let isDirty = false;
if (d.description !== (it.description || "")) {
changed.description = d.description;
isDirty = true;
}
if (d.quantity !== it.quantity) {
changed.quantity = d.quantity;
isDirty = true;
}
if (d.selling_price !== it.selling_price) {
changed.selling_price = d.selling_price;
isDirty = true;
}
if (d.cost_price !== it.cost_price) {
changed.cost_price = d.cost_price;
isDirty = true;
}
return isDirty ? changed : null;
})
.filter((x): x is NonNullable<typeof x> => x !== null);
if (dirtyItems.length > 0) body.items = dirtyItems;
// Bundle any in-session new rows for the create endpoint. The
// sentinel id is dropped from the wire payload since the server
// supplies the real id on response.
const itemsToAdd = Object.entries(itemDrafts)
.filter(([, d]) => d.isNew)
.map(([, d]) => ({
description: d.description,
quantity: d.quantity,
cost_price: d.cost_price ?? 0,
markup: d.markup ?? 0,
selling_price: d.selling_price ?? 0,
}));
if (itemsToAdd.length > 0) body.itemsToAdd = itemsToAdd;
// Bundle any rows flagged for removal. Persisted rows only — new
// rows that the operator hasn't yet committed can be dropped by
// simply deleting the sentinel from the draft state below.
const itemsToRemove = persistedItems
.filter((it) => itemDrafts[it.id]?.pendingRemoval)
.map((it) => it.id);
if (itemsToRemove.length > 0) body.itemsToRemove = itemsToRemove;
try {
const res = await fetch(
`/api/v1/orders/${order.id}/review-save`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (res.status === 409) {
const err = await res.json();
setNumberError(
`${err.attemptedNumber} is already used by another order.`,
);
setCollidingOrderId(err.collidingOrderId);
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Save failed (HTTP ${res.status})`);
}
const result = (await res.json()) as {
order?: typeof order;
items?: { updated: number; failed: Array<{ id: string; error: string }> };
itemsAdded?: {
created: Array<{ id: string; description: string; quantity: number | null; cost_price: number; selling_price: number; markup: number }>;
failed: Array<{ id: string; error: string }>;
};
itemsRemoved?: {
softFlagged: string[];
failed: Array<{ id: string; error: string }>;
};
};
// Update the persisted baseline with the freshly-returned order
// so the dirty check + future saves treat the saved fields as
// clean (and don't resend them on retry).
if (result.order) {
setPersistedOrder(result.order);
}
// Always clear the "removed" rows from the visible list — the
// server either soft-flagged them (success) or it doesn't
// matter (we'll re-try on save). The success path lets the
// modal close; the failure path keeps the row in place so the
// operator can retry (and the row re-renders as not-removed
// because the draft's pendingRemoval flag is still true, which
// we treat as an in-session hint — but the rendering branch
// filters out rows whose persistedItem is no longer present).
//
// Simpler invariant: drop soft-flagged rows from both
// persistedItems and itemDrafts on save. If the server failed
// to flag a row, the operator can re-open the modal and the
// reload of `items` will re-hydrate it.
const removedIds = new Set(
result.itemsRemoved?.softFlagged ?? [],
);
const newPersistedItems =
removedIds.size > 0
? persistedItems.filter((it) => !removedIds.has(it.id))
: persistedItems;
// Re-key the in-session new drafts with the PB ids the server
// returned. We pair them by order — `itemsAdded.created` is in
// the same order as the `itemsToAdd` we sent. The draft's
// flag is cleared so future saves treat the row as an existing
// item (a price edit, not a create).
const newlyCreatedIds = result.itemsAdded?.created ?? [];
const newDrafts: Record<string, ItemDraft> = {};
let newIdx = 0;
for (const [id, d] of Object.entries(itemDrafts)) {
if (d.isNew) {
const created = newlyCreatedIds[newIdx++];
if (created) {
// Re-key under the real PB id with isNew clear.
newDrafts[created.id] = {
...d,
isNew: false,
};
// And add the persisted item backing the row.
// (Spread + force-replace if any prior state existed.)
if (!newPersistedItems.some((it) => it.id === created.id)) {
newPersistedItems.push({
...created,
order: order.id,
collectionId: "",
collectionName: "",
created: new Date().toISOString(),
updated: new Date().toISOString(),
po_description: created.description,
supplier: "",
markup: created.markup,
purchase_order: "",
item_status: "pending",
in_stock: false,
notes: "",
} as OrderItem);
}
}
// If created is undefined (server-side failure for this
// row), drop the draft — the operator will re-add if they
// want to retry. Less common is to keep the sentinel so
// the operator can see the failure; we keep it but the
// row renders via the orphan-draft branch.
} else if (removedIds.has(id)) {
// Drop removed rows from the draft map so the list
// doesn't re-render them.
continue;
} else {
newDrafts[id] = d;
}
}
// Update partial-failure UI blocks.
const updateFailed = result.items?.failed ?? [];
const addFailed = result.itemsAdded?.failed ?? [];
const removeFailed = result.itemsRemoved?.failed ?? [];
setPartialFailures(updateFailed);
setAddFailures(addFailed);
setRemoveFailures(removeFailed);
// Re-anchor the persisted baseline + drafts. We do this for
// both full-success and partial-failure paths; the failure
// bits are already exposed above and the failed drafts stay
// dirty (the dirty calc considers any pendingRemoval flag).
setPersistedItems(newPersistedItems);
setItemDrafts(newDrafts);
const totalFailures =
updateFailed.length + addFailed.length + removeFailed.length;
if (totalFailures > 0) {
const totalSucceeded =
(result.items?.updated ?? 0) +
(result.itemsAdded?.created?.length ?? 0) +
(result.itemsRemoved?.softFlagged?.length ?? 0);
toast.error(
`Saved ${totalSucceeded} change(s), ${totalFailures} failed`,
);
return;
}
toast.success("Saved");
onSaved();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
// Esc closes without saving (no confirm-discard in v1; dirty work is
// small and the operator can re-open to retry).
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !saving) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, saving, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="orders-review-title"
onClick={(e) => {
if (e.target === e.currentTarget && !saving) onClose();
}}
>
<Card className="w-full max-w-5xl max-h-[90vh] flex flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0 border-b">
<div className="min-w-0 flex-1">
<CardTitle
id="orders-review-title"
className="text-sm flex items-center gap-2"
>
<AlertTriangle className="h-4 w-4 text-warn-foreground" />
Review order
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
Verify AI-populated fields against the source email &amp;
attachments.
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={saving}
aria-label="Close"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
</CardHeader>
<CardContent className="flex-1 overflow-y-auto space-y-4 p-4">
{/* Header strip */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Order number</Label>
<Input
value={orderNumber}
onChange={(e) => {
setOrderNumber(e.target.value);
setNumberError(null);
setCollidingOrderId(null);
}}
className={cn(
"h-9 font-mono text-sm",
numberError && "border-destructive focus-visible:ring-destructive/40",
)}
data-testid="review-order-number"
/>
{numberError && (
<p
className="text-[11px] text-destructive flex items-center gap-1"
data-testid="review-order-number-error"
>
<AlertTriangle className="h-3 w-3" />
{numberError}
{collidingOrderId && (
<a
href={`/orders/${collidingOrderId}`}
className="underline hover:no-underline inline-flex items-center gap-0.5"
>
open existing <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<CustomerSearch
value={customerId}
onValueChange={setCustomerId}
customers={customers}
placeholder="Pick a customer…"
/>
<p className="text-[11px] text-muted-foreground">
{/* Reflect the CURRENT selection, not the originally-
fetched `customer` prop — otherwise switching the
combobox leaves a stale label below the box. Fall
back to the prop when the lookup misses (defensive
— should never happen in normal flow since the
combobox only emits values from `customers`). */}
{(() => {
const selected = customers.find((c) => c.id === customerId);
const name = selected?.customer ?? customer.customer;
return name || "(no customer name)";
})()}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Company</Label>
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
{cascadeCompanyName || company?.company || "(unknown)"}
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Rep</Label>
<p className="h-9 inline-flex items-center px-3 rounded-md border bg-muted/30 text-sm">
{cascadeRepName || persistedOrder.rep || "—"}
</p>
</div>
</div>
{/* Items table (left side) + preview placeholder (right side).
The preview region is filled in by Task 5. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-2 gap-2">
<div className="flex items-baseline gap-3 min-w-0">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Order items
</h3>
{/* Tier 1 header totals — count + selling total.
Reads from the live draft state so dirty edits
update the totals live. Kept on a single subdued
line so the title stays the visual anchor. Cost
is intentionally omitted because the review modal
no longer surfaces cost/markup as editable
fields. */}
<p
className="text-xs text-muted-foreground tabular-nums truncate"
data-testid="review-items-totals"
>
{headerTotals.count} item
{headerTotals.count === 1 ? "" : "s"} · Selling{" "}
{formatCurrency(headerTotals.selling)}
</p>
</div>
{/* Edit gate — items are read-only until the operator
confirms via the dialog. The button label flips
between "Edit items" and "Stop editing" so the
operator has a clear exit. Clicking while in edit
mode skips the confirmation (no data loss to
confirm against; save still has its own gate). */}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
if (editingItems) {
setEditingItems(false);
} else {
setConfirmingEdit(true);
}
}}
data-testid="review-edit-items-btn"
aria-label={editingItems ? "Stop editing items" : "Edit items"}
>
<Pencil className="h-3 w-3 mr-1" />
{editingItems ? "Stop editing" : "Edit items"}
</Button>
</div>
{(() => {
// Render list — persisted rows first (in their
// persisted order), then any in-session new rows. New
// rows that the operator has flagged for removal are
// still rendered (with reduced opacity + strikethrough)
// so the operator can undo before saving. Persisted
// rows pending removal are also rendered so the undo
// button is reachable — the row will only disappear
// after a successful save.
const newDraftEntries = Object.entries(itemDrafts).filter(
([, d]) => d.isNew,
);
const visiblePersisted = persistedItems;
const totalRows = visiblePersisted.length + newDraftEntries.length;
if (totalRows === 0) {
return (
<p className="text-xs text-muted-foreground italic">
No line items on this order.
</p>
);
}
return (
<>
<ul
className={cn(
"divide-y rounded-md border bg-card",
editingItems && "ring-1 ring-warn/40 bg-warn/5",
)}
data-testid="review-items-list"
data-editing={editingItems ? "true" : "false"}
>
{visiblePersisted.map((it) => {
const d = itemDrafts[it.id];
if (!d) return null;
return (
<li
key={it.id}
data-testid={`review-item-row-${it.id}`}
data-pending-removal={d.pendingRemoval ? "true" : "false"}
className={cn(
"p-3 space-y-2",
d.pendingRemoval && "opacity-60",
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{editingItems ? (
<Input
value={d.description}
onChange={(e) =>
onDescriptionChange(it.id, e.target.value)
}
placeholder="Item description"
disabled={saving || d.pendingRemoval}
data-testid="review-item-description"
className={cn(
"h-8 text-xs",
d.pendingRemoval && "line-through",
)}
/>
) : (
<p
className={cn(
"text-sm font-medium truncate",
d.pendingRemoval && "line-through opacity-60",
)}
>
{d.description || it.description || "(no description)"}
</p>
)}
</div>
{editingItems && (
d.pendingRemoval ? (
<button
type="button"
onClick={() => toggleRemoveItem(it.id)}
aria-label="Restore item"
data-testid="review-restore-item-btn"
className="inline-flex items-center justify-center rounded-md p-1 text-warn-foreground hover:bg-accent hover:text-foreground"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={() => toggleRemoveItem(it.id)}
aria-label="Flag for removal"
data-testid="review-remove-item-btn"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Qty
</Label>
<Input
type="number"
value={d.quantity ?? ""}
onChange={(e) =>
onQuantityChange(it.id, e.target.value)
}
disabled={!editingItems || saving || d.pendingRemoval}
className="h-8 text-xs tabular-nums"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Selling
</Label>
<Input
type="number"
value={d.selling_price ?? ""}
onChange={(e) =>
onSellingChange(it.id, e.target.value)
}
disabled={!editingItems || saving || d.pendingRemoval}
className="h-8 text-xs tabular-nums"
/>
</div>
</div>
</li>
);
})}
{newDraftEntries.map(([id, d]) => {
const removed = d.pendingRemoval;
return (
<li
key={id}
data-testid={`review-item-row-${id}`}
data-new="true"
data-pending-removal={removed ? "true" : "false"}
className={cn(
"p-3 space-y-2 border-l-2 border-accent bg-accent/5",
removed && "opacity-60",
)}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-1">
<Input
value={d.description}
onChange={(e) =>
onDescriptionChange(id, e.target.value)
}
placeholder="New item description"
disabled={!editingItems || saving || removed}
data-testid="review-new-item-description"
className={cn(
"h-8 text-xs",
removed && "line-through",
)}
/>
<span className="inline-block text-[10px] font-medium uppercase tracking-wide text-accent-foreground bg-accent/40 px-1.5 py-0.5 rounded">
New
</span>
</div>
{editingItems && (
removed ? (
<button
type="button"
onClick={() => toggleRemoveItem(id)}
aria-label="Restore new item"
data-testid="review-restore-item-btn"
className="inline-flex items-center justify-center rounded-md p-1 text-warn-foreground hover:bg-accent hover:text-foreground"
>
<Undo2 className="h-3.5 w-3.5" />
</button>
) : (
<button
type="button"
onClick={() => toggleRemoveItem(id)}
aria-label="Discard new item"
data-testid="review-remove-item-btn"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Qty
</Label>
<Input
type="number"
value={d.quantity ?? ""}
onChange={(e) => onQuantityChange(id, e.target.value)}
disabled={!editingItems || saving || removed}
className="h-8 text-xs tabular-nums"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
Selling
</Label>
<Input
type="number"
value={d.selling_price ?? ""}
onChange={(e) => onSellingChange(id, e.target.value)}
disabled={!editingItems || saving || removed}
className="h-8 text-xs tabular-nums"
/>
</div>
</div>
</li>
);
})}
</ul>
{editingItems && (
<div className="mt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="h-7 px-2 text-xs"
data-testid="review-add-item-btn"
aria-label="Add new item"
>
<Plus className="h-3 w-3 mr-1" />
Add item
</Button>
</div>
)}
</>
);
})()}
{/* Partial-failure summary — only render when the most
recent save had row-level failures so the operator
can see which IDs to retry. Each ID is rendered with
the server's error string underneath. Three sections
for updates / adds / removes — same shape, distinct
labels so the operator can tell which endpoint
failed. */}
{(partialFailures.length > 0 ||
addFailures.length > 0 ||
removeFailures.length > 0) && (
<div
className="mt-3 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs space-y-2"
data-testid="review-partial-failures"
>
{partialFailures.length > 0 && (
<FailureSection
label={`Failed to save ${partialFailures.length} item${partialFailures.length === 1 ? "" : "s"}`}
failures={partialFailures}
/>
)}
{addFailures.length > 0 && (
<FailureSection
label={`Failed to add ${addFailures.length} item${addFailures.length === 1 ? "" : "s"}`}
failures={addFailures}
/>
)}
{removeFailures.length > 0 && (
<FailureSection
label={`Failed to remove ${removeFailures.length} item${removeFailures.length === 1 ? "" : "s"}`}
failures={removeFailures}
/>
)}
</div>
)}
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Source of truth
</h3>
{/* Task 5 fills this in. */}
<SourceOfTruth
quoteEmail={quoteEmail}
attachments={attachments}
/>
</div>
</div>
</CardContent>
{/* Edit-mode confirmation dialog. Pops when the operator clicks
"Edit items" so the input fields aren't immediately
editable — the operator has to confirm intent. Cancel just
closes the dialog; Enable editing flips `editingItems` and
also closes the dialog. */}
<Dialog
open={confirmingEdit}
onOpenChange={(open) => {
// Only allow the dialog to be dismissed via Cancel / X —
// don't auto-flip editingItems from the open-change handler
// (the only writers are the two buttons below).
if (!open) setConfirmingEdit(false);
}}
>
<DialogContent
className="sm:max-w-sm"
data-testid="review-edit-confirm-dialog"
>
<DialogHeader>
<DialogTitle>Enable item editing?</DialogTitle>
<DialogDescription>
Are you sure you want to edit the fields? Changes will be
saved when you click Save.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="outline" size="sm" />}>
Cancel
</DialogClose>
<Button
size="sm"
onClick={() => {
setEditingItems(true);
setConfirmingEdit(false);
}}
data-testid="review-edit-confirm-enable"
>
Enable editing
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center justify-end gap-2 border-t p-4">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={saving}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || saving}
data-testid="review-save-button"
>
{saving ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Check className="h-3 w-3 mr-1" />
)}
Save
</Button>
</div>
</Card>
</div>
);
}
// --- Partial-failure sub-section -----------------------------------------
// Used three times in the partial-failures block (updates / adds /
// removes). Each section is a small labelled list of { id, error }
// rows. Labels are supplied by the caller so the section can carry
// the appropriate count verb ("Failed to save" / "Failed to add" /
// "Failed to remove").
function FailureSection({
label,
failures,
}: {
label: string;
failures: Array<{ id: string; error: string }>;
}) {
return (
<div>
<p className="font-semibold text-destructive mb-1">{label}:</p>
<ul className="space-y-1">
{failures.map((f) => (
<li
key={f.id}
className="flex items-start gap-1.5 text-destructive"
>
<span className="font-mono">{f.id}</span>
<span className="text-muted-foreground">{f.error}</span>
</li>
))}
</ul>
</div>
);
}
// --- Source-of-truth panel -----------------------------------------------
// Always renders BOTH the source email body AND the source attachments
// (if any). The email body is the audit trail the customer sent the
// PO from; it can be long, so it's collapsible. Attachments preview
// inline. When there are no attachments the email body sits at the
// top so the operator can scan it before checking the items.
function SourceOfTruth({
quoteEmail,
attachments,
}: {
quoteEmail: { sender: string; subject: string; body: string };
attachments: QuoteAttachment[];
}) {
// Email body is always rendered; the operator can expand it when
// they want to cross-check the customer's wording. Default: collapsed
// — the email is the secondary audit trail, not the primary edit
// surface. Even when expanded, the body scrolls inside a short
// container so it doesn't push the order items / attachments off
// screen.
const [emailOpen, setEmailOpen] = useState(false);
// Pick the first PDF if any, else first image, else the first
// entry of any other kind (which gets the "no preview" treatment).
const firstPdf = attachments.find((a) => a.kind === "pdf");
const firstImage = attachments.find((a) => a.kind === "image");
const firstOther = attachments.find(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
const previewTarget = firstPdf ?? firstImage ?? null;
const othersAreUnpreviewable = attachments.some(
(a) => a.kind !== "pdf" && a.kind !== "image",
);
return (
<div className="rounded-md border bg-muted/30 text-xs space-y-3" data-testid="review-source-of-truth">
{/* From + Subject header — ALWAYS visible above the
collapsible email body. The operator needs to see who sent
the mail and what the subject was at a glance, without
having to expand the email. Resolution chain for the
sender lives in `getOrderReviewData` — by the time it
reaches this component, `sender` is already the best
available signal (order snapshot → quote → customer
email), or empty string for unknown. Empty renders as
"(unknown sender)" to match the existing "(no subject)"
/ "(no body)" placeholder pattern. */}
<div
className="px-3 pt-3 space-y-1.5"
data-testid="review-email-header"
>
<div className="flex items-start gap-1.5">
<AtSign className="h-3 w-3 mt-0.5 shrink-0 text-muted-foreground" />
<span
className="break-all text-foreground"
data-testid="review-email-sender"
>
{quoteEmail.sender || (
<span className="text-muted-foreground italic">
(unknown sender)
</span>
)}
</span>
</div>
<div className="flex items-start gap-1.5">
<Mail className="h-3 w-3 mt-0.5 shrink-0 text-muted-foreground" />
<span
className="break-words text-foreground font-medium"
data-testid="review-email-subject"
>
{quoteEmail.subject || (
<span className="text-muted-foreground italic font-normal">
(no subject)
</span>
)}
</span>
</div>
</div>
{/* Email body — ALWAYS visible (collapsible). Audit trail of
the customer's request: the operator reads it when an AI
field looks wrong. Long quotes get a collapse toggle so the
preview pane stays usable. */}
<div className="border-t border-border/40">
<button
type="button"
onClick={() => setEmailOpen((open) => !open)}
aria-expanded={emailOpen}
aria-controls="review-email-body"
data-testid="review-email-toggle"
className="w-full px-3 pt-2 pb-2 flex items-center gap-1.5 text-left font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
{emailOpen ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
Email body
</button>
{emailOpen && (
<div
id="review-email-body"
className="px-3 pb-3 space-y-1"
data-testid="review-email-body"
>
{/* Subject repeats here too, inside the body, so
operators who DO open the email still see From →
Subject → Body in reading order. Cheap to repeat
(already in `quoteEmail`). */}
<p className="text-foreground font-medium break-words">
{quoteEmail.subject || "(no subject)"}
</p>
{/* Short scroll container so even long emails don't push
the operator's real work (order items + attachments)
down the page. The toggle above lets them skip the
email entirely when they don't need it. */}
<pre className="whitespace-pre-wrap font-sans text-muted-foreground max-h-40 overflow-y-auto text-[11px] leading-snug">
{quoteEmail.body || "(no body)"}
</pre>
</div>
)}
</div>
{/* Attachments — only render the section when there is at
least one. Without this, the "Attachments (0)" header is
noisy on attachment-less orders. */}
{attachments.length > 0 && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center gap-1.5 font-semibold uppercase tracking-wide text-muted-foreground">
<Paperclip className="h-3 w-3" />
Attachments ({attachments.length})
</div>
{/* Tab strip — DECORATIVE in v1 (no click handler, no activeId state).
Only the first previewable attachment (PDF > image) is rendered below;
the chips are visual context. Future v2 could wire them with
`useState<activeId>` inside `SourceOfTruth` to swap the preview. */}
{attachments.length > 1 && (
<div className="flex flex-wrap gap-1.5 border-b border-border/40 pb-2">
{attachments.map((a) => {
const isActive = previewTarget && a.id === previewTarget.id;
const previewable = a.kind === "pdf" || a.kind === "image";
return (
<span
key={a.filename}
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 leading-5 text-[10px]",
isActive
? "bg-primary/15 text-primary font-medium"
: "text-muted-foreground",
)}
title={`${a.filename} (${a.kind ?? "unknown"})`}
>
{previewable ? "●" : "○"} {a.filename}
</span>
);
})}
</div>
)}
{previewTarget && previewTarget.kind === "pdf" && (
<iframe
src={attachmentUrl(previewTarget)}
title={previewTarget.filename}
className="w-full h-[420px] bg-background border rounded"
data-testid="review-attachment-iframe"
/>
)}
{previewTarget && previewTarget.kind === "image" && (
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={attachmentUrl(previewTarget)}
alt={previewTarget.filename}
className="max-w-full max-h-[420px] rounded border bg-background"
data-testid="review-attachment-img"
/>
</div>
)}
{/* No previewable attachment at all — show a no-preview message. */}
{!previewTarget && firstOther && (
<div className="space-y-1">
<p
className="text-muted-foreground italic"
data-testid="review-no-preview"
>
{`No preview available for this ${firstOther.kind ?? "file"} type.`}
</p>
</div>
)}
{othersAreUnpreviewable && previewTarget && (
<p className="text-[10px] text-muted-foreground">
+ {attachments.filter(
(a) => a.kind !== "pdf" && a.kind !== "image",
).length}{" "}
unpreviewable attachment(s) — open to view.
</p>
)}
<ul className="space-y-1">
{attachments.map((a) => (
<li
key={a.filename}
className="flex items-center justify-between gap-2 text-[11px]"
>
<span className="truncate text-muted-foreground">
{a.filename}
</span>
<a
href={attachmentUrl(a)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
open <ExternalLink className="h-2.5 w-2.5" />
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}
function attachmentUrl(a: QuoteAttachment): string {
const token = Array.isArray(a.file) ? a.file[0] : a.file;
return `/api/v1/files/${a.collectionId}/${a.id}/${token}`;
}