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:
parent
33591959c2
commit
a18ab0f19f
10 changed files with 1254 additions and 346 deletions
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
44
src/app/api/v1/users/lookup/route.ts
Normal file
44
src/app/api/v1/users/lookup/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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">
|
||||
<{senderDisplay}>
|
||||
</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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
85
src/lib/user-color.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue