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:
parent
7d4e4c34f2
commit
cfc56f5c86
3 changed files with 422 additions and 55 deletions
|
|
@ -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 & 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'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't upload anything
|
||||
for this line — verify the price against the supplier'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue