From a18ab0f19fb47ad376b4d0368da8f2b187a61f98 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 22 Jun 2026 15:15:00 +0200 Subject: [PATCH] feat: add quote email tracking, user color coding, and improved attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../quote_attachments.collection.json | 4 +- pb_quote_attachments.schema.json | 2 +- scripts/setup-pocketbase.ts | 1 + src/app/(app)/quotes/[id]/page.tsx | 33 +- src/app/(app)/quotes/page.tsx | 657 ++++++++++------ src/app/api/v1/users/lookup/route.ts | 44 ++ src/components/data-table.tsx | 36 +- src/components/quote-email-card.tsx | 728 ++++++++++++++++-- src/lib/types.ts | 10 +- src/lib/user-color.ts | 85 ++ 10 files changed, 1254 insertions(+), 346 deletions(-) create mode 100644 src/app/api/v1/users/lookup/route.ts create mode 100644 src/lib/user-color.ts diff --git a/docs/pocketbase/quote_attachments.collection.json b/docs/pocketbase/quote_attachments.collection.json index f2ed232..e350b0f 100644 --- a/docs/pocketbase/quote_attachments.collection.json +++ b/docs/pocketbase/quote_attachments.collection.json @@ -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)", diff --git a/pb_quote_attachments.schema.json b/pb_quote_attachments.schema.json index 3caa981..3d01509 100644 --- a/pb_quote_attachments.schema.json +++ b/pb_quote_attachments.schema.json @@ -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, diff --git a/scripts/setup-pocketbase.ts b/scripts/setup-pocketbase.ts index 9e8e59c..4efdcab 100644 --- a/scripts/setup-pocketbase.ts +++ b/scripts/setup-pocketbase.ts @@ -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 } }, diff --git a/src/app/(app)/quotes/[id]/page.tsx b/src/app/(app)/quotes/[id]/page.tsx index 9960b8a..5e79f84 100644 --- a/src/app/(app)/quotes/[id]/page.tsx +++ b/src/app/(app)/quotes/[id]/page.tsx @@ -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(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) => { setActionLoading(action); try { @@ -352,8 +380,9 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri {quote.rfq_number && RFQ: {quote.rfq_number}} {quote.claimed_by && ( - - Claimed by user + + + {claimerName || (quote.claimed_by.length > 12 ? `${quote.claimed_by.slice(0, 12)}…` : quote.claimed_by)} )} diff --git a/src/app/(app)/quotes/page.tsx b/src/app/(app)/quotes/page.tsx index 364630c..613a2ae 100644 --- a/src/app/(app)/quotes/page.tsx +++ b/src/app/(app)/quotes/page.tsx @@ -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 = { - 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 = { - 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([]); - 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>({}); - const [formOpen, setFormOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - const [deleteQuote, setDeleteQuote] = useState(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 ( -
-
-
-

Quotes

-

Manage quote requests

-
-
- - - -
-
- - { setActiveTab(v); setPage(1); }}> - - {[ - { 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) => ( - - {tab.label} - {typeof counts[tab.key] === "number" && ( - - {counts[tab.key]} - - )} - - ))} - - - - ( -
- - {item.quote_number} -
- ), - }, - { - key: "customer", - label: "Customer", - render: (item: Quote) => {item.expand?.customer?.customer || "—"}, - }, - { - key: "company", - label: "Company", - render: (item: Quote) => {item.expand?.company?.company || "—"}, - }, - { - key: "status", - label: "Status", - render: (item: Quote) => ( - - {formatQuoteStatus(item.status)} - - ), - }, - { - key: "letterhead", - label: "Letterhead", - render: (item: Quote) => {item.company_letterhead || "—"}, - }, - { - key: "created", - label: "Date", - render: (item: Quote) => ( - {formatDate(item.created)} - ), - }, - ]} - 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) => ( - router.push(`/quotes/${item.id}`)}> - -
-
-

{item.quote_number}

-

{item.expand?.customer?.customer || "—"}

-
- - {formatQuoteStatus(item.status)} - -
-
- {formatDate(item.created)} - {item.company_letterhead} -
-
-
- )} - /> - - - - -
- ); -} +"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 = { + 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 = { + 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([]); + 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>({}); + const [formOpen, setFormOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleteQuote, setDeleteQuote] = useState(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([]); + + 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(); + 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 ( +
+
+
+

Quotes

+

Manage quote requests

+
+
+ + + +
+
+ + { setActiveTab(v); setPage(1); }}> + + {TAB_ORDER.map((tab) => ( + + {tab.label} + {typeof counts[tab.key] === "number" && ( + + {counts[tab.key]} + + )} + + ))} + + + + ( +
+ + {item.quote_number} +
+ ), + }, + { + key: "customer", + label: "Customer", + render: (item: Quote) => {item.expand?.customer?.customer || "—"}, + }, + { + key: "company", + label: "Company", + render: (item: Quote) => {item.expand?.company?.company || "—"}, + }, + { + key: "rep", + label: "Rep", + render: (item: Quote) => ( + + {item.expand?.customer?.rep || "—"} + + ), + }, + { + 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 —; + } + return ( + + ); + }, + }, + { + key: "status", + label: "Status", + render: (item: Quote) => ( + + {formatQuoteStatus(item.status)} + + ), + }, + { + key: "created", + label: "Date", + render: (item: Quote) => ( + {formatDate(item.created)} + ), + }, + ]} + 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) => ( + router.push(`/quotes/${item.id}`)}> + +
+
+

{item.quote_number}

+

+ {item.expand?.customer?.customer || "—"} +

+
+ + {formatQuoteStatus(item.status)} + +
+
+
+ Rep: + {item.expand?.customer?.rep || "—"} +
+
+ Claimed: + + {item.claimed_by ? userDisplayName(userById.get(item.claimed_by) ?? null) : "—"} + +
+
+
+ {formatDate(item.created)} +
+
+
+ )} + /> + + + + +
+ ); +} + +/** + * 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 ( + + + {display} + + ); + } + return ( + + + + {display} + + + ); +} diff --git a/src/app/api/v1/users/lookup/route.ts b/src/app/api/v1/users/lookup/route.ts new file mode 100644 index 0000000..a150a76 --- /dev/null +++ b/src/app/api/v1/users/lookup/route.ts @@ -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 }); + } +} diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx index 9ab66cc..7d3022c 100644 --- a/src/components/data-table.tsx +++ b/src/components/data-table.tsx @@ -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 { 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({ @@ -49,6 +69,10 @@ export function DataTable({ onRowClick, actions, mobileCardRender, + rowClassName, + rowStyle, + mobileCardClassName, + mobileCardStyle, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); @@ -117,7 +141,11 @@ export function DataTable({ {data.map((item, i) => ( ).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({ {mobileCardRender && (
{data.map((item, i) => ( -
).id as string) || i}> +
).id as string) || i} + className={mobileCardClassName?.(item)} + style={mobileCardStyle?.(item)} + > {mobileCardRender(item)}
))} diff --git a/src/components/quote-email-card.tsx b/src/components/quote-email-card.tsx index 44cf553..8184b65 100644 --- a/src/components/quote-email-card.tsx +++ b/src/components/quote-email-card.tsx @@ -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 = { + 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 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("` 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> = { + 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(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(/]*>/gi, "") + .replace(//gi, "") + .replace(/]*>/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 ``; + if (!attrs) return `<${tag}>`; + const allowed = ALLOWED_ATTRS[tag] || new Set(); + // 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, ">"); + 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, ">"); + return escaped.replace( + /(\b(?:https?:\/\/|mailto:)[^\s<]+)/gi, + (url) => `${url}` + ); +} + +/** + * 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

, the quote in a left-bordered + * block. Preserves single line breaks within a paragraph via
. + */ +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("
"); + out.push(`

${joined}

`); + paragraph = []; + }; + const flushQuote = () => { + if (quote.length === 0) return; + const joined = quote + .map((l) => linkifyText(l)) + .join("
"); + out.push(`
${joined}
`); + 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 ( @@ -99,62 +334,87 @@ export default function QuoteEmailCard({ - {/* Metadata: subject, sender, received_at */} -
-
- -
+ {/* Email-header strip — From / Received / Subject */} +
+
+ +
+
+ + {senderName(senderDisplay)} + + + <{senderDisplay}> + +
+
+ + + + {formatReceivedAt(receivedAt)} + + + {quote.email_message_id && ( + + + {quote.email_message_id} + + )} +
+
+
+ {quote.email_subject && ( +
Subject
- {quote.email_subject || "(no subject)"} + {quote.email_subject}
-
-
- -
-
From
-
- {senderDisplay} -
-
-
-
- -
-
Received
-
- {formatReceivedAt(quote.email_delivered_at || "")} -
-
-
+ )}
- {/* 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

/

    tags); the sanitizer + strips dangerous tags and event handlers before injection. */} {hasBody && (
    -
    Body
    -
    Message
    +
    - {quote.email_body} - + dangerouslySetInnerHTML={{ __html: bodyHtml }} + />
    )} - {/* 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 && (
    @@ -162,37 +422,9 @@ export default function QuoteEmailCard({ Attachments ({attachments.length})
      - {attachments.map((a) => { - const Icon = attachmentIcon(a.kind); - return ( -
    • - -
      -
      - {a.filename} -
      -
      - {a.kind.toUpperCase()} · {formatBytes(a.size_bytes)} - {a.content_type ? ` · ${a.content_type}` : ""} -
      -
      - - - Download - -
    • - ); - })} + {attachments.map((a) => ( + + ))}
    )} @@ -200,3 +432,329 @@ export default function QuoteEmailCard({ ); } + +// ───────────────────────────────────────────────────────────────────────────── +// 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 `