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>
This commit is contained in:
parent
7f1603d9b0
commit
ba1f916102
5 changed files with 217 additions and 14 deletions
|
|
@ -186,7 +186,7 @@ function OrdersReviewModalMount({
|
|||
customer: import("@/lib/types").Customer;
|
||||
company: import("@/lib/types").Company | null;
|
||||
customers: import("@/lib/types").Customer[];
|
||||
quoteEmail: { subject: string; body: string };
|
||||
quoteEmail: { sender: string; subject: string; body: string };
|
||||
attachments: import("@/lib/types").QuoteAttachment[];
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -199,7 +199,7 @@ function OrdersReviewModalMount({
|
|||
order: import("@/lib/types").Order;
|
||||
items: OrderItem[];
|
||||
customers: import("@/lib/types").Customer[];
|
||||
quoteEmail: { subject: string; body: string };
|
||||
quoteEmail: { sender: string; subject: string; body: string };
|
||||
}>(`/api/v1/orders/${encodeURIComponent(orderId)}/review`);
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ const baseProps = () => ({
|
|||
customers: [baseCustomer],
|
||||
customer: baseCustomer,
|
||||
company: baseCompany,
|
||||
quoteEmail: { subject: "Re: RFQ", body: "Hi team..." },
|
||||
quoteEmail: { sender: "jane@acme.test", subject: "Re: RFQ", body: "Hi team..." },
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
|
|
@ -825,6 +825,63 @@ describe("OrdersReviewModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("source of truth email header (From + Subject)", () => {
|
||||
it("renders sender and subject above the collapsible email body — visible without expanding", () => {
|
||||
// The From: line and Subject: line must always be on screen,
|
||||
// independent of whether the operator has expanded the email
|
||||
// body. They're the quick-scan header for the audit trail.
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
const header = screen.getByTestId("review-email-header");
|
||||
expect(header).toBeInTheDocument();
|
||||
expect(screen.getByTestId("review-email-sender")).toHaveTextContent(
|
||||
"jane@acme.test",
|
||||
);
|
||||
expect(screen.getByTestId("review-email-subject")).toHaveTextContent(
|
||||
"Re: RFQ",
|
||||
);
|
||||
// And the email body toggle is still collapsed by default.
|
||||
expect(
|
||||
screen.queryByTestId("review-email-body"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders '(unknown sender)' when sender is empty", () => {
|
||||
// Empty sender → friendly placeholder so the From: row is
|
||||
// never blank (matches the existing '(no subject)' / '(no
|
||||
// body)' pattern).
|
||||
render(
|
||||
<OrdersReviewModal
|
||||
{...baseProps()}
|
||||
quoteEmail={{ sender: "", subject: "RFQ", body: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("review-email-sender")).toHaveTextContent(
|
||||
"(unknown sender)",
|
||||
);
|
||||
expect(screen.getByTestId("review-email-subject")).toHaveTextContent(
|
||||
"RFQ",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders '(no subject)' when subject is empty but sender is present", () => {
|
||||
// Subject-less emails are real (some RFQs ship with no
|
||||
// subject line). Subject placeholder applies independently
|
||||
// from sender — sender is still shown if we have it.
|
||||
render(
|
||||
<OrdersReviewModal
|
||||
{...baseProps()}
|
||||
quoteEmail={{ sender: "jane@acme.test", subject: "", body: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("review-email-sender")).toHaveTextContent(
|
||||
"jane@acme.test",
|
||||
);
|
||||
expect(screen.getByTestId("review-email-subject")).toHaveTextContent(
|
||||
"(no subject)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customer cascade (company + rep)", () => {
|
||||
const expandedCustomerA: Customer = {
|
||||
...baseCustomer,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
AtSign,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -67,7 +68,7 @@ export interface OrdersReviewModalProps {
|
|||
customers: Customer[];
|
||||
customer: Customer;
|
||||
company: Company | null;
|
||||
quoteEmail: { subject: string; body: string };
|
||||
quoteEmail: { sender: string; subject: string; body: string };
|
||||
attachments: QuoteAttachment[];
|
||||
}
|
||||
|
||||
|
|
@ -1215,7 +1216,7 @@ function SourceOfTruth({
|
|||
quoteEmail,
|
||||
attachments,
|
||||
}: {
|
||||
quoteEmail: { subject: string; body: string };
|
||||
quoteEmail: { sender: string; subject: string; body: string };
|
||||
attachments: QuoteAttachment[];
|
||||
}) {
|
||||
// Email body is always rendered; the operator can expand it when
|
||||
|
|
@ -1241,26 +1242,66 @@ function SourceOfTruth({
|
|||
|
||||
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-b border-border/40">
|
||||
<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-3 pb-2 flex items-center gap-1.5 text-left font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
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" />
|
||||
)}
|
||||
<Mail className="h-3 w-3" />
|
||||
Email
|
||||
Email body
|
||||
</button>
|
||||
{emailOpen && (
|
||||
<div
|
||||
|
|
@ -1268,6 +1309,10 @@ function SourceOfTruth({
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -1013,6 +1013,7 @@ describe("getOrderReviewData", () => {
|
|||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail).toEqual({
|
||||
sender: "",
|
||||
subject: "RFQ for 10x M10 bolts",
|
||||
body: "Hi team, please quote on the attached.\nThanks",
|
||||
});
|
||||
|
|
@ -1041,6 +1042,7 @@ describe("getOrderReviewData", () => {
|
|||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail).toEqual({
|
||||
sender: "",
|
||||
subject: "Quote Q-001 attached",
|
||||
body: "Please find our quote attached.",
|
||||
});
|
||||
|
|
@ -1067,8 +1069,87 @@ describe("getOrderReviewData", () => {
|
|||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail).toEqual({
|
||||
sender: "",
|
||||
subject: "Updated RFQ body",
|
||||
body: "Latest customer message",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the order's own email_sender when set", async () => {
|
||||
// The n8n ingest agent snapshots the inbound From: address onto
|
||||
// the order itself (orders.email_sender). The review modal's
|
||||
// "From:" line must read from that snapshot — not the related
|
||||
// quote's email_sender — so the value matches what the customer
|
||||
// actually sent on this specific order.
|
||||
const order = await pbMock.collection("orders").create({
|
||||
order_number: "ORD-2026-07-001",
|
||||
customer: "cust-1",
|
||||
email_sender: "jane@acme.test",
|
||||
email_subject: "RFQ",
|
||||
email_body: "Body",
|
||||
});
|
||||
|
||||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail.sender).toBe("jane@acme.test");
|
||||
});
|
||||
|
||||
it("falls back to the related quote's email_sender when the order column is empty", async () => {
|
||||
// Pre-snapshot orders (created before orders.email_sender
|
||||
// existed) carry the original From: address on the related
|
||||
// quote (`quote.email_sender`). The modal must still show a
|
||||
// useful sender for these legacy rows.
|
||||
const quote = await pbMock.collection("quotes").create({
|
||||
id: "quote-1",
|
||||
quote_number: "Q-001",
|
||||
email_sender: "jane@acme.test",
|
||||
email_subject: "RFQ",
|
||||
email_body: "Body",
|
||||
});
|
||||
const order = await pbMock.collection("orders").create({
|
||||
order_number: "ORD-2026-07-001",
|
||||
customer: "cust-1",
|
||||
quote: "quote-1",
|
||||
// email_sender intentionally absent.
|
||||
});
|
||||
|
||||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail.sender).toBe("jane@acme.test");
|
||||
});
|
||||
|
||||
it("falls back to the linked customer's email when neither order nor quote has email_sender", async () => {
|
||||
// Last-resort fallback so the modal always has a From: line.
|
||||
// The customer's general email isn't necessarily the address
|
||||
// that triggered *this* order, but it's the best signal we
|
||||
// have without snapshot data.
|
||||
await pbMock.collection("customers").create({
|
||||
id: "cust-1",
|
||||
customer: "Jane",
|
||||
email: "jane@acme.test",
|
||||
});
|
||||
const order = await pbMock.collection("orders").create({
|
||||
order_number: "ORD-2026-07-001",
|
||||
customer: "cust-1",
|
||||
// email_sender absent on both order and quote.
|
||||
});
|
||||
|
||||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail.sender).toBe("jane@acme.test");
|
||||
});
|
||||
|
||||
it("returns an empty sender when no signal is available (modal renders '(unknown sender)')", async () => {
|
||||
// No order.email_sender, no related quote, no customer email.
|
||||
// The modal must still render — empty sender becomes the
|
||||
// "(unknown sender)" placeholder rather than the page failing.
|
||||
const order = await pbMock.collection("orders").create({
|
||||
order_number: "ORD-2026-07-001",
|
||||
customer: "",
|
||||
});
|
||||
|
||||
const result = await getOrderReviewData(order.id);
|
||||
|
||||
expect(result.quoteEmail.sender).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -748,6 +748,13 @@ export async function getOrdersArchiveData(
|
|||
}
|
||||
|
||||
export interface OrderReviewEmail {
|
||||
/**
|
||||
* From: address of the customer email that originated this order.
|
||||
* Resolved with the same fallback chain as `subject` / `body` —
|
||||
* see `getOrderReviewData` below. Empty string means the sender
|
||||
* is genuinely unknown (the modal renders `(unknown sender)`).
|
||||
*/
|
||||
sender: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
|
@ -758,10 +765,11 @@ export async function getOrderReviewData(orderId: string): Promise<{
|
|||
customers: Customer[];
|
||||
/**
|
||||
* Source email for the review modal. Prefers the order's own
|
||||
* `email_subject` / `email_body` (populated by the n8n agent when
|
||||
* it ingests the customer email that triggered the order). Falls
|
||||
* back to the related `quote.email_*` for older orders that were
|
||||
* created before the snapshot columns existed on `orders`.
|
||||
* `email_subject` / `email_body` / `email_sender` (populated by
|
||||
* the n8n agent when it ingests the customer email that
|
||||
* triggered the order). Falls back to the related `quote.email_*`
|
||||
* for older orders that were created before the snapshot
|
||||
* columns existed on `orders`.
|
||||
*/
|
||||
quoteEmail: OrderReviewEmail;
|
||||
}> {
|
||||
|
|
@ -790,19 +798,31 @@ export async function getOrderReviewData(orderId: string): Promise<{
|
|||
// The quote side carries the *operator's* email_body, not the
|
||||
// customer's, so this fallback is best-effort — the order
|
||||
// columns are the audit trail we actually want to show.
|
||||
//
|
||||
// Sender resolution: order.email_sender → quote.email_sender →
|
||||
// customer.email (last resort). The customer's general email
|
||||
// isn't necessarily the address that triggered this specific
|
||||
// order — it's only used so the modal has *something* to show
|
||||
// for pre-snapshot orders. The empty-string fallthrough renders
|
||||
// as "(unknown sender)" in the modal.
|
||||
const orderSubject = (order.email_subject || "").trim();
|
||||
const orderBody = (order.email_body || "").trim();
|
||||
const orderSender = (order.email_sender || "").trim();
|
||||
const quote = order.expand?.quote as
|
||||
| { email_subject?: string; email_body?: string }
|
||||
| { email_subject?: string; email_body?: string; email_sender?: string }
|
||||
| undefined;
|
||||
const quoteSubject = (quote?.email_subject || "").trim();
|
||||
const quoteBody = (quote?.email_body || "").trim();
|
||||
const quoteSender = (quote?.email_sender || "").trim();
|
||||
const customerEmail = ((order.expand?.customer as Customer | undefined)?.email || "").trim();
|
||||
const sender = orderSender || quoteSender || customerEmail;
|
||||
|
||||
return {
|
||||
order,
|
||||
items,
|
||||
customers,
|
||||
quoteEmail: {
|
||||
sender,
|
||||
subject: orderSubject || quoteSubject,
|
||||
body: orderBody || quoteBody,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue