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 || "—"}

+
+
+
+
+
+