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
85 lines
3.8 KiB
TypeScript
85 lines
3.8 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|