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>
1438 lines
57 KiB
TypeScript
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 &
|
|
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}`;
|
|
}
|