feat(quotes): add approval page and email updates

- add operator (claimer) display section to quote approval review page
- add per-line-item supplier pricing proof popover with lazy-loaded attachment previews
- improve customer/company text styling and responsive layout on approvals pages
- fix markup and selling price rounding to whole currency values for cleaner outputs
- completely overhaul default quote email templates for better readability and clear CTAs
- update sticky action bar styling and responsive behavior on approval page
- remove hardcoded "The Sales Team" fallback sender name in email templates
This commit is contained in:
Dave 2026-08-11 16:09:40 +02:00
parent 7d4e4c34f2
commit cfc56f5c86
3 changed files with 422 additions and 55 deletions

View file

@ -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<number>(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<string | null>(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 (
<div className="space-y-4" ref={topRef}>
// `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).
<div className={cn("space-y-4", isApprovable && "pb-24 sm:pb-4")} ref={topRef}>
{/* ── Top strip ──────────────────────────────────────── */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 min-w-0">
@ -542,10 +607,17 @@ export default function ApprovalReviewPage() {
</Button>
</div>
{/* ── Customer + company + rep ─────────────────────────── */}
{/* ── Customer + company + rep + operator ──────────────── */}
<Card>
<CardContent className="p-4">
<div className="grid gap-3 sm:grid-cols-3">
{/* 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. */}
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
<div className="flex items-start gap-2">
<UserIcon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div className="min-w-0">
@ -585,6 +657,43 @@ export default function ApprovalReviewPage() {
<div className="text-sm font-medium truncate">{repName || "—"}</div>
</div>
</div>
<div className="flex items-start gap-2">
{/* 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). */}
<UserCheck className="h-4 w-4 mt-0.5 text-violet-600 dark:text-violet-400 shrink-0" />
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Operator
</div>
{quote.claimed_by ? (
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
<Avatar size="sm" className="border border-violet-500/30">
<AvatarFallback className="bg-violet-500/10 text-violet-700 dark:text-violet-300 text-[10px] font-semibold">
{(claimerName || quote.claimed_by).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span
className="text-sm font-medium truncate"
title={claimerName || quote.claimed_by}
>
{claimerName ||
(quote.claimed_by.length > 14
? `${quote.claimed_by.slice(0, 14)}…`
: quote.claimed_by)}
</span>
</div>
) : (
<div className="text-sm text-muted-foreground italic">
Unclaimed
</div>
)}
</div>
</div>
</div>
{customerIsPlaceholder && (
<div className="mt-3 flex items-start gap-2 rounded-md border border-warn/30 bg-warn/5 p-2 text-xs text-warn-foreground">
@ -692,9 +801,19 @@ export default function ApprovalReviewPage() {
}
className="mt-0.5"
/>
<p className="text-sm whitespace-pre-wrap break-words flex-1 min-w-0">
{/* 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. */}
<p className="text-[15px] font-semibold leading-snug whitespace-pre-wrap break-words flex-1 min-w-0 text-foreground">
{it.description || (
<span className="text-muted-foreground italic">
<span className="text-muted-foreground italic font-normal">
(no description)
</span>
)}
@ -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 && (
<span className="ml-1 text-[10px] text-primary">edit</span>
<span className="ml-1 text-[10px] font-normal opacity-80">
edit
</span>
)}
</button>
</div>
@ -802,11 +939,32 @@ export default function ApprovalReviewPage() {
{qty}
</span>
</span>
<span>
Supplier:{" "}
<span className="text-foreground font-medium">
<span className="inline-flex items-center gap-1.5">
<span>Supplier:</span>
{/* 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. */}
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md border text-xs font-semibold",
it.expand?.supplier?.suppliername
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 dark:border-emerald-400/30 dark:bg-emerald-400/10"
: "border-border bg-muted text-muted-foreground"
)}
>
{it.expand?.supplier?.suppliername ?? "—"}
</span>
{/* 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. */}
<SupplierProofPopover itemId={it.id} />
</span>
<span>
Line total:{" "}
@ -921,8 +1079,8 @@ export default function ApprovalReviewPage() {
{/* ── Bottom action bar (only when approvable) ────────── */}
{isApprovable && (
<Card className="sticky bottom-4 z-10 shadow-lg">
<CardContent className="p-4">
<Card className="sticky bottom-0 z-20 shadow-[0_-4px_20px_-2px_rgba(0,0,0,0.15)] border-t-2 border-primary/30 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/85">
<CardContent className="p-3 sm:p-4">
{rejectOpen ? (
<div className="space-y-3">
<div>
@ -942,36 +1100,36 @@ export default function ApprovalReviewPage() {
autoFocus
/>
</div>
<div className="flex items-center justify-end gap-2">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setRejectOpen(false);
setRejectReason("");
}}
disabled={actionLoading !== null}
className="w-full sm:w-auto h-11 sm:h-8"
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => void handleReject()}
disabled={!rejectReason.trim() || actionLoading !== null}
className="w-full sm:w-auto h-11 sm:h-8"
>
{actionLoading === "reject" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 sm:h-3.5 sm:w-3.5 animate-spin" />
) : (
<XCircle className="mr-1 h-3 w-3" />
<XCircle className="mr-1 h-4 w-4 sm:h-3.5 sm:w-3.5" />
)}
Confirm reject
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-xs text-muted-foreground">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="text-xs text-muted-foreground text-center sm:text-left">
{hasPendingChanges ? (
<span className="text-primary font-medium">
{pendingChangesList.length} edit
@ -984,25 +1142,33 @@ export default function ApprovalReviewPage() {
<>Approving sends the email to the customer now.</>
)}
</div>
<div className="flex items-center gap-2">
{/* 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. */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:gap-2 w-full sm:w-auto">
<Button
variant="destructive"
size="sm"
onClick={() => setRejectOpen(true)}
disabled={actionLoading !== null}
className="w-full sm:w-auto h-11 sm:h-8"
>
<XCircle className="mr-1 h-3 w-3" />
<XCircle className="mr-1 h-4 w-4 sm:h-3.5 sm:w-3.5" />
Reject
</Button>
<Button
size="sm"
onClick={() => setSendOpen(true)}
disabled={actionLoading !== null || customerIsPlaceholder}
className="w-full sm:w-auto h-11 sm:h-8 font-semibold"
>
{actionLoading === "approve" ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
<Loader2 className="mr-1 h-4 w-4 sm:h-3.5 sm:w-3.5 animate-spin" />
) : (
<CheckCircle className="mr-1 h-3 w-3" />
<CheckCircle className="mr-1 h-4 w-4 sm:h-3.5 sm:w-3.5" />
)}
Approve &amp; send email
</Button>
@ -1104,8 +1270,10 @@ export default function ApprovalReviewPage() {
</div>
<div className="text-sm font-medium tabular-nums">
{formatCurrency(
getEffectiveCost(markupEditItem) *
(1 + getEffectiveMarkup(markupEditItem) / 100)
Math.round(
getEffectiveCost(markupEditItem) *
(1 + getEffectiveMarkup(markupEditItem) / 100),
),
)}
</div>
</div>
@ -1130,11 +1298,31 @@ export default function ApprovalReviewPage() {
</Button>
<div className="min-w-[5rem] text-center">
<div className="text-3xl font-bold tabular-nums text-primary">
{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)}%
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">
{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
</>
);
})()}
</div>
</div>
<Button
@ -1158,16 +1346,20 @@ export default function ApprovalReviewPage() {
</div>
<div className="text-xl font-bold tabular-nums">
{formatCurrency(
getEffectiveCost(markupEditItem) * (1 + markupDraft / 100)
Math.round(
getEffectiveCost(markupEditItem) *
(1 + markupDraft / 100),
),
)}
</div>
</div>
<div className="text-[10px] text-muted-foreground mt-1">
Line total:{" "}
{formatCurrency(
getEffectiveCost(markupEditItem) *
(1 + markupDraft / 100) *
(Number(markupEditItem.quantity) || 1)
Math.round(
getEffectiveCost(markupEditItem) *
(1 + markupDraft / 100),
) * (Number(markupEditItem.quantity) || 1)
)}{" "}
× qty
</div>
@ -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<number | null>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={cn(
"inline-flex items-center gap-0.5 rounded-md px-1 py-0.5 text-[10px] font-medium transition-colors",
"text-muted-foreground hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
// Highlight the button when we know there are proofs —
// visually announces "there's something here" before the
// rep opens the popover.
count !== null && count > 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}`}
>
<Paperclip className="h-3 w-3" />
{count !== null && count > 0 && (
<span className="tabular-nums">{count}</span>
)}
</PopoverTrigger>
<PopoverContent align="start" side="top" className="w-80">
<PopoverTitle className="text-sm">Pricing proof</PopoverTitle>
<PopoverDescription className="text-xs">
The office&apos;s audit trail for this line — supplier quotes,
price-list screenshots, datasheets.
</PopoverDescription>
{loading ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5 py-1">
<Loader2 className="h-3 w-3 animate-spin" />
Loading attachments…
</p>
) : items.length === 0 ? (
<p className="text-xs text-muted-foreground py-1">
No proof attached. The operator didn&apos;t upload anything
for this line — verify the price against the supplier&apos;s
own quote separately.
</p>
) : (
<ul className="space-y-1 max-h-60 overflow-y-auto">
{items.map((att) => (
<li
key={att.id}
className="flex items-center gap-2 rounded-md border bg-background px-2 py-1 text-xs"
>
{att.content_type?.startsWith("image/") ? (
<FileImageIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<a
href={`/api/v1/files/${att.collectionId ?? ""}/${att.id}/${att.file}`}
target="_blank"
rel="noreferrer"
className="flex-1 truncate text-primary hover:underline"
title={att.filename}
>
{att.filename}
</a>
<span className="text-muted-foreground tabular-nums shrink-0">
{formatSize(att.size_bytes || 0)}
</span>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
);
}

View file

@ -256,10 +256,25 @@ export default function ApprovalsPage() {
</Badge>
)}
</div>
{/* 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. */}
<p className="text-sm mb-1">
{quote.expand?.customer?.customer || "—"}
<span className="font-semibold text-sky-700 dark:text-sky-300">
{quote.expand?.customer?.customer || "—"}
</span>
{quote.expand?.customer?.expand?.company && (
<span className="text-muted-foreground"> — {quote.expand.customer.expand.company.company}</span>
<span className="font-medium text-sky-600/80 dark:text-sky-400/80">
{" — "}
{quote.expand.customer.expand.company.company}
</span>
)}
</p>
{quote.description && (

View file

@ -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(
`<p style="${BODY_STYLE}">${introLine}</p>` +
bullets +
`<p style="${SMALL_STYLE}">${escapeHtml(fallbackLine)}</p>` +
`<p style="${SIGN_STYLE}">Kind regards,<br>${escapeHtml(ctx.senderName || "The Sales Team")}<br>${escapeHtml(ctx.companyLetterhead)}</p>` +
`<p style="${SIGN_STYLE}">Kind regards,<br>${escapeHtml(ctx.senderName)}<br>${escapeHtml(ctx.companyLetterhead)}</p>` +
closing +
`</body></html>`;
return { text, html };
}
}