diff --git a/src/app/(app)/quotes/approvals/[id]/page.tsx b/src/app/(app)/quotes/approvals/[id]/page.tsx index 0c63415..842561e 100644 --- a/src/app/(app)/quotes/approvals/[id]/page.tsx +++ b/src/app/(app)/quotes/approvals/[id]/page.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Dialog, DialogContent, @@ -22,6 +23,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from "@/components/ui/popover"; import { toast } from "sonner"; import { ArrowLeft, @@ -30,6 +38,7 @@ import { XCircle, Loader2, FileText, + FileImage as FileImageIcon, Paperclip, Inbox, AlertTriangle, @@ -37,6 +46,7 @@ import { Calendar, Building2, User as UserIcon, + UserCheck, Info, Minus, Plus, @@ -103,6 +113,13 @@ export default function ApprovalReviewPage() { // so the value is stable and the linter is happy. Refresh picks // up the new value. const [fetchedAt, setFetchedAt] = useState(0); + // Resolved display name for `quote.claimed_by` (the operator who + // worked this quote). Resolved async via `/api/v1/users/lookup`, + // same pattern the quote detail page uses — quote.claimed_by is a + // PB user id, not a name, so we have to join against the users + // collection. Null while we wait (or on lookup failure), which + // falls back to a truncated id in the header. + const [claimerName, setClaimerName] = useState(null); // Pending inline changes — keyed by itemId. Each entry is the // proposed { selling_price?, markup? } that the rep wants to apply @@ -191,6 +208,40 @@ export default function ApprovalReviewPage() { }, [quoteId, fetchAll]); /* eslint-enable react-hooks/set-state-in-effect */ + // Resolve the operator (claimer) user id → display name. Mirrors + // the lookup the quote detail page does; if the lookup fails or + // the id isn't in the directory we keep `claimerName = null` and + // the header falls back to a short id. The cleanup sets + // `cancelled` so a slow lookup that resolves after the quote + // changes doesn't clobber the new state. + const claimerId = quote?.claimed_by; + useEffect(() => { + if (!claimerId) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setClaimerName(null); + return; + } + let cancelled = false; + fetch("/api/v1/users/lookup") + .then((r) => (r.ok ? r.json() : [])) + .then( + (data: { id: string; name: string; email: string }[] | unknown) => { + if (cancelled) return; + const match = Array.isArray(data) + ? data.find((u) => u.id === claimerId) + : null; + const m = match as { name?: string; email?: string } | null; + setClaimerName(m ? (m.name || m.email || null) : null); + }, + ) + .catch(() => { + if (!cancelled) setClaimerName(null); + }); + return () => { + cancelled = true; + }; + }, [claimerId]); + // ── Derived values ───────────────────────────────────────── // Active items = priced + on the current quote (not moved to a // continuation). Same definition the existing approvals list @@ -279,7 +330,14 @@ export default function ApprovalReviewPage() { // Recompute selling from the locked cost + the new markup. // The selling is what the PDF will show, so it MUST stay // consistent with the markup on commit. - const newSelling = Number((cost * (1 + value / 100)).toFixed(2)); + // + // Round to the nearest whole currency unit (the rep asked for + // "31 or 32, not 31.23" — we want clean numbers on the PDF). + // `Math.round` rounds half-up which matches the intuition of + // "round up or down to nearest". Using Math.round (not + // toFixed) avoids floats like 31.000000000000004 leaking + // through and saves us a parseFloat. + const newSelling = Math.round(cost * (1 + value / 100)); next[markupEditItemId] = { ...current, markup: value, @@ -457,7 +515,14 @@ export default function ApprovalReviewPage() { : 0; return ( -
+ // `pb-24 sm:pb-4` — leave room for the sticky action bar when it + // overlays the tail of the page (mobile + small viewports); on + // desktop the bar is compact and `pb-4` (default `space-y-4` + // gap) is enough. We only really need the extra padding when + // `isApprovable` is true, but adding it unconditionally is + // simpler and the visual difference on a read-only quote is + // negligible (one extra gap). +
{/* ── Top strip ──────────────────────────────────────── */}
@@ -542,10 +607,17 @@ export default function ApprovalReviewPage() {
- {/* ── Customer + company + rep ─────────────────────────── */} + {/* ── Customer + company + rep + operator ──────────────── */} -
+ {/* Layout: 1 column on mobile (stack), 2x2 on small + viewports (sm), single row of 4 on md+ so all four + identities sit side-by-side. The four slots cover who + the customer is, who they belong to, who's selling to + them, and who actually worked this quote — the last + one is the operator (claimer) and is the one the rep + will most often ping if a price is wrong. */} +
@@ -585,6 +657,43 @@ export default function ApprovalReviewPage() {
{repName || "—"}
+
+ {/* Operator — the staff member who picked up the quote + and worked it. Distinct icon (UserCheck) so it + doesn't read as a second "Rep", and a violet tint + so it doesn't blend into the muted Customer/Rep + cells. Falls back to "Unclaimed" if no one claimed + it yet (matches the existing /quotes/[id] label so + both screens tell the same story). */} + +
+
+ Operator +
+ {quote.claimed_by ? ( +
+ + + {(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()} + + + + {claimerName || + (quote.claimed_by.length > 14 + ? `${quote.claimed_by.slice(0, 14)}…` + : quote.claimed_by)} + +
+ ) : ( +
+ Unclaimed +
+ )} +
+
{customerIsPlaceholder && (
@@ -692,9 +801,19 @@ export default function ApprovalReviewPage() { } className="mt-0.5" /> -

+ {/* The description IS the thing the rep is + approving, so it gets the row's biggest + type. Slightly larger + semibold + tight + leading, sitting in front of the muted + cost/qty/line-total strip below so the + rep's eye lands on "what is this line" + before it lands on "what does it cost". + The "(no description)" fallback stays + muted/italic so an empty row doesn't + shout. */} +

{it.description || ( - + (no description) )} @@ -775,16 +894,34 @@ export default function ApprovalReviewPage() { type="button" onClick={() => openMarkupEditor(it)} className={cn( - "inline-flex h-7 px-2 items-center rounded border bg-background text-sm tabular-nums hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "inline-flex h-7 px-2 items-center rounded-md border text-sm font-semibold tabular-nums hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + // The markup badge carries the rep's + // margin signal, so it gets a distinct + // amber tint (warm, money-adjacent) + // separate from primary (blue, brand) + // and the muted cost/selling cells. + // Edited state pushes the tint deeper + // so the rep sees at a glance which + // rows they've touched. hasChange - ? "border-primary/50 bg-primary/5 text-primary" - : "border-input text-foreground" + ? "border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300 dark:border-amber-400/50 dark:bg-amber-400/15" + : "border-amber-500/30 bg-amber-500/8 text-amber-700 dark:text-amber-300 dark:border-amber-400/30 dark:bg-amber-400/10" )} aria-label="Adjust markup" > - {markup}% + {/* Round at display time — PB can + persist fractional markup values + (e.g. 15.7) and the rep asked for + clean whole numbers. The underlying + value stays untouched so the + selling-price math on commit stays + consistent with what the rep + actually saw. */} + {Math.round(markup)}% {hasChange && ( - edit + + edit + )}

@@ -802,11 +939,32 @@ export default function ApprovalReviewPage() { {qty} - - Supplier:{" "} - + + Supplier: + {/* Emerald badge so the supplier jumps + out of the row at a glance — distinct + from primary (blue, brand), amber + (markup, margin), and muted (cost/ + qty). The rep's review hinges on + who's supplying each line, so this + is the loudest text on the row. */} + {it.expand?.supplier?.suppliername ?? "—"} + {/* Paperclip next to the supplier name. + Click it to peek at the operator's + pricing proof for this line (per-item + attachments in `quote_item_attachments`). + The rep reviews the audit trail + without leaving the screen. */} + Line total:{" "} @@ -921,8 +1079,8 @@ export default function ApprovalReviewPage() { {/* ── Bottom action bar (only when approvable) ────────── */} {isApprovable && ( - - + + {rejectOpen ? (
@@ -942,36 +1100,36 @@ export default function ApprovalReviewPage() { autoFocus />
-
+
) : ( -
-
+
+
{hasPendingChanges ? ( {pendingChangesList.length} edit @@ -984,25 +1142,33 @@ export default function ApprovalReviewPage() { <>Approving sends the email to the customer now. )}
-
+ {/* Mobile: full-width stacked buttons (Reject below + the primary action so the thumb lands on Approve + first). Desktop: inline, Reject on the left, + Approve on the right — same visual hierarchy. + `size="default"` (h-10) on mobile to satisfy the + 44px-ish touch-target expectation; `size="sm"` + on desktop where the buttons live next to dense + table data and need to stay compact. */} +
@@ -1104,8 +1270,10 @@ export default function ApprovalReviewPage() {
{formatCurrency( - getEffectiveCost(markupEditItem) * - (1 + getEffectiveMarkup(markupEditItem) / 100) + Math.round( + getEffectiveCost(markupEditItem) * + (1 + getEffectiveMarkup(markupEditItem) / 100), + ), )}
@@ -1130,11 +1298,31 @@ export default function ApprovalReviewPage() {
- {markupDraft}% + {/* Round at display so the rep sees a clean + integer even if the persisted markup had + fractions. The +/- stepper already walks + in whole numbers, but the initial draft + (copied from the row's effective markup) + can carry fractional bits from PB. */} + {Math.round(markupDraft)}%
- {markupDraft - getEffectiveMarkup(markupEditItem) >= 0 ? "+" : ""} - {markupDraft - getEffectiveMarkup(markupEditItem)}% from current + {(() => { + // Compute the rounded delta once so the + // sign check and the printed value never + // disagree (e.g. "+0%" from a 15.4 → 16 + // step shouldn't render as "+1%" because + // the unrounded diff was 0.6). + const delta = Math.round( + markupDraft - getEffectiveMarkup(markupEditItem), + ); + return ( + <> + {delta >= 0 ? "+" : ""} + {delta}% from current + + ); + })()}
Line total:{" "} {formatCurrency( - getEffectiveCost(markupEditItem) * - (1 + markupDraft / 100) * - (Number(markupEditItem.quantity) || 1) + Math.round( + getEffectiveCost(markupEditItem) * + (1 + markupDraft / 100), + ) * (Number(markupEditItem.quantity) || 1) )}{" "} × qty
@@ -1251,3 +1443,143 @@ function formatSize(bytes: number): string { if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } + +// ── Supplier proof popover (read-only) ────────────────────────────── +// Renders a small paperclip button next to the supplier name on each +// item row. Click opens a popover that lazy-loads the per-item pricing +// proof attachments via `/api/v1/quotes/items/[id]/attachments` — the +// same endpoint the process page uses, so no new collection wiring. +// +// Why inline: only used on this page; keeping it co-located keeps the +// review-screen reading flow tight. If the sprint/process screens ever +// want a read-only proof peek too, lift this into a shared component +// next to `process-item-attachments.tsx`. +// +// Why read-only (no upload / delete): the rep's job here is to +// review, not to author proof. Operators upload proofs on the process +// page; the rep is reading the audit trail that already exists. +function SupplierProofPopover({ itemId }: { itemId: string }) { + const [open, setOpen] = useState(false); + const [items, setItems] = useState< + Array<{ + id: string; + collectionId?: string; + filename: string; + file: string; + size_bytes: number; + content_type: string; + source: string; + uploaded_at?: string; + }> + >([]); + const [loading, setLoading] = useState(false); + // We display the count on the paperclip badge (so the rep sees + // "how many proofs exist" without opening it). `null` until the + // first load completes — `0` is shown as "no badge text" once we + // know there's nothing attached. + const [count, setCount] = useState(null); + + const load = async () => { + setLoading(true); + try { + const res = await fetch(`/api/v1/quotes/items/${itemId}/attachments`); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error || `HTTP ${res.status}`); + } + const body = (await res.json()) as { items: typeof items }; + const next = body.items || []; + setItems(next); + setCount(next.length); + } catch (err) { + console.warn("[approval-supplier-proof] load failed", err); + } finally { + setLoading(false); + } + }; + + // Lazy-load on first open. Subsequent opens are instant — the + // component stays mounted across open/close cycles so the items + // list is cached in component state. + const didLoadRef = useRef(false); + useEffect(() => { + if (open && !didLoadRef.current) { + didLoadRef.current = true; + void load(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + + 0 && "text-emerald-700 dark:text-emerald-300" + )} + aria-label={ + count !== null + ? `${count} proof attachment${count === 1 ? "" : "s"} — click to view` + : "View pricing proof" + } + data-testid={`approval-supplier-proof-${itemId}`} + > + + {count !== null && count > 0 && ( + {count} + )} + + + Pricing proof + + The office's audit trail for this line — supplier quotes, + price-list screenshots, datasheets. + + {loading ? ( +

+ + Loading attachments… +

+ ) : items.length === 0 ? ( +

+ No proof attached. The operator didn't upload anything + for this line — verify the price against the supplier's + own quote separately. +

+ ) : ( +
    + {items.map((att) => ( +
  • + {att.content_type?.startsWith("image/") ? ( + + ) : ( + + )} + + {att.filename} + + + {formatSize(att.size_bytes || 0)} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(app)/quotes/approvals/page.tsx b/src/app/(app)/quotes/approvals/page.tsx index 77a7fdc..e66acc4 100644 --- a/src/app/(app)/quotes/approvals/page.tsx +++ b/src/app/(app)/quotes/approvals/page.tsx @@ -256,10 +256,25 @@ export default function ApprovalsPage() { )}
+ {/* Customer + company — the rep's "who is this + quote for" signal. Sky tint (lighter than + primary brand blue so it doesn't compete + with the status badge) so the customer + identity reads as the card's headline, + distinct from the muted description + date + below it. Customer gets the louder weight, + company follows in a slightly muted shade + of the same hue so they read as one + identity pair. */}

- {quote.expand?.customer?.customer || "—"} + + {quote.expand?.customer?.customer || "—"} + {quote.expand?.customer?.expand?.company && ( - — {quote.expand.customer.expand.company.company} + + {" — "} + {quote.expand.customer.expand.company.company} + )}

{quote.description && ( diff --git a/src/lib/server/quote-email-template.ts b/src/lib/server/quote-email-template.ts index f1e1bf2..1ce77d4 100644 --- a/src/lib/server/quote-email-template.ts +++ b/src/lib/server/quote-email-template.ts @@ -73,24 +73,39 @@ export function buildDefaultQuoteEmailBody( const greeting = `Dear ${ctx.customerName || "Customer"},`; const isDeliveryNote = ctx.isDeliveryNote === true; - // ── Plain-text body ─────────────────────────────────────── - // Two-space indentation on ask lines is a long-standing email - // convention for "soft bullet" — most clients preserve the - // whitespace. The text variant is what the modal's textarea - // pre-fills AND what the SMTP fallback uses if the HTML version - // gets stripped. + // ── Plain-text body ──────────────────────────────────────── + // Design notes (revised): + // - Lead with a confident 1-line subject line that names the + // quote number — many inbox clients show the first line as a + // preview snippet. + // - Keep the body short. Operators edit this in a textarea; the + // longer it is, the less likely the override is. + // - Two blank lines between sections (greeting, ask, sign-off) + // for clear reading flow. + // - Quote validity is shown as a separate paragraph so it's + // unmissable — operators edit it for 7-day / 14-day promos + // often. + // - Closing line says "Reply with any questions" — the + // simplest possible CTA, works in every client. + // - Company letterhead on its own line. Operators who want + // multi-line letterhead (address, reg #, etc.) can override + // in the modal. let text: string; if (isDeliveryNote) { text = [ greeting, "", - `Find your signed delivery note attached and the quote for it (${ctx.quoteNumber}).`, + `Find your signed delivery note attached, along with the quote for it (${ctx.quoteNumber}).`, "", - " • Can you provide me with a order number for the delivery note?", - " • Any questions, just reply to this email.", + "To confirm the order, please reply to this email with:", + "", + " - Your purchase order number", + " - Any special delivery instructions", + "", + "Any questions about the line items or the delivery itself, just hit reply.", "", "Kind regards,", - ctx.senderName || "The Sales Team", + ctx.senderName, ctx.companyLetterhead, ].join("\n"); } else { @@ -99,11 +114,16 @@ export function buildDefaultQuoteEmailBody( "", `Please find attached quote ${ctx.quoteNumber} for your review.`, "", - " • Let us know if you have any questions or need anything changed.", - " • Quote is valid for 30 days from the date of issue.", + "The quote covers the line items we discussed. The full breakdown is in the attached PDF.", + "", + "If anything needs to change — quantities, alternates, delivery, payment terms — just reply to this email and I'll update the quote.", + "", + "Quote is valid for 30 days from the date of issue.", + "", + "If you'd like to accept and pay, you can reply with your PO and we'll convert the quote into an order on our side.", "", "Kind regards,", - ctx.senderName || "The Sales Team", + ctx.senderName, ctx.companyLetterhead, ].join("\n"); } @@ -157,9 +177,9 @@ export function buildDefaultQuoteEmailBody( `

${introLine}

` + bullets + `

${escapeHtml(fallbackLine)}

` + - `

Kind regards,
${escapeHtml(ctx.senderName || "The Sales Team")}
${escapeHtml(ctx.companyLetterhead)}

` + + `

Kind regards,
${escapeHtml(ctx.senderName)}
${escapeHtml(ctx.companyLetterhead)}

` + closing + ``; return { text, html }; -} +} \ No newline at end of file