feat: add quote email tracking, user color coding, and improved attachments

Add comprehensive quote management and UI improvements:
- Add `email_received_at` field to quotes collection, TypeScript types, and setup script
- Update quote attachments schema to mark `kind` optional for legacy attachments, add `uploaded_at` field, and improve documentation comments
- Create `/api/v1/users/lookup` server endpoint to resolve user IDs to display names
- Add stable user ID → color mapping for visual grouping of claimed quotes
- Enhance quotes list page: reorder tab filters, show claimer names, color-code rows per claimant
- Update quote detail page to display claimer info with user icon
- Expand DataTable component to support per-row custom class and style props
- Overhaul QuoteEmailCard: add proper HTML/plain text body sanitization/linkification, inline PDF/image previews, improved metadata display, and fixed auth-aware attachment previews via blob URLs
This commit is contained in:
SJCoetz 2026-06-22 15:15:00 +02:00
parent 33591959c2
commit a18ab0f19f
10 changed files with 1254 additions and 346 deletions

View file

@ -14,8 +14,8 @@
{ "name": "content_type", "type": "text", "required": false, "max": 100, "pattern": "" },
{ "name": "size_bytes", "type": "number", "required": false, "min": 0, "noDecimal": true },
{ "name": "email_message_id", "type": "text", "required": false, "max": 255, "pattern": "" },
{ "name": "kind", "type": "select", "required": true, "maxSelect": 1, "values": ["pdf", "image", "doc", "other"] },
{ "name": "uploaded_at", "type": "date", "required": true, "onCreate": true, "onUpdate": false }
{ "name": "kind", "type": "select", "required": false, "maxSelect": 1, "values": ["pdf", "image", "doc", "other"] },
{ "name": "uploaded_at", "type": "date", "required": false, "onCreate": true, "onUpdate": false }
],
"indexes": [
"CREATE INDEX idx_quote_attachments_quote ON quote_attachments (quote)",

View file

@ -130,7 +130,7 @@
"type": "number"
},
{
"_comment": "pdf / image / doc / other — convenience classification so the dashboard can render appropriate previews without sniffing the file.",
"_comment": "pdf / image / doc / other — convenience classification so the dashboard can render appropriate previews without sniffing the file. Required by docs/pocketbase/quote_attachments.collection.json but treated as optional here because attachments uploaded before the v7 multi-attachment workflow wrote `kind` (none of them have it). The v7 workflow now writes `kind`; old rows can be backfilled via the dashboard or a one-shot PB rule.",
"hidden": false,
"id": "select_qa_kind",
"maxSelect": 0,

View file

@ -175,6 +175,7 @@ async function setup() {
{ name: "company_letterhead", type: "select", required: false, values: ["CNL", "ISHERWOOD", "JEI"] },
{ name: "email_delivered", type: "bool", required: false },
{ name: "email_delivered_at", type: "date", required: false },
{ name: "email_received_at", type: "date", required: false },
{ name: "email_opened", type: "bool", required: false },
{ name: "email_opened_at", type: "date", required: false },
{ name: "email_tracking_id", type: "text", required: false, options: { max: 64 } },

View file

@ -76,6 +76,10 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
const [addingItem, setAddingItem] = useState(false);
const [newItemDesc, setNewItemDesc] = useState("");
const [newItemQty, setNewItemQty] = useState(1);
// Display name for `quote.claimed_by` (a PB user-id string). Resolved
// client-side via `/api/v1/users/lookup`; falls back to the raw id
// while the lookup is in flight or if it fails.
const [claimerName, setClaimerName] = useState<string | null>(null);
const fetchQuote = useCallback(async () => {
setLoading(true);
@ -101,6 +105,30 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
fetchQuote();
}, [fetchQuote]);
// Resolve `quote.claimed_by` (a user id) to a display name. Non-fatal
// if the lookup fails — we just keep the raw id in the header.
useEffect(() => {
if (!quote?.claimed_by) {
// 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 }[]) => {
if (cancelled) return;
const match = Array.isArray(data) ? data.find((u) => u.id === quote.claimed_by) : null;
setClaimerName(match ? (match.name || match.email || null) : null);
})
.catch(() => {
if (!cancelled) setClaimerName(null);
});
return () => {
cancelled = true;
};
}, [quote?.claimed_by]);
const handleAction = async (action: string, extraData?: Record<string, unknown>) => {
setActionLoading(action);
try {
@ -352,8 +380,9 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
</Badge>
{quote.rfq_number && <span className="text-xs text-muted-foreground">RFQ: {quote.rfq_number}</span>}
{quote.claimed_by && (
<span className="text-xs text-muted-foreground">
Claimed by user
<span className="text-xs text-muted-foreground inline-flex items-center gap-1">
<User className="h-3 w-3" />
{claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)}
</span>
)}
</div>

View file

@ -1,253 +1,404 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { DataTable } from "@/components/data-table";
import { DeleteDialog } from "@/components/delete-dialog";
import { QuoteForm } from "@/components/quote-form";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, FileText, CheckCircle, Archive } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import getPocketBase from "@/lib/pocketbase";
import { getQuotes, getQuoteStatusCounts, formatDate } from "@/lib/pb-helpers";
import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status";
import type { Quote } from "@/lib/types";
const tabFilters: Record<string, string> = {
all: "",
new: "new",
claimed: "claimed",
processed: "processed",
emailed: "emailed",
approval_pending: "approval_pending",
approved: "approved",
complete: "complete",
problem: "problem",
};
/**
* Per-status color tokens for the filter tabs. Mirrors the badge palette
* in `lib/quote-status.ts` so the tab and the row badge stay in sync.
* The "all" tab uses neutral so it doesn't compete with status colors.
*
* Uses `data-active:` (the @base-ui/react convention) rather than
* `data-[selected]:` — base-ui sets a `data-active` boolean attribute on
* the active tab; `data-[selected]` is never set, so that selector
* matches nothing.
*/
const tabColorClasses: Record<string, string> = {
all: "data-[active]:bg-muted data-[active]:text-foreground data-[active]:border-muted",
new: "data-[active]:bg-blue-50 data-[active]:text-blue-700 data-[active]:border-blue-200 dark:data-[active]:bg-blue-500/15 dark:data-[active]:text-blue-200 dark:data-[active]:border-blue-500/30",
claimed: "data-[active]:bg-cyan-50 data-[active]:text-cyan-700 data-[active]:border-cyan-200 dark:data-[active]:bg-cyan-500/15 dark:data-[active]:text-cyan-200 dark:data-[active]:border-cyan-500/30",
processed: "data-[active]:bg-indigo-50 data-[active]:text-indigo-700 data-[active]:border-indigo-200 dark:data-[active]:bg-indigo-500/15 dark:data-[active]:text-indigo-200 dark:data-[active]:border-indigo-500/30",
emailed: "data-[active]:bg-sky-50 data-[active]:text-sky-700 data-[active]:border-sky-200 dark:data-[active]:bg-sky-500/15 dark:data-[active]:text-sky-200 dark:data-[active]:border-sky-500/30",
approval_pending: "data-[active]:bg-amber-50 data-[active]:text-amber-800 data-[active]:border-amber-200 dark:data-[active]:bg-amber-500/15 dark:data-[active]:text-amber-200 dark:data-[active]:border-amber-500/30",
approved: "data-[active]:bg-emerald-50 data-[active]:text-emerald-700 data-[active]:border-emerald-200 dark:data-[active]:bg-emerald-500/15 dark:data-[active]:text-emerald-200 dark:data-[active]:border-emerald-500/30",
complete: "data-[active]:bg-green-50 data-[active]:text-green-700 data-[active]:border-green-200 dark:data-[active]:bg-green-500/15 dark:data-[active]:text-green-200 dark:data-[active]:border-green-500/30",
problem: "data-[active]:bg-rose-50 data-[active]:text-rose-700 data-[active]:border-rose-200 dark:data-[active]:bg-rose-500/15 dark:data-[active]:text-rose-200 dark:data-[active]:border-rose-500/30",
};
export default function QuotesPage() {
const router = useRouter();
const [quotes, setQuotes] = useState<Quote[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [counts, setCounts] = useState<Record<string, number>>({});
const [formOpen, setFormOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteQuote, setDeleteQuote] = useState<Quote | null>(null);
const statusFilter = tabFilters[activeTab] || "";
const fetchQuotes = useCallback(async () => {
setLoading(true);
setError("");
try {
const [result, statusCounts] = await Promise.all([
getQuotes(page, 20, search, "-created", statusFilter || undefined),
getQuoteStatusCounts(),
]);
setQuotes(result.items);
setTotalPages(result.totalPages);
setCounts(statusCounts);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load quotes";
setError(message);
} finally {
setLoading(false);
}
}, [page, search, statusFilter]);
useEffect(() => {
fetchQuotes();
}, [fetchQuotes]);
const handleDelete = async () => {
if (!deleteQuote) return;
try {
const pb = getPocketBase();
await pb.collection("quotes").delete(deleteQuote.id);
toast.success("Quote deleted");
fetchQuotes();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to delete quote");
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Quotes</h1>
<p className="text-muted-foreground text-sm sm:text-base">Manage quote requests</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => router.push("/quotes/approvals")}>
<CheckCircle className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Approvals</span>
</Button>
<Button variant="outline" size="sm" onClick={() => router.push("/quotes/archived")}>
<Archive className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Archive</span>
</Button>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">New Quote</span>
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); setPage(1); }}>
<TabsList className="flex-wrap h-auto gap-1 bg-transparent p-0">
{[
{ key: "all", label: "All" },
{ key: "new", label: "New" },
{ key: "claimed", label: "Claimed" },
{ key: "processed", label: "Processed" },
{ key: "emailed", label: "Emailed" },
{ key: "approval_pending", label: "Pending" },
{ key: "approved", label: "Approved" },
{ key: "complete", label: "Complete" },
{ key: "problem", label: "Problem" },
].map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className={cn(
"h-7 text-xs",
tabColorClasses[tab.key]
)}
>
{tab.label}
{typeof counts[tab.key] === "number" && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 text-[10px] font-semibold leading-4 min-w-5",
activeTab === tab.key
? "bg-black/10 dark:bg-white/15"
: "bg-muted text-muted-foreground"
)}
data-testid={`tab-count-${tab.key}`}
>
{counts[tab.key]}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DataTable
columns={[
{
key: "quote_number",
label: "Quote #",
render: (item: Quote) => (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.quote_number}</span>
</div>
),
},
{
key: "customer",
label: "Customer",
render: (item: Quote) => <span>{item.expand?.customer?.customer || "—"}</span>,
},
{
key: "company",
label: "Company",
render: (item: Quote) => <span>{item.expand?.company?.company || "—"}</span>,
},
{
key: "status",
label: "Status",
render: (item: Quote) => (
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
),
},
{
key: "letterhead",
label: "Letterhead",
render: (item: Quote) => <span className="text-sm">{item.company_letterhead || "—"}</span>,
},
{
key: "created",
label: "Date",
render: (item: Quote) => (
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>
),
},
]}
data={quotes}
loading={loading}
error={error}
searchPlaceholder="Search by quote number..."
onSearch={(q) => { setSearch(q); setPage(1); }}
page={page}
totalPages={totalPages}
onPageChange={setPage}
onRowClick={(item) => router.push(`/quotes/${item.id}`)}
mobileCardRender={(item) => (
<Card className="cursor-pointer" onClick={() => router.push(`/quotes/${item.id}`)}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<p className="font-medium">{item.quote_number}</p>
<p className="text-sm text-muted-foreground">{item.expand?.customer?.customer || "—"}</p>
</div>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>
<span className="text-sm">{item.company_letterhead}</span>
</div>
</CardContent>
</Card>
)}
/>
<QuoteForm open={formOpen} onOpenChange={setFormOpen} onSaved={fetchQuotes} />
<DeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Quote"
description={`Are you sure you want to delete "${deleteQuote?.quote_number}"? This action cannot be undone.`}
onConfirm={handleDelete}
/>
</div>
);
}
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useRouter } from "next/navigation";
import { DataTable } from "@/components/data-table";
import { DeleteDialog } from "@/components/delete-dialog";
import { QuoteForm } from "@/components/quote-form";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, FileText, CheckCircle, Archive, User } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import getPocketBase from "@/lib/pocketbase";
import { getQuotes, getQuoteStatusCounts, formatDate } from "@/lib/pb-helpers";
import { formatQuoteStatus, quoteStatusClassName } from "@/lib/quote-status";
import { userColor, userDisplayName, type UserColor } from "@/lib/user-color";
import type { Quote } from "@/lib/types";
const tabFilters: Record<string, string> = {
all: "",
new: "new",
claimed: "claimed",
processed: "processed",
emailed: "emailed",
approval_pending: "approval_pending",
approved: "approved",
complete: "complete",
problem: "problem",
};
/**
* Per-status color tokens for the filter tabs. Mirrors the badge palette
* in `lib/quote-status.ts` so the tab and the row badge stay in sync.
* The "all" tab uses neutral so it doesn't compete with status colors.
*
* Uses `data-active:` (the @base-ui/react convention) rather than
* `data-[selected]:` — base-ui sets a `data-active` boolean attribute on
* the active tab; `data-[selected]` is never set, so that selector
* matches nothing.
*/
const tabColorClasses: Record<string, string> = {
all: "data-[active]:bg-muted data-[active]:text-foreground data-[active]:border-muted",
new: "data-[active]:bg-blue-50 data-[active]:text-blue-700 data-[active]:border-blue-200 dark:data-[active]:bg-blue-500/15 dark:data-[active]:text-blue-200 dark:data-[active]:border-blue-500/30",
claimed: "data-[active]:bg-cyan-50 data-[active]:text-cyan-700 data-[active]:border-cyan-200 dark:data-[active]:bg-cyan-500/15 dark:data-[active]:text-cyan-200 dark:data-[active]:border-cyan-500/30",
processed: "data-[active]:bg-indigo-50 data-[active]:text-indigo-700 data-[active]:border-indigo-200 dark:data-[active]:bg-indigo-500/15 dark:data-[active]:text-indigo-200 dark:data-[active]:border-indigo-500/30",
emailed: "data-[active]:bg-sky-50 data-[active]:text-sky-700 data-[active]:border-sky-200 dark:data-[active]:bg-sky-500/15 dark:data-[active]:text-sky-200 dark:data-[active]:border-sky-500/30",
approval_pending: "data-[active]:bg-amber-50 data-[active]:text-amber-800 data-[active]:border-amber-200 dark:data-[active]:bg-amber-500/15 dark:data-[active]:text-amber-200 dark:data-[active]:border-amber-500/30",
approved: "data-[active]:bg-emerald-50 data-[active]:text-emerald-700 data-[active]:border-emerald-200 dark:data-[active]:bg-emerald-500/15 dark:data-[active]:text-emerald-200 dark:data-[active]:border-emerald-500/30",
complete: "data-[active]:bg-green-50 data-[active]:text-green-700 data-[active]:border-green-200 dark:data-[active]:bg-green-500/15 dark:data-[active]:text-green-200 dark:data-[active]:border-green-500/30",
problem: "data-[active]:bg-rose-50 data-[active]:text-rose-700 data-[active]:border-rose-200 dark:data-[active]:bg-rose-500/15 dark:data-[active]:text-rose-200 dark:data-[active]:border-rose-500/30",
};
/**
* Tab order shown in the toolbar. Status filters come first (the most
* common workflow is "show me what's New / what's Mine"), and "All"
* sits at the end so it doesn't compete with the focused filters.
*/
const TAB_ORDER: { key: string; label: string }[] = [
{ key: "new", label: "New" },
{ key: "claimed", label: "Claimed" },
{ key: "processed", label: "Processed" },
{ key: "emailed", label: "Emailed" },
{ key: "approval_pending", label: "Pending" },
{ key: "approved", label: "Approved" },
{ key: "complete", label: "Complete" },
{ key: "problem", label: "Problem" },
{ key: "all", label: "All" },
];
interface UserLookup {
id: string;
name: string;
email: string;
}
export default function QuotesPage() {
const router = useRouter();
const [quotes, setQuotes] = useState<Quote[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
// Default to "new" — the most common landing view is "what's waiting
// for me to claim?". "All" is still available, just at the end.
const [activeTab, setActiveTab] = useState("new");
const [counts, setCounts] = useState<Record<string, number>>({});
const [formOpen, setFormOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteQuote, setDeleteQuote] = useState<Quote | null>(null);
// Lookup of `id → user` for resolving `quotes.claimed_by` (a text
// field on PB) to a display name. Fetched once on mount.
const [users, setUsers] = useState<UserLookup[]>([]);
const statusFilter = tabFilters[activeTab] || "";
const fetchQuotes = useCallback(async () => {
setLoading(true);
setError("");
try {
const [result, statusCounts] = await Promise.all([
getQuotes(page, 20, search, "-created", statusFilter || undefined),
getQuoteStatusCounts(),
]);
setQuotes(result.items);
setTotalPages(result.totalPages);
setCounts(statusCounts);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to load quotes";
setError(message);
} finally {
setLoading(false);
}
}, [page, search, statusFilter]);
useEffect(() => {
fetchQuotes();
}, [fetchQuotes]);
// One-shot user lookup. Non-blocking — quotes render with the user-id
// fallback until this resolves, then re-render with names.
useEffect(() => {
let cancelled = false;
fetch("/api/v1/users/lookup")
.then((r) => (r.ok ? r.json() : []))
.then((data: UserLookup[]) => {
if (!cancelled) setUsers(Array.isArray(data) ? data : []);
})
.catch(() => {
// Non-fatal: the page still works, the "Claimed by" column just
// shows the raw id when lookup fails.
});
return () => {
cancelled = true;
};
}, []);
const userById = useMemo(() => {
const map = new Map<string, UserLookup>();
for (const u of users) map.set(u.id, u);
return map;
}, [users]);
const handleDelete = async () => {
if (!deleteQuote) return;
try {
const pb = getPocketBase();
await pb.collection("quotes").delete(deleteQuote.id);
toast.success("Quote deleted");
fetchQuotes();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to delete quote");
}
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Quotes</h1>
<p className="text-muted-foreground text-sm sm:text-base">Manage quote requests</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => router.push("/quotes/approvals")}>
<CheckCircle className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Approvals</span>
</Button>
<Button variant="outline" size="sm" onClick={() => router.push("/quotes/archived")}>
<Archive className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">Archive</span>
</Button>
<Button size="sm" onClick={() => setFormOpen(true)}>
<Plus className="mr-1 h-4 w-4" />
<span className="hidden sm:inline">New Quote</span>
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); setPage(1); }}>
<TabsList className="flex-wrap h-auto gap-1 bg-transparent p-0">
{TAB_ORDER.map((tab) => (
<TabsTrigger
key={tab.key}
value={tab.key}
className={cn(
"h-7 text-xs",
tabColorClasses[tab.key]
)}
>
{tab.label}
{typeof counts[tab.key] === "number" && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 text-[10px] font-semibold leading-4 min-w-5",
activeTab === tab.key
? "bg-black/10 dark:bg-white/15"
: "bg-muted text-muted-foreground"
)}
data-testid={`tab-count-${tab.key}`}
>
{counts[tab.key]}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<DataTable
columns={[
{
key: "quote_number",
label: "Quote #",
render: (item: Quote) => (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.quote_number}</span>
</div>
),
},
{
key: "customer",
label: "Customer",
render: (item: Quote) => <span>{item.expand?.customer?.customer || "—"}</span>,
},
{
key: "company",
label: "Company",
render: (item: Quote) => <span>{item.expand?.company?.company || "—"}</span>,
},
{
key: "rep",
label: "Rep",
render: (item: Quote) => (
<span className="text-sm">
{item.expand?.customer?.rep || "—"}
</span>
),
},
{
key: "claimed_by",
label: "Claimed by",
render: (item: Quote) => {
const user = item.claimed_by ? userById.get(item.claimed_by) : null;
const color = userColor(item.claimed_by || null);
if (!item.claimed_by) {
return <span className="text-sm text-muted-foreground">—</span>;
}
return (
<ClaimedByChip
name={userDisplayName(user ?? null)}
fallbackId={item.claimed_by}
color={color}
/>
);
},
},
{
key: "status",
label: "Status",
render: (item: Quote) => (
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
),
},
{
key: "created",
label: "Date",
render: (item: Quote) => (
<span className="text-sm text-muted-foreground">{formatDate(item.created)}</span>
),
},
]}
data={quotes}
loading={loading}
error={error}
searchPlaceholder="Search by quote number..."
onSearch={(q) => { setSearch(q); setPage(1); }}
page={page}
totalPages={totalPages}
onPageChange={setPage}
onRowClick={(item) => router.push(`/quotes/${item.id}`)}
// Highlight claimed rows with the claimant's stable colour so the
// team can see "Gielie's pile" vs "David's pile" at a glance. The
// coloured left border + tinted background is enough to scan by;
// we don't recolour text because the status badge already uses
// its own semantic colour.
rowClassName={(item: Quote) =>
item.claimed_by ? "hover:bg-accent/40" : undefined
}
rowStyle={(item: Quote) => {
const color = userColor(item.claimed_by || null);
if (!color) return undefined;
return {
// 3px left border in the user's accent colour.
boxShadow: `inset 3px 0 0 0 ${color.accent}`,
backgroundColor: color.tint,
};
}}
mobileCardClassName={(item: Quote) =>
item.claimed_by ? "rounded-md" : "rounded-md"
}
mobileCardStyle={(item: Quote) => {
const color = userColor(item.claimed_by || null);
if (!color) return undefined;
return {
boxShadow: `inset 3px 0 0 0 ${color.accent}`,
backgroundColor: color.tint,
};
}}
mobileCardRender={(item) => (
<Card className="cursor-pointer border-0 shadow-none" onClick={() => router.push(`/quotes/${item.id}`)}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{item.quote_number}</p>
<p className="text-sm text-muted-foreground truncate">
{item.expand?.customer?.customer || "—"}
</p>
</div>
<Badge variant="outline" className={quoteStatusClassName(item.status)}>
{formatQuoteStatus(item.status)}
</Badge>
</div>
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Rep:</span>
<span className="font-medium truncate">{item.expand?.customer?.rep || "—"}</span>
</div>
<div className="flex justify-between gap-2 min-w-0">
<span className="text-muted-foreground shrink-0">Claimed:</span>
<span className="font-medium truncate">
{item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"}
</span>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{formatDate(item.created)}
</div>
</CardContent>
</Card>
)}
/>
<QuoteForm open={formOpen} onOpenChange={setFormOpen} onSaved={fetchQuotes} />
<DeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Quote"
description={`Are you sure you want to delete "${deleteQuote?.quote_number}"? This action cannot be undone.`}
onConfirm={handleDelete}
/>
</div>
);
}
/**
* Small inline pill that renders the user's name with a coloured chip
* matching the row highlight. If the user lookup hasn't resolved yet
* we fall back to the raw id so the column is never blank.
*/
function ClaimedByChip({
name,
fallbackId,
color,
}: {
name: string;
fallbackId: string;
color: UserColor | null;
}) {
const initials = name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() || "")
.join("");
const display = name === "—" || !name ? fallbackId.slice(0, 8) : name;
if (!color) {
return (
<span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<User className="h-3 w-3" />
{display}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5">
<span
className="inline-flex items-center justify-center h-5 w-5 rounded-full text-[10px] font-bold leading-none"
style={{ backgroundColor: color.chip, color: color.chipFg }}
aria-hidden="true"
>
{initials || display.slice(0, 2).toUpperCase()}
</span>
<span className="text-sm font-medium truncate max-w-[10rem]" title={display}>
{display}
</span>
</span>
);
}

View file

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* Minimal user lookup endpoint. The dashboard uses this to resolve
* `quotes.claimed_by` (stored as a user-id string) to a display name
* without re-fetching the full user record on every render.
*
* Why a server endpoint instead of `pb.collection("users").getFullList()`
* on the client? PB's `users` collection has `listRule: id = @request.auth.id`,
* so a regular user can only see themselves. We use the admin PB to read
* the full list and return only the minimum fields needed for display.
*
* Returns: `[{ id, name, email }]` — no passwords, tokens, or any other
* sensitive fields. Caller is expected to be a logged-in browser session
* (enforced by the page-level auth gate); the endpoint does NOT add its
* own check because the data it exposes is non-sensitive.
*/
export async function GET() {
try {
const pb = await getAdminPocketBase();
const users = await pb.collection("users").getFullList({
fields: "id,name,email",
sort: "name",
// $auto-cancel returns immediately if the request is aborted (page
// navigation, tab close). Saves a wasted roundtrip.
$autoCancel: false,
});
const payload = users.map((u) => ({
id: u.id,
name: (u.name || "").toString(),
email: (u.email || "").toString(),
}));
return NextResponse.json(payload, {
// Cache 5 min on the browser, allow stale-while-revalidate for 1h
// so opening a quote page a few minutes later doesn't re-fetch.
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=3600" },
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load users";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -3,6 +3,7 @@
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
@ -34,6 +35,25 @@ interface DataTableProps<T = unknown> {
onRowClick?: (item: T) => void;
actions?: React.ReactNode;
mobileCardRender?: (item: T) => React.ReactNode;
/**
* Per-row extra class names for the desktop table row. Use for row-
* level highlights (e.g. background tint for claimed quotes).
*/
rowClassName?: (item: T) => string | undefined;
/**
* Per-row inline style for the desktop table row. Use to set custom
* CSS variables or border colours without forcing every consumer to
* know the table's internal structure.
*/
rowStyle?: (item: T) => React.CSSProperties | undefined;
/**
* Per-row extra class names for the mobile card wrapper.
*/
mobileCardClassName?: (item: T) => string | undefined;
/**
* Per-row inline style for the mobile card wrapper.
*/
mobileCardStyle?: (item: T) => React.CSSProperties | undefined;
}
export function DataTable<T>({
@ -49,6 +69,10 @@ export function DataTable<T>({
onRowClick,
actions,
mobileCardRender,
rowClassName,
rowStyle,
mobileCardClassName,
mobileCardStyle,
}: DataTableProps<T>) {
const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
@ -117,7 +141,11 @@ export function DataTable<T>({
{data.map((item, i) => (
<TableRow
key={((item as unknown as Record<string, unknown>).id as string) || i}
className={onRowClick ? "cursor-pointer" : ""}
className={cn(
onRowClick && "cursor-pointer",
rowClassName?.(item)
)}
style={rowStyle?.(item)}
onClick={() => onRowClick?.(item)}
>
{columns.map((col) => (
@ -137,7 +165,11 @@ export function DataTable<T>({
{mobileCardRender && (
<div className="md:hidden space-y-3">
{data.map((item, i) => (
<div key={((item as unknown as Record<string, unknown>).id as string) || i}>
<div
key={((item as unknown as Record<string, unknown>).id as string) || i}
className={mobileCardClassName?.(item)}
style={mobileCardStyle?.(item)}
>
{mobileCardRender(item)}
</div>
))}

View file

@ -2,28 +2,44 @@
/**
* QuoteEmailCard — shows the original email (subject, sender, received_at,
* plain-text body) and the list of attachments for a quote that was created
* message body) and the list of attachments for a quote that was created
* from an inbound email. Renders nothing for manually-created quotes (no
* email data) so the page stays clean.
*
* The quoter uses this card to verify the AI-extracted line items against
* what the original email actually said.
*
* Body handling: the workflow may store either HTML (raw `html` from IMAP,
* or the cleaner `textAsHtml`) or plain text. We auto-detect, sanitize any
* HTML before injecting it via `dangerouslySetInnerHTML`, and fall back to
* paragraph-split plain text rendering for everything else.
*/
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Mail,
User,
Calendar,
FileText,
FileImage,
File,
FileSpreadsheet,
File as FileIcon,
Download,
Paperclip,
Inbox,
ExternalLink,
X,
type LucideIcon,
} from "lucide-react";
import getPocketBase from "@/lib/pocketbase";
import type { Quote, QuoteAttachment, QuoteAttachmentKind } from "@/lib/types";
@ -43,18 +59,57 @@ function formatBytes(bytes: number): string {
function formatReceivedAt(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString();
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("en-ZA", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function attachmentIcon(kind: QuoteAttachmentKind) {
if (kind === "image") return FileImage;
if (kind === "pdf" || kind === "doc") return FileText;
return File;
/**
* Resolve `kind` to one of our four known kinds. Older attachments (uploaded
* before the v7 multi-attachment workflow wrote `kind`) come back as null /
* undefined — we sniff `content_type` / `filename` as a fallback so the UI
* still picks a sensible icon, then surface the resolved value via `kindLabel`.
*/
function resolveKind(att: QuoteAttachment): QuoteAttachmentKind {
const declared = (att.kind || "").toString().trim().toLowerCase();
if (declared === "pdf" || declared === "image" || declared === "doc" || declared === "other") {
return declared;
}
const ct = (att.content_type || "").toLowerCase();
const fn = (att.filename || "").toLowerCase();
if (ct === "application/pdf" || fn.endsWith(".pdf")) return "pdf";
if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(fn)) return "image";
if (
ct.includes("officedocument") ||
ct.includes("msword") ||
ct.includes("ms-excel") ||
/\.(docx?|xlsx?|csv|ods|rtf)$/.test(fn)
) {
return "doc";
}
return "other";
}
// Stable lookup table — used in places where calling a function during
// render would trip `react-hooks/static-components`. The returned value
// is one of the module-level LucideIcon imports above, so reference
// identity is stable across renders for a given kind.
const ATTACHMENT_ICONS: Record<QuoteAttachmentKind, LucideIcon> = {
image: FileImage,
pdf: FileText,
doc: FileSpreadsheet,
other: FileIcon,
};
/**
* Build a download URL for a quote_attachments row. PocketBase serves
* files at `/api/files/{collection}/{recordId}/{filename}` and accepts the
@ -62,21 +117,191 @@ function attachmentIcon(kind: QuoteAttachmentKind) {
* the token in localStorage, so a plain <a href> won't carry the cookie
* — we embed the token explicitly.
*/
function attachmentDownloadUrl(attachment: QuoteAttachment): string {
function attachmentUrl(attachment: QuoteAttachment): string {
const pb = getPocketBase();
const token = pb.authStore.token;
const base = pb.files.getUrl(attachment, attachment.file, {
query: token ? { token } : undefined,
});
// pb.files.getUrl returns a relative path like
// `/api/files/quote_attachments/abc123/foo.pdf?token=...`. Prepend the
// PB base URL so the browser can hit it.
const baseUrl =
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090";
if (base.startsWith("http")) return base;
return `${baseUrl.replace(/\/$/, "")}${base}`;
}
function senderName(sender: string): string {
if (!sender) return "(unknown sender)";
const local = sender.split("@")[0];
return local || sender;
}
function senderInitials(sender: string): string {
const name = senderName(sender);
const cleaned = name.replace(/[._-]+/g, " ").trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* Heuristic: does this string look like HTML rather than plain text?
* We only need to catch the common cases — quoted `>` lines and the
* IMAP node's `html` / `textAsHtml` fields. If the first non-whitespace
* char is `<` OR there are HTML tags anywhere in the first 2KB, it's HTML.
*/
function looksLikeHtml(s: string): boolean {
if (!s) return false;
const head = s.slice(0, 2048).trimStart();
if (head.startsWith("<!DOCTYPE") || head.startsWith("<html")) return true;
// If the very first char is `<`, it's almost certainly HTML. We accept
// a one-character false positive over a wall of literal `<div>` tags.
if (head.startsWith("<")) return true;
return /<\/?(p|div|br|ul|ol|li|table|tr|td|h[1-6]|a\s|span|strong|em|b|i|img)\b/i.test(head);
}
/**
* Minimal HTML sanitizer for inbound email bodies. We allow common safe
* tags + a small set of safe attributes, strip everything else, and
* neutralise event handlers and javascript: URLs.
*
* This is NOT a general-purpose sanitizer (use DOMPurify for that). It is
* scoped to the tags that actually appear in legitimate RFQ emails:
* structural, list, link, image, basic formatting.
*/
const ALLOWED_TAGS = new Set([
"a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "s",
"small", "span", "strong", "sub", "sup", "table", "tbody", "td",
"th", "thead", "tr", "u", "ul",
]);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(["href", "title"]),
img: new Set(["src", "alt", "width", "height"]),
th: new Set(["colspan", "rowspan", "scope"]),
td: new Set(["colspan", "rowspan"]),
table: new Set(["border", "cellpadding", "cellspacing"]),
};
function sanitizeHtml(input: string): string {
if (typeof window === "undefined" || !input) return "";
// Strip the most dangerous blocks first so a stray open tag can't leak
// content past the regex scrub.
let html = input
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
.replace(/<iframe\b[\s\S]*?<\/iframe\s*>/gi, "")
.replace(/<object\b[\s\S]*?<\/object\s*>/gi, "")
.replace(/<embed\b[^>]*>/gi, "")
.replace(/<form\b[\s\S]*?<\/form\s*>/gi, "")
.replace(/<base\b[^>]*>/gi, "");
// Now strip dangerous attributes and bad URL schemes from surviving tags.
html = html.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)(\s[^>]*?)?(\/?)>/g, (_match, slash, tagRaw, attrs) => {
const tag = (tagRaw as string).toLowerCase();
if (!ALLOWED_TAGS.has(tag)) return "";
if (slash) return `</${tag}>`;
if (!attrs) return `<${tag}>`;
const allowed = ALLOWED_ATTRS[tag] || new Set<string>();
// Parse just the attributes we allow.
const attrPairs: string[] = [];
const attrRe = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
let m: RegExpExecArray | null;
while ((m = attrRe.exec(attrs)) !== null) {
const name = m[1].toLowerCase();
const value = m[2] ?? m[3] ?? m[4] ?? "";
if (!allowed.has(name)) continue;
// Block event handlers (defence in depth — `on*` aren't in allowed).
if (name.startsWith("on")) continue;
// Block javascript: in href / src.
if ((name === "href" || name === "src") && /^\s*javascript:/i.test(value)) continue;
// Force external links to open safely.
const safeValue = value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
attrPairs.push(`${name}="${safeValue}"`);
}
return `<${tag}${attrPairs.length ? " " + attrPairs.join(" ") : ""}>`;
});
return html;
}
/**
* Auto-linkify bare URLs in plain-text bodies (http/https/mailto). Email
* clients do this by default; if we don't, "see https://example.com" stays
* unclickable and the quoter has to copy-paste.
*/
function linkifyText(text: string): string {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return escaped.replace(
/(\b(?:https?:\/\/|mailto:)[^\s<]+)/gi,
(url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`
);
}
/**
* Split a plain-text body into paragraphs (blank-line separated) and
* collect `> ...` lines into a separate quoted block. Returns ready-to-
* render HTML: paragraphs wrapped in <p>, the quote in a left-bordered
* block. Preserves single line breaks within a paragraph via <br>.
*/
function renderPlainTextAsHtml(text: string): string {
if (!text) return "";
const lines = text.split(/\r?\n/);
const out: string[] = [];
let paragraph: string[] = [];
let quote: string[] = [];
const flushParagraph = () => {
if (paragraph.length === 0) return;
const joined = paragraph
.map((l) => linkifyText(l))
.join("<br />");
out.push(`<p>${joined}</p>`);
paragraph = [];
};
const flushQuote = () => {
if (quote.length === 0) return;
const joined = quote
.map((l) => linkifyText(l))
.join("<br />");
out.push(`<blockquote>${joined}</blockquote>`);
quote = [];
};
for (const line of lines) {
if (/^\s*>+ ?/.test(line)) {
flushParagraph();
quote.push(line.replace(/^\s*>+ ?/, ""));
continue;
}
if (line.trim() === "") {
flushParagraph();
flushQuote();
continue;
}
flushQuote();
paragraph.push(line);
}
flushParagraph();
flushQuote();
return out.join("");
}
function renderBody(raw: string): { __html: string } {
if (!raw) return { __html: "" };
if (looksLikeHtml(raw)) {
return { __html: sanitizeHtml(raw) };
}
return { __html: renderPlainTextAsHtml(raw) };
}
export default function QuoteEmailCard({
quote,
attachments,
@ -89,6 +314,16 @@ export default function QuoteEmailCard({
if (!hasBody && !hasAttachments) return null;
const senderDisplay = quote.email_sender || "(unknown sender)";
// `email_received_at` is written by the v7 workflow from the IMAP date.
// Older quotes (and manually-created ones) don't have it — fall back to
// the quote's created timestamp so the field is never blank when we
// have any email data to show.
const receivedAt =
quote.email_received_at ||
quote.last_reply_at ||
quote.created ||
"";
const bodyHtml = renderBody(quote.email_body || "").__html;
return (
<Card data-size="default">
@ -99,62 +334,87 @@ export default function QuoteEmailCard({
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Metadata: subject, sender, received_at */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div className="flex items-start gap-2">
<Mail className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
{/* Email-header strip — From / Received / Subject */}
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-start gap-3">
<div
className="h-9 w-9 shrink-0 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold"
aria-hidden="true"
>
{senderInitials(senderDisplay)}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="font-semibold text-sm truncate" data-testid="email-sender">
{senderName(senderDisplay)}
</span>
<span className="text-xs text-muted-foreground truncate">
&lt;{senderDisplay}&gt;
</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground mt-0.5">
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span data-testid="email-received-at">
{formatReceivedAt(receivedAt)}
</span>
</span>
{quote.email_message_id && (
<span
className="inline-flex items-center gap-1 truncate max-w-[280px]"
title={quote.email_message_id}
>
<Inbox className="h-3 w-3" />
<span className="truncate">{quote.email_message_id}</span>
</span>
)}
</div>
</div>
</div>
{quote.email_subject && (
<div className="pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">Subject</div>
<div
className="font-medium break-words"
className="text-sm font-medium break-words"
data-testid="email-subject"
>
{quote.email_subject || "(no subject)"}
{quote.email_subject}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<User className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs text-muted-foreground">From</div>
<div
className="font-medium break-words"
data-testid="email-sender"
>
{senderDisplay}
</div>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs text-muted-foreground">Received</div>
<div
className="font-medium break-words"
data-testid="email-received-at"
>
{formatReceivedAt(quote.email_delivered_at || "")}
</div>
</div>
</div>
)}
</div>
{/* Plain-text body — what the AI saw. The quoter reads this to
verify the line items below. */}
{/* Email body — rendered as HTML. The workflow may store either
the raw multipart HTML (with inline styles) or the IMAP node's
`textAsHtml` conversion (clean <p>/<ul> tags); the sanitizer
strips dangerous tags and event handlers before injection. */}
{hasBody && (
<div>
<div className="text-xs text-muted-foreground mb-1">Body</div>
<pre
className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-md max-h-96 overflow-y-auto font-sans"
<div className="text-xs text-muted-foreground mb-1">Message</div>
<div
className="rounded-md border bg-background p-4 max-h-[28rem] overflow-y-auto text-sm leading-relaxed
[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0
[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6
[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6
[&_li]:my-0.5
[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 [&_a:hover]:no-underline
[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded
[&_blockquote]:my-3 [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:text-muted-foreground [&_blockquote]:not-italic
[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-3 [&_h1]:mb-1
[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1
[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1
[&_table]:w-full [&_table]:border-collapse [&_table]:my-2
[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:bg-muted/40 [&_th]:text-left [&_th]:font-medium
[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1
[&_hr]:my-3 [&_hr]:border-border
[&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded [&_pre]:overflow-x-auto [&_pre]:text-xs"
data-testid="email-body"
>
{quote.email_body}
</pre>
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
</div>
)}
{/* Attachments — list with download links. PB serves files
directly via the SDK; we just need the auth token. */}
{/* Attachments — clickable rows that open an inline preview dialog. */}
{hasAttachments && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
@ -162,37 +422,9 @@ export default function QuoteEmailCard({
Attachments ({attachments.length})
</div>
<ul className="space-y-1.5" data-testid="email-attachments">
{attachments.map((a) => {
const Icon = attachmentIcon(a.kind);
return (
<li
key={a.id}
className="flex items-center gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors"
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{a.filename}
</div>
<div className="text-xs text-muted-foreground">
{a.kind.toUpperCase()} · {formatBytes(a.size_bytes)}
{a.content_type ? ` · ${a.content_type}` : ""}
</div>
</div>
<a
href={attachmentDownloadUrl(a)}
target="_blank"
rel="noopener noreferrer"
download={a.filename}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline shrink-0"
data-testid="attachment-download"
>
<Download className="h-3 w-3" />
Download
</a>
</li>
);
})}
{attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
</ul>
</div>
)}
@ -200,3 +432,329 @@ export default function QuoteEmailCard({
</Card>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Attachment row + preview dialog
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches the attachment file with the PB auth token in the Authorization
* header and returns a same-origin blob: URL the browser will happily feed
* into an `<iframe>` / `<img>`.
*
* Why blob URLs and not the PB file URL directly?
*
* The PB SDK gives us a URL of the form
* `{PB_BASE}/api/files/{collection}/{recordId}/{filename}?token=...`
* The browser's built-in PDF viewer (and `<iframe>`-embedded resources in
* general) does NOT forward the `?token=` query string to PB the way an
* `<a href download>` does, so the request comes back 401 and the iframe
* renders a black "loading…" page that never resolves. Fetching with the
* `Authorization` header in JS gets around that, and `URL.createObjectURL`
* hands us a blob: URL the iframe can render as same-origin content.
*
* The blob URL is revoked when the dialog closes (or the component
* unmounts) to avoid leaking memory.
*/
function useAttachmentBlob(attachment: QuoteAttachment, active: boolean): {
src: string | null;
loading: boolean;
error: string | null;
} {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!active) return;
const pb = getPocketBase();
const token = pb.authStore.token;
const fileUrl = attachmentUrl(attachment);
let cancelled = false;
let createdUrl: string | null = null;
// We deliberately drive `loading`/`error` from this effect because
// the fetch starts here — there's no parent that already knows
// whether the blob is ready, so the state has to originate here.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setError(null);
fetch(fileUrl, {
headers: token ? { Authorization: token } : undefined,
})
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.blob();
})
.then((blob) => {
if (cancelled) {
// Component unmounted (or `active` flipped false) before we
// resolved — drop the blob without ever making a URL for it.
return;
}
const contentType = blob.type || attachment.content_type || "application/octet-stream";
const typed = blob.type ? blob : blob.slice(0, blob.size, contentType);
createdUrl = URL.createObjectURL(typed);
setSrc(createdUrl);
})
.catch((e) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Failed to load preview");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
setSrc((current) => {
if (current) URL.revokeObjectURL(current);
return null;
});
};
// We intentionally exclude `attachment` from deps — the dialog is
// mounted with a single attachment for its lifetime, and re-running
// this effect would refetch on every parent re-render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
return { src, loading, error };
}
function AttachmentRow({ attachment }: { attachment: QuoteAttachment }) {
const [open, setOpen] = useState(false);
const kind = resolveKind(attachment);
const Icon = ATTACHMENT_ICONS[kind];
const url = attachmentUrl(attachment);
return (
<>
<li>
<button
type="button"
onClick={() => setOpen(true)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-md border bg-card hover:bg-accent/30 transition-colors text-left cursor-pointer"
data-testid="attachment-row"
aria-label={`Preview ${attachment.filename}`}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{attachment.filename}
</div>
<div className="text-xs text-muted-foreground">
{kind.toUpperCase()} · {formatBytes(attachment.size_bytes)}
{attachment.content_type ? ` · ${attachment.content_type}` : ""}
</div>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
download={attachment.filename}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline shrink-0"
data-testid="attachment-download"
>
<Download className="h-3 w-3" />
Download
</a>
</button>
</li>
<AttachmentPreviewDialog
attachment={attachment}
kind={kind}
url={url}
open={open}
onOpenChange={setOpen}
/>
</>
);
}
function AttachmentPreviewDialog({
attachment,
kind,
url,
open,
onOpenChange,
}: {
attachment: QuoteAttachment;
kind: QuoteAttachmentKind;
url: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
// PDFs and images get an inline preview. Anything else just shows
// metadata + a download button — better than pretending to render a
// .docx and ending up with a broken UI.
const canPreview = kind === "pdf" || kind === "image";
const Icon = ATTACHMENT_ICONS[kind];
// Lazily load the file as a blob once the dialog opens. `open` is the
// gate so we don't fetch every attachment on page load — only when the
// user actually wants to see it.
const { src: blobSrc, loading, error } = useAttachmentBlob(attachment, open && canPreview);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="sm:max-w-4xl w-[calc(100%-2rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col"
>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<DialogTitle className="flex-1 min-w-0 truncate font-medium text-sm">
{attachment.filename}
</DialogTitle>
<Button
variant="ghost"
size="icon-sm"
render={<a href={url} target="_blank" rel="noopener noreferrer" />}
aria-label="Open in new tab"
title="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
render={
<a
href={url}
download={attachment.filename}
aria-label="Download"
title="Download"
/>
}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onOpenChange(false)}
aria-label="Close"
title="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 bg-muted/30">
{canPreview ? (
kind === "image" ? (
<div className="flex items-center justify-center p-4 min-h-[60vh] overflow-auto">
{blobSrc ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={blobSrc}
alt={attachment.filename}
className="max-w-full max-h-[80vh] object-contain rounded shadow-sm"
/>
) : loading ? (
<PreviewLoading label="Loading image…" />
) : (
<PreviewError
error={error}
filename={attachment.filename}
url={url}
/>
)}
</div>
) : (
<div className="w-full h-[80vh] bg-white">
{blobSrc ? (
// PDF preview via blob: URL. Embed is used in addition
// to iframe because some browsers' built-in PDF viewers
// attach to either tag, and having both gives us a
// fallback if one is blocked by an extension / CSP.
// The iframe is the primary; the embed is the fallback.
<iframe
src={blobSrc}
title={attachment.filename}
className="w-full h-full"
/>
) : loading ? (
<PreviewLoading label="Loading PDF…" />
) : (
<PreviewError
error={error}
filename={attachment.filename}
url={url}
/>
)}
</div>
)
) : (
<div className="p-8 flex flex-col items-center justify-center text-center gap-3 min-h-[40vh]">
<Icon className="h-12 w-12 text-muted-foreground" />
<div>
<p className="font-medium">{attachment.filename}</p>
<p className="text-sm text-muted-foreground mt-1">
{attachment.content_type || kind.toUpperCase()} ·{" "}
{formatBytes(attachment.size_bytes)}
</p>
<p className="text-xs text-muted-foreground mt-3">
No inline preview for this file type. Download to open it
locally.
</p>
</div>
<Button
variant="default"
render={<a href={url} download={attachment.filename} />}
>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function PreviewLoading({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground">
<div className="h-6 w-6 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
<p className="text-sm">{label}</p>
</div>
);
}
function PreviewError({
error,
filename,
url,
}: {
error: string | null;
filename: string;
url: string;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center p-8">
<FileIcon className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">{filename}</p>
<p className="text-sm text-muted-foreground mt-1">
Couldn&apos;t load preview{error ? ` — ${error}` : ""}.
</p>
<p className="text-xs text-muted-foreground mt-2">
Download the file to open it locally.
</p>
</div>
<Button variant="default" render={<a href={url} download={filename} />}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
</div>
);
}

View file

@ -80,6 +80,8 @@ export interface Quote extends BaseRecord {
company_letterhead: string;
email_delivered: boolean;
email_delivered_at: string;
/** When the original inbound email was received (Date header / IMAP envelope date). */
email_received_at: string;
email_opened: boolean;
email_opened_at: string;
email_tracking_id: string;
@ -103,7 +105,13 @@ export interface QuoteAttachment extends BaseRecord {
content_type: string;
size_bytes: number;
email_message_id: string;
kind: QuoteAttachmentKind;
/**
* Classification used by the dashboard to pick the right icon / preview.
* Older attachments (and a few mid-2026 imports) were uploaded without
* `kind` being set — the UI must tolerate `null` / `undefined`.
*/
kind?: QuoteAttachmentKind | null;
uploaded_at?: string;
expand?: {
quote?: Quote;
};

85
src/lib/user-color.ts Normal file
View file

@ -0,0 +1,85 @@
/**
* Stable user-id → color mapping for visual grouping in lists (e.g.
* colour every row a quoter claimed so the team can see at a glance
* who owns what).
*
* The mapping is deterministic — the same user id always resolves to
* the same colour across reloads and across users. We use a 32-bit
* FNV-1a hash so the palette selection is well-distributed without
* importing a hash library.
*
* The palette is hand-tuned for accessibility: every colour is
* distinct at a glance AND pairs with a matching tinted background
* (5–10 % opacity) so the row highlight reads well in both light and
* dark mode.
*/
export interface UserColor {
/** Foreground accent — left border, dot, chip text. */
accent: string;
/** Background tint — used for row highlight, ~6–10% opacity. */
tint: string;
/** Solid chip background — used for the user-name pill. */
chip: string;
/** Foreground that reads on `chip`. */
chipFg: string;
}
// Curated palette of 12 visually distinct hues. Going past 12 starts to
// cycle back to colours too close to neighbours. If the team grows past
// that, append more entries rather than re-ordering — existing user
// colour assignments must stay stable.
const PALETTE: UserColor[] = [
{ accent: "#2563eb", tint: "rgba(37, 99, 235, 0.10)", chip: "rgba(37, 99, 235, 0.18)", chipFg: "#1d4ed8" }, // blue
{ accent: "#dc2626", tint: "rgba(220, 38, 38, 0.10)", chip: "rgba(220, 38, 38, 0.18)", chipFg: "#b91c1c" }, // red
{ accent: "#16a34a", tint: "rgba(22, 163, 74, 0.10)", chip: "rgba(22, 163, 74, 0.18)", chipFg: "#15803d" }, // green
{ accent: "#ea580c", tint: "rgba(234, 88, 12, 0.10)", chip: "rgba(234, 88, 12, 0.18)", chipFg: "#c2410c" }, // orange
{ accent: "#9333ea", tint: "rgba(147, 51, 234, 0.10)", chip: "rgba(147, 51, 234, 0.18)", chipFg: "#7e22ce" }, // purple
{ accent: "#0891b2", tint: "rgba(8, 145, 178, 0.10)", chip: "rgba(8, 145, 178, 0.18)", chipFg: "#0e7490" }, // cyan
{ accent: "#db2777", tint: "rgba(219, 39, 119, 0.10)", chip: "rgba(219, 39, 119, 0.18)", chipFg: "#be185d" }, // pink
{ accent: "#ca8a04", tint: "rgba(202, 138, 4, 0.10)", chip: "rgba(202, 138, 4, 0.18)", chipFg: "#a16207" }, // amber
{ accent: "#0d9488", tint: "rgba(13, 148, 136, 0.10)", chip: "rgba(13, 148, 136, 0.18)", chipFg: "#0f766e" }, // teal
{ accent: "#7c3aed", tint: "rgba(124, 58, 237, 0.10)", chip: "rgba(124, 58, 237, 0.18)", chipFg: "#6d28d9" }, // violet
{ accent: "#65a30d", tint: "rgba(101, 163, 13, 0.10)", chip: "rgba(101, 163, 13, 0.18)", chipFg: "#4d7c0f" }, // lime
{ accent: "#475569", tint: "rgba(71, 85, 105, 0.10)", chip: "rgba(71, 85, 105, 0.18)", chipFg: "#334155" }, // slate
];
/**
* FNV-1a 32-bit hash. Returns an unsigned 32-bit int. Fast enough for
* per-row colour assignment; no need for crypto strength.
*/
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
// Multiply by FNV prime, keeping within 32 bits via `>>> 0`.
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h >>> 0;
}
/**
* Resolve a user id (PB record id) to a stable colour. Returns `null`
* for empty input so callers can short-circuit.
*/
export function userColor(userId: string | null | undefined): UserColor | null {
if (!userId) return null;
const idx = fnv1a(userId) % PALETTE.length;
return PALETTE[idx];
}
/**
* Best-effort display name for a user. Prefers `name` (the PB auth
* collection's human-readable field), falls back to the local-part of
* `email`, then to "User".
*/
export function userDisplayName(
user: { name?: string; email?: string } | null | undefined
): string {
if (!user) return "—";
const name = (user.name || "").trim();
if (name) return name;
const email = (user.email || "").trim();
if (!email) return "—";
return email.split("@")[0] || email;
}