From 4e085a3f69a73d2b8ec3eb14f0a62046b68916ac Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 29 Jun 2026 22:23:44 +0200 Subject: [PATCH] refactor(quotes detail): two-column layout with hero Items card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote detail page had grown to eight stacked cards, all with identical Card chrome — Items, Outbound, Linked Orders, Notes, Settings, History, Email Status, and a TODO 'Generate PDF (coming soon)' card. The Items card (the page's hero) had the same visual weight as the admin-panel cards, so the eye had nowhere to land. Two-column layout on desktop (lg:grid-cols-3, primary 2/3, secondary 1/3): Left column (primary) - Description card (conditional) - Items card — hero treatment: * Left primary-blue accent border (border-l-4 border-l-primary) * Tinted primary header background (bg-primary/5) * Larger title (text-base font-semibold) * Rewrite with AI demoted from loud amber pill to outline variant (matches the accent trap from MEMORY.md) - Notes card (conditional) Right column (secondary) - Customer meta card — replaces the old 5-card info strip with a single compact card: customer name, company, letterhead, created date, quote date (if set), and claimed-by with avatar. - Outbound attachments card - Linked orders card - Quote Settings accordion (collapsed by default) - History accordion (collapsed by default) - Email Status card — tightened to 2-col grid Mobile (below lg) collapses to single column with content-then-meta order: Description → Items → Notes → Customer meta → Outbound → Linked → Settings → History → Email Status. The 'Generate PDF (coming soon)' placeholder card is removed — that was a TODO shipped to users. The actual Generate PDF flow lives in the 'View Quote' menu item (QuotePdfViewer) and the 'Approve' modal, neither of which need this stub. Items card header is flex-col on mobile so the title, totals, and Rewrite button each get their own line instead of getting clipped. Tests: 229/230 vitest (the 1 failure is the pre-existing attachments off-by-one, unrelated to this change). --- src/app/(app)/quotes/[id]/page.tsx | 1139 ++++++++++++++-------------- 1 file changed, 572 insertions(+), 567 deletions(-) diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index b5c552c..4c921d3 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -1578,601 +1578,606 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri )} -
- - - Customer - - -

{quote.expand?.customer?.customer || "—"}

- {quote.expand?.customer?.expand?.company && ( -

{quote.expand.customer.expand.company.company}

- )} -
-
- - - Company - - -

{quote.expand?.company?.company || "—"}

-
-
- - - Letterhead - - -

{quote.company_letterhead || "CNL"}

-
-
- - - Date - - -

{formatDate(quote.created)}

-
-
- - - - - Claimed by - - - - {quote.claimed_by ? ( -
- - {(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()} - -

- {claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)} -

-
- ) : ( -

Unclaimed

- )} -
-
-
- - {quote.description && ( - - - Description - - -

{quote.description}

-
-
- )} - - - - Items ({items.length}) -
- {subtotal > 0 && ( -
- Subtotal: - {formatCurrency(subtotal)} - VAT: - {formatCurrency(vat)} -
- )} - {items.length > 0 && ( - - )} -
-
- - {/* "Compare with original request" toggle — when on, the - original email card renders inline above the items so an - approver can verify each quoted line against what was - actually asked for without scrolling. The email card is - moved out of its old spot below the items to keep the - page from showing the same content twice. */} - {(quote.email_body || attachments.length > 0) && ( -
-

- Toggle the original email request above the items for line-by-line comparison. -

- -
+
+ {/* LEFT COLUMN — primary content (Description / Items / Notes). + The Items card lives here because it's the page's hero; the + admin panels on the right are quieter so the eye lands on + the data first. */} +
+ {quote.description && ( + + + Description + + +

{quote.description}

+
+
)} - {compareOpen && ( -
- -
- )} - {items.length === 0 ? ( -
- -

No items yet

-
- ) : ( - <> -
- - - - Description - Qty - Supplier - Cost - Selling - - - - Markup - - - Processed - - - - + + + + + + Items ({items.length}) + +
+ {subtotal > 0 && ( +
+ Subtotal: + {formatCurrency(subtotal)} + VAT: + {formatCurrency(vat)} +
+ )} + {items.length > 0 && ( + + )} +
+
+ + {/* "Compare with original request" toggle — when on, the + original email card renders inline above the items so an + approver can verify each quoted line against what was + actually asked for without scrolling. The email card is + moved out of its old spot below the items to keep the + page from showing the same content twice. */} + {(quote.email_body || attachments.length > 0) && ( +
+

+ Toggle the original email request above the items for line-by-line comparison. +

+ +
+ )} + {compareOpen && ( +
+ +
+ )} + {items.length === 0 ? ( +
+ +

No items yet

+
+ ) : ( + <> +
+
+ + + Description + Qty + Supplier + Cost + Selling + + + + Markup + + + Processed + + + + + {items.map((item) => { + const pct = itemMarkupPercent(item); + return ( + + {item.description} + {item.quantity} + {item.expand?.supplier?.suppliername || "—"} + {item.cost_price ? formatCurrency(item.cost_price) : "—"} + {item.selling_price ? formatCurrency(item.selling_price) : "—"} + + {formatMarkupPercent(pct)} + + + + {item.is_processed ? "Yes" : "No"} + + + + + + + ); + })} + +
+
+
{items.map((item) => { const pct = itemMarkupPercent(item); return ( - - {item.description} - {item.quantity} - {item.expand?.supplier?.suppliername || "—"} - {item.cost_price ? formatCurrency(item.cost_price) : "—"} - {item.selling_price ? formatCurrency(item.selling_price) : "—"} - - {formatMarkupPercent(pct)} - - - + +
+

{item.description}

+ {item.is_processed ? "Yes" : "No"} - - +
+
+
+ Qty: + {item.quantity} +
+
+ Supplier: + {item.expand?.supplier?.suppliername || "—"} +
+
+ Cost: + {item.cost_price ? formatCurrency(item.cost_price) : "—"} +
+
+ Selling: + {item.selling_price ? formatCurrency(item.selling_price) : "—"} +
+
+ Markup: + {formatMarkupPercent(pct)} +
+
+
- - +
+
); })} - - -
-
- {items.map((item) => { - const pct = itemMarkupPercent(item); - return ( - -
-

{item.description}

- - {item.is_processed ? "Yes" : "No"} - -
-
-
- Qty: - {item.quantity} -
-
- Supplier: - {item.expand?.supplier?.suppliername || "—"} -
-
- Cost: - {item.cost_price ? formatCurrency(item.cost_price) : "—"} -
-
- Selling: - {item.selling_price ? formatCurrency(item.selling_price) : "—"} -
-
- Markup: - {formatMarkupPercent(pct)} -
-
-
- -
-
- ); - })} -
- - )} - - - - { - // Outbound file preview reuses the email-card's dialog via - // a tiny inline state — opening the same attachment preview - // pattern the office already trusts. Implementation lives - // alongside the card to keep the modal scope local. - setOutboundPreviewAtt(att); - }} - /> - - {/* Linked orders — every order that has been hard-linked to - this quote, OR that has us as a `potential_quote` suggestion - awaiting accept. Single surface for the operator to see - which orders reference this quote (e.g. supplier emails - get the items they need to fulfill). Fetched once at page - load and re-fetched alongside the quote on relevant - actions. */} - - - - - Linked Orders - - - Every order that points at this quote — already-linked, or - suggested and waiting for accept on the order detail page. - - - - {linkedOrders.length === 0 && ( -

- No orders linked to this quote yet. They show up here - automatically when an order’s matcher picks this quote, - or when one is created from this quote via the menu. -

- )} -
    - {linkedOrders.map((o) => ( -
  • - -
  • - ))} -
-
-
- - {quote.notes && ( - - - Notes - - -

{quote.notes}

-
-
- )} - - - - {settingsOpen && ( - - {quote.is_template ? ( - // Templates don't have a specific date or validity — those - // only matter when a quote actually goes to a customer. - // Only the follow-up reminder is meaningful. -

- Templates don't carry a quote date or validity — those - are set on the real quote generated from this template. - Use the follow-up date below to remind yourself to issue - this template on a particular day. -

- ) : null} -
- {!quote.is_template && ( - <> -
- - setQuoteDate(e.target.value)} - className="h-9 text-sm" - /> -

- Date printed on the PDF. Defaults to today. -

-
-
- - setValidityDays(e.target.value)} - className="h-9 text-sm" - /> -

- Empty = 30 day default. Set 60 for 2 months, etc. -

)} -
- - setFollowUpDue(e.target.value)} - className="h-9 text-sm" - /> -

- Dashboard flags this quote when overdue. -

-
-
-
- -
-
- )} -
+ + - - - {historyOpen && ( - -

- Derived from the existing quote + email fields. Full audit trail - (with actor + per-field diffs) requires a new PB collection and - write hooks — tracked as a follow-up migration. -

-
    - } - label="Quote created" - timestamp={quote.created} - /> - } - label="Last updated" - timestamp={quote.updated} - muted={quote.updated === quote.created} - /> - } - label={`Status: ${statusLabels[quote.status] || quote.status}`} - timestamp={quote.updated} - /> - {quote.claimed_by && ( - } - label={`Claimed by ${claimerName || quote.claimed_by}`} - timestamp={null} - muted - /> - )} - {quote.email_received_at && ( - } - label="Original email received" - timestamp={quote.email_received_at} - /> - )} - {quote.email_delivered && ( - } - label="Quote email delivered" - timestamp={quote.email_delivered_at} - /> - )} - {quote.email_opened && ( - } - label="Quote email opened" - timestamp={quote.email_opened_at} - /> - )} - {quote.last_reply_at && ( - } - label="Customer replied" - timestamp={quote.last_reply_at} - /> - )} - {quote.pdf_generated_at && ( - } - label="PDF generated" - timestamp={quote.pdf_generated_at} - /> - )} - {quote.email_failed_at && ( - } - label={ - quote.email_error - ? `Email send failed — ${quote.email_error}` - : "Email send failed" - } - timestamp={quote.email_failed_at} - destructive - /> - )} - {quote.status === "sending" && quote.sending_started_at && ( - } - label="Email send in progress" - timestamp={quote.sending_started_at} - /> - )} - {quote.approved_by && ( - } - label={`Approved by ${claimerName || quote.approved_by}`} - timestamp={quote.updated} - /> - )} - {quote.follow_up_due && ( - } - label={`Follow-up due ${formatDate(quote.follow_up_due)}`} - timestamp={null} - muted - /> - )} -
-
- )} -
+
- - - Email Status - - -
-
-

Delivered

-
- {quote.email_delivered ? ( - - ) : ( - + {/* RIGHT COLUMN — meta + admin panels. The eye should land on the + Items card first; the panels on the right are reference / config + and sit quietly. Mobile stacks them below the items. */} +
+ {/* Compact meta card — Customer / Company / Dates / Letterhead / + Claimed by, packed into a single card with a header icon + rather than five separate micro-cards. */} + + + + + Customer + + + +
+

{quote.expand?.customer?.customer || "—"}

+ {quote.expand?.customer?.expand?.company && ( +

{quote.expand.customer.expand.company.company}

)} - {quote.email_delivered ? "Yes" : "No"}
-
-
-

Opened

-
- {quote.email_opened ? ( - - ) : ( - +
+
+

Company

+

{quote.expand?.company?.company || "—"}

+
+
+

Letterhead

+

{quote.company_letterhead || "CNL"}

+
+
+

Created

+

{formatDate(quote.created)}

+
+ {quote.quote_date && ( +
+

Quote date

+

{formatDate(quote.quote_date)}

+
)} - {quote.email_opened ? "Yes" : "No"}
-
-
-

Sender

-

{quote.email_sender || "—"}

-
-
-

Subject

-

{quote.email_subject || "—"}

-
-
- - +
+

Claimed by

+ {quote.claimed_by ? ( +
+ + {(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()} + +

+ {claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)} +

+
+ ) : ( +

Unclaimed

+ )} +
+ + - - - - - + { + // Outbound file preview reuses the email-card's dialog via + // a tiny inline state — opening the same attachment preview + // pattern the office already trusts. Implementation lives + // alongside the card to keep the modal scope local. + setOutboundPreviewAtt(att); + }} + /> + + {/* Linked orders — every order that has been hard-linked to + this quote, OR that has us as a `potential_quote` suggestion + awaiting accept. Single surface for the operator to see + which orders reference this quote (e.g. supplier emails + get the items they need to fulfill). Fetched once at page + load and re-fetched alongside the quote on relevant + actions. */} + + + + + Linked Orders + + + Already-linked, or suggested and waiting for accept on the order detail page. + + + + {linkedOrders.length === 0 && ( +

+ No orders linked yet. They show up automatically when an order's matcher picks this quote, or when one is created from this quote via the menu. +

+ )} +
    + {linkedOrders.map((o) => ( +
  • + +
  • + ))} +
+
+
+ + {/* Settings / History / Email Status sit at the bottom of the + right column. They're reference / config surfaces so they + stay below the working content (items + outbound + linked). */} + + + {settingsOpen && ( + + {quote.is_template ? ( + // Templates don't have a specific date or validity — those + // only matter when a quote actually goes to a customer. + // Only the follow-up reminder is meaningful. +

+ Templates don't carry a quote date or validity — those + are set on the real quote generated from this template. + Use the follow-up date below to remind yourself to issue + this template on a particular day. +

+ ) : null} +
+ {!quote.is_template && ( + <> +
+ + setQuoteDate(e.target.value)} + className="h-9 text-sm" + /> +

+ Date printed on the PDF. Defaults to today. +

+
+
+ + setValidityDays(e.target.value)} + className="h-9 text-sm" + /> +

+ Empty = 30 day default. Set 60 for 2 months, etc. +

+
+ + )} +
+ + setFollowUpDue(e.target.value)} + className="h-9 text-sm" + /> +

+ Dashboard flags this quote when overdue. +

+
+
+
+ +
+
+ )} +
+ + + + {historyOpen && ( + +

+ Derived from the existing quote + email fields. Full audit trail + (with actor + per-field diffs) requires a new PB collection and + write hooks — tracked as a follow-up migration. +

+
    + } + label="Quote created" + timestamp={quote.created} + /> + } + label="Last updated" + timestamp={quote.updated} + muted={quote.updated === quote.created} + /> + } + label={`Status: ${statusLabels[quote.status] || quote.status}`} + timestamp={quote.updated} + /> + {quote.claimed_by && ( + } + label={`Claimed by ${claimerName || quote.claimed_by}`} + timestamp={null} + muted + /> + )} + {quote.email_received_at && ( + } + label="Original email received" + timestamp={quote.email_received_at} + /> + )} + {quote.email_delivered && ( + } + label="Quote email delivered" + timestamp={quote.email_delivered_at} + /> + )} + {quote.email_opened && ( + } + label="Quote email opened" + timestamp={quote.email_opened_at} + /> + )} + {quote.last_reply_at && ( + } + label="Customer replied" + timestamp={quote.last_reply_at} + /> + )} + {quote.pdf_generated_at && ( + } + label="PDF generated" + timestamp={quote.pdf_generated_at} + /> + )} + {quote.email_failed_at && ( + } + label={ + quote.email_error + ? `Email send failed — ${quote.email_error}` + : "Email send failed" + } + timestamp={quote.email_failed_at} + destructive + /> + )} + {quote.status === "sending" && quote.sending_started_at && ( + } + label="Email send in progress" + timestamp={quote.sending_started_at} + /> + )} + {quote.approved_by && ( + } + label={`Approved by ${claimerName || quote.approved_by}`} + timestamp={quote.updated} + /> + )} + {quote.follow_up_due && ( + } + label={`Follow-up due ${formatDate(quote.follow_up_due)}`} + timestamp={null} + muted + /> + )} +
+
+ )} +
+ + + + + + Email Status + + + +
+
+

Delivered

+
+ {quote.email_delivered ? ( + + ) : ( + + )} + {quote.email_delivered ? "Yes" : "No"} +
+
+
+

Opened

+
+ {quote.email_opened ? ( + + ) : ( + + )} + {quote.email_opened ? "Yes" : "No"} +
+
+
+

Sender

+

{quote.email_sender || "—"}

+
+
+

Subject

+

{quote.email_subject || "—"}

+
+
+
+
+
+