import { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import api from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; // ── Customer-status SVG icon imports ───────────────────────────────────────── import clientIcon from "../../assets/customer-status/client.svg?raw"; import negotiatingIcon from "../../assets/customer-status/negotiating.svg?raw"; import awaitingQuotationIcon from "../../assets/customer-status/awating-quotation.svg?raw"; import awaitingConfirmIcon from "../../assets/customer-status/awaiting-confirmation.svg?raw"; import quotationAcceptedIcon from "../../assets/customer-status/quotation-accepted.svg?raw"; import startedMfgIcon from "../../assets/customer-status/started-mfg.svg?raw"; import awaitingPaymentIcon from "../../assets/customer-status/awaiting-payment.svg?raw"; import shippedIcon from "../../assets/customer-status/shipped.svg?raw"; import inactiveIcon from "../../assets/customer-status/inactive.svg?raw"; import declinedIcon from "../../assets/customer-status/declined.svg?raw"; import churnedIcon from "../../assets/customer-status/churned.svg?raw"; import orderIcon from "../../assets/customer-status/order.svg?raw"; import exclamationIcon from "../../assets/customer-status/exclamation.svg?raw"; import wrenchIcon from "../../assets/customer-status/wrench.svg?raw"; const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", }; const TITLE_SHORT = { "Fr.": "Fr.", "Rev.": "Rev.", "Archim.": "Archim.", "Bp.": "Bp.", "Abp.": "Abp.", "Met.": "Met.", "Mr.": "Mr.", "Mrs.": "Mrs.", "Ms.": "Ms.", "Dr.": "Dr.", "Prof.": "Prof.", }; // ISO 639-1 → human-readable. Extend as needed. const LANGUAGE_NAMES = { af: "Afrikaans", sq: "Albanian", am: "Amharic", ar: "Arabic", hy: "Armenian", az: "Azerbaijani", eu: "Basque", be: "Belarusian", bn: "Bengali", bs: "Bosnian", bg: "Bulgarian", ca: "Catalan", zh: "Chinese", hr: "Croatian", cs: "Czech", da: "Danish", nl: "Dutch", en: "English", et: "Estonian", fi: "Finnish", fr: "French", ka: "Georgian", de: "German", el: "Greek", gu: "Gujarati", he: "Hebrew", hi: "Hindi", hu: "Hungarian", id: "Indonesian", it: "Italian", ja: "Japanese", kn: "Kannada", kk: "Kazakh", ko: "Korean", lv: "Latvian", lt: "Lithuanian", mk: "Macedonian", ms: "Malay", ml: "Malayalam", mt: "Maltese", mr: "Marathi", mn: "Mongolian", ne: "Nepali", no: "Norwegian", fa: "Persian", pl: "Polish", pt: "Portuguese", pa: "Punjabi", ro: "Romanian", ru: "Russian", sr: "Serbian", si: "Sinhala", sk: "Slovak", sl: "Slovenian", es: "Spanish", sw: "Swahili", sv: "Swedish", tl: "Tagalog", ta: "Tamil", te: "Telugu", th: "Thai", tr: "Turkish", uk: "Ukrainian", ur: "Urdu", uz: "Uzbek", vi: "Vietnamese", cy: "Welsh", yi: "Yiddish", zu: "Zulu", }; function resolveLanguage(val) { if (!val) return "—"; const key = val.trim().toLowerCase(); return LANGUAGE_NAMES[key] || val; } const ALL_COLUMNS = [ { id: "name", label: "Name", default: true, locked: true }, { id: "status", label: "Status", default: true }, { id: "support", label: "Support", default: true }, { id: "organization", label: "Organization", default: true }, { id: "address", label: "Full Address", default: true }, { id: "location", label: "Location", default: true }, { id: "email", label: "Email", default: true }, { id: "phone", label: "Phone", default: true }, { id: "tags", label: "Tags", default: true }, { id: "religion", label: "Religion", default: false }, { id: "language", label: "Language", default: false }, ]; const SORT_OPTIONS = [ { value: "default", label: "Date Added" }, { value: "name", label: "First Name" }, { value: "surname", label: "Surname" }, { value: "latest_comm", label: "Latest Communication" }, ]; const COL_STORAGE_KEY = "crm_customers_columns"; const COL_ORDER_KEY = "crm_customers_col_order"; const NOTES_MODE_KEY = "crm_customers_notes_mode"; function loadColumnPrefs() { try { const vis = JSON.parse(localStorage.getItem(COL_STORAGE_KEY) || "null"); const order = JSON.parse(localStorage.getItem(COL_ORDER_KEY) || "null"); const visible = vis || Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default])); const orderedIds = order || ALL_COLUMNS.map((c) => c.id); for (const c of ALL_COLUMNS) { if (!orderedIds.includes(c.id)) orderedIds.push(c.id); } const filtered = orderedIds.filter(id => ALL_COLUMNS.find(c => c.id === id)); // Always force locked columns visible for (const c of ALL_COLUMNS) { if (c.locked) visible[c.id] = true; } return { visible, orderedIds: filtered }; } catch { return { visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.id, c.default])), orderedIds: ALL_COLUMNS.map((c) => c.id), }; } } function saveColumnPrefs(visible, orderedIds) { localStorage.setItem(COL_STORAGE_KEY, JSON.stringify(visible)); localStorage.setItem(COL_ORDER_KEY, JSON.stringify(orderedIds)); } // ── Status icons helpers ───────────────────────────────────────────────────── function statusColors(direction) { const pendingOurReply = direction === "inbound"; // negotiations: yellow if we sent last, orange if client sent last // issues: yellow if we sent last, red if client sent last const negColor = pendingOurReply ? "var(--crm-status-alert)" : "#e8a504"; const issColor = pendingOurReply ? "var(--crm-status-danger)" : "#e03535"; return { negColor, issColor, pendingOurReply }; } // ── Customer status icon resolver ───────────────────────────────────────────── // Pre-manufacturing statuses: customer can still go silent/churn const PRE_MFG_STATUSES = new Set([ "negotiating", "awaiting_quotation", "awaiting_customer_confirmation", "awaiting_fulfilment", "awaiting_payment", ]); // Returns { icon, color, title } for a customer based on their status + orders function resolveStatusIcon(customer) { const status = customer.relationship_status || "lead"; const summary = customer.crm_summary || {}; const allOrders = summary.all_orders_statuses || []; // ── Churned ──────────────────────────────────────────────────────────────── if (status === "churned") { return { icon: churnedIcon, color: "var(--crm-customer-icon-muted)", title: "Churned" }; } // ── Lead / Prospect ──────────────────────────────────────────────────────── if (status === "lead") { return { icon: clientIcon, color: "var(--crm-customer-icon-lead)", title: "Lead" }; } if (status === "prospect") { return { icon: clientIcon, color: "var(--crm-customer-icon-info)", title: "Prospect" }; } // ── Inactive ─────────────────────────────────────────────────────────────── // Always show inactive icon; backend polling corrects wrongly-inactive records. if (status === "inactive") { return { icon: inactiveIcon, color: "var(--crm-customer-icon-muted)", title: "Inactive" }; } // ── Active ───────────────────────────────────────────────────────────────── if (status === "active") { const activeOrderStatus = summary.active_order_status; const orderIconMap = { negotiating: { icon: negotiatingIcon, color: "var(--crm-customer-icon-passive)", title: "Negotiating" }, awaiting_quotation: { icon: awaitingQuotationIcon, color: "var(--crm-customer-icon-passive)", title: "Awaiting Quotation" }, awaiting_customer_confirmation: { icon: awaitingConfirmIcon, color: "var(--crm-customer-icon-passive)", title: "Awaiting Confirmation" }, awaiting_fulfilment: { icon: quotationAcceptedIcon, color: "var(--crm-customer-icon-info)", title: "Awaiting Fulfilment" }, awaiting_payment: { icon: awaitingPaymentIcon, color: "var(--crm-customer-icon-payment)", title: "Awaiting Payment" }, manufacturing: { icon: startedMfgIcon, color: "var(--crm-customer-icon-positive)", title: "Manufacturing" }, shipped: { icon: shippedIcon, color: "var(--crm-customer-icon-positive)", title: "Shipped" }, installed: { icon: inactiveIcon, color: "var(--crm-customer-icon-positive)", title: "Installed" }, }; // 1. There is an open order → show its icon. // active_order_status is only set for non-terminal (non-declined, non-complete) orders. if (activeOrderStatus && orderIconMap[activeOrderStatus]) { return orderIconMap[activeOrderStatus]; } // From here: no open orders. Determine why via all_orders_statuses. // Note: all_orders_statuses may be absent on older records not yet re-summarised. const allDeclined = allOrders.length > 0 && allOrders.every((s) => s === "declined"); const allComplete = allOrders.length > 0 && allOrders.every((s) => s === "complete"); // 2. All orders declined → show declined icon; staff decides next step. if (allDeclined) { return { icon: declinedIcon, color: "var(--crm-customer-icon-declined)", title: "All orders declined" }; } // 3. All orders complete → should have auto-flipped to inactive already. if (allComplete) { return { icon: inactiveIcon, color: "var(--crm-customer-icon-positive)", title: "All orders complete" }; } // 4. No orders at all (edge case: newly active, or old record without summary). return { icon: inactiveIcon, color: "var(--crm-customer-icon-info)", title: "Active, no orders" }; } return { icon: clientIcon, color: "var(--crm-customer-icon-muted)", title: status }; } // ── Status icon size ───────────────────────────────────────────────────────── // Status icon box size (px) — same for all status icons so layout is consistent const STATUS_ICON_SIZE = 22; const REL_STATUS_STYLES = { lead: { bg: "var(--crm-rel-lead-bg)", color: "var(--crm-rel-lead-text)", border: "var(--crm-rel-lead-border)" }, prospect: { bg: "var(--crm-rel-prospect-bg)", color: "var(--crm-rel-prospect-text)", border: "var(--crm-rel-prospect-border)" }, active: { bg: "var(--crm-rel-active-bg)", color: "var(--crm-rel-active-text)", border: "var(--crm-rel-active-border)" }, inactive: { bg: "var(--crm-rel-inactive-bg)", color: "var(--crm-rel-inactive-text)", border: "var(--crm-rel-inactive-border)" }, churned: { bg: "var(--crm-rel-churned-bg)", color: "var(--crm-rel-churned-text)", border: "var(--crm-rel-churned-border)" }, }; const REL_STATUS_LABELS = { lead:"Lead", prospect:"Prospect", active:"Active", inactive:"Inactive", churned:"Churned" }; const ORDER_STATUS_LABELS = { negotiating:"Negotiating", awaiting_quotation:"Awaiting Quotation", awaiting_customer_confirmation:"Awaiting Confirmation", awaiting_fulfilment:"Awaiting Fulfilment", awaiting_payment:"Awaiting Payment", manufacturing:"Manufacturing", shipped:"Shipped", installed:"Installed", declined:"Declined", complete:"Complete", }; function renderMaskedIcon(icon, color, title, size = STATUS_ICON_SIZE) { const svgMarkup = icon .replace(/<\?xml[\s\S]*?\?>/gi, "") .replace(//gi, "") .replace(//g, "") .replace( /]*)>/i, ``, ); return ( ); } function StatusCell({ customer, lastCommDate, onChurnUpdate }) { const { icon, color, title } = resolveStatusIcon(customer); // Auto-churn: active + has an open pre-mfg order + 12+ months since last comm useEffect(() => { if ((customer.relationship_status || "lead") !== "active") return; if (!lastCommDate) return; const allOrders = (customer.crm_summary?.all_orders_statuses) || []; if (!allOrders.some((s) => PRE_MFG_STATUSES.has(s))) return; const days = Math.floor((Date.now() - new Date(lastCommDate).getTime()) / 86400000); if (days < 365) return; onChurnUpdate?.(customer.id); }, [customer.id, customer.relationship_status, lastCommDate]); return ( {renderMaskedIcon(icon, color, title)} ); } // ── Icon color filter system ────────────────────────────────────────────────── // CSS filters starting from a black SVG source. // ── Icon tinting ────────────────────────────────────────────────────────────── // Maps every color token used in resolveStatusIcon to a pre-computed CSS filter. // To change a color: update BOTH the color value in resolveStatusIcon AND add/update // its entry here. Use https://codepen.io/sosuke/pen/Pjoqqp to generate the filter. // // All filters start with brightness(0) saturate(100%) to zero out the source black, // then the remaining steps shift to the target color. const ICON_FILTER_MAP = { // lead — near-white beige #f5f0e8 "#f5f0e8": "brightness(0) saturate(100%) invert(96%) sepia(10%) saturate(200%) hue-rotate(330deg) brightness(103%)", // prospect / active / manufacturing / shipped / installed — green, var(--crm-rel-active-text) ≈ #22c55e "var(--crm-rel-active-text)": "brightness(0) saturate(100%) invert(69%) sepia(48%) saturate(500%) hue-rotate(95deg) brightness(95%)", "var(--crm-rel-prospect-text)": "brightness(0) saturate(100%) invert(69%) sepia(48%) saturate(500%) hue-rotate(95deg) brightness(95%)", // inactive / churned / all-declined / silent — mid grey, var(--text-muted) ≈ #737373 "var(--text-muted)": "brightness(0) saturate(100%) invert(48%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%)", // negotiating / awaiting_quotation / awaiting_confirmation — bright grey, var(--text-primary) ≈ #d4d4d4 "var(--text-primary)": "brightness(0) saturate(100%) invert(87%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%)", // awaiting_fulfilment — light blue #7ab4e0 "#7ab4e0": "brightness(0) saturate(100%) invert(67%) sepia(35%) saturate(500%) hue-rotate(182deg) brightness(105%)", // awaiting_payment — yellow #e8c040 "#e8c040": "brightness(0) saturate(100%) invert(80%) sepia(55%) saturate(700%) hue-rotate(8deg) brightness(102%)", // declined — soft red #e07070 "#e07070": "brightness(0) saturate(100%) invert(52%) sepia(40%) saturate(700%) hue-rotate(314deg) brightness(108%)", }; function buildIconFilter(colorToken) { if (!colorToken) return ""; return ICON_FILTER_MAP[colorToken] || ""; } function SupportCell({ customer }) { const summary = customer.crm_summary || {}; const hasIssue = (summary.active_issues_count || 0) > 0; const hasSupport = (summary.active_support_count || 0) > 0; if (!hasIssue && !hasSupport) return ; return (
{hasIssue && ( {summary.active_issues_count} )} {hasSupport && ( {summary.active_support_count} )}
); } // ── Column toggle ──────────────────────────────────────────────────────────── function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { const [open, setOpen] = useState(false); const [dragging, setDragging] = useState(null); const ref = useRef(null); useEffect(() => { const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const handleDragOver = (e, id) => { e.preventDefault(); if (dragging && dragging !== id) { const next = [...orderedIds]; const from = next.indexOf(dragging); const to = next.indexOf(id); next.splice(from, 1); next.splice(to, 0, dragging); onReorder(next); } }; const visibleCount = Object.values(visible).filter(Boolean).length; return (
{open && (

Drag to reorder · Click to toggle

{orderedIds.map((id) => { const col = ALL_COLUMNS.find((c) => c.id === id); if (!col) return null; const isLocked = !!col.locked; return (
!isLocked && setDragging(id)} onDragOver={(e) => handleDragOver(e, id)} onDragEnd={() => setDragging(null)} onClick={() => !isLocked && onChange(id, !visible[id])} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6, cursor: isLocked ? "default" : "pointer", userSelect: "none", backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent", opacity: isLocked ? 0.5 : 1, }} onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = dragging === id ? "var(--bg-card-hover)" : "transparent"; }} >
{(isLocked || visible[id]) && }
{col.label}{isLocked ? " (locked)" : ""}
); })}
)}
); } // ── Filter dropdown ────────────────────────────────────────────────────────── const FILTER_OPTIONS = [ { value: "lead", label: "Lead" }, { value: "prospect", label: "Prospect" }, { value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }, { value: "churned", label: "Churned" }, { value: "has_issue", label: "Has Open Issue" }, { value: "has_support", label: "Has Open Support" }, ]; function FilterDropdown({ active, onChange }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const toggle = (value) => { const next = new Set(active); if (next.has(value)) next.delete(value); else next.add(value); onChange(next); }; const count = active.size; return (
{open && (
{FILTER_OPTIONS.map(opt => { const checked = active.has(opt.value); return ( ); })} {count > 0 && ( )}
)}
); } // ── Sort dropdown ──────────────────────────────────────────────────────────── function SortDropdown({ value, onChange }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const current = SORT_OPTIONS.find(o => o.value === value) || SORT_OPTIONS[0]; return (
{open && (
{SORT_OPTIONS.map(opt => ( ))}
)}
); } // ── Notes mode toggle ──────────────────────────────────────────────────────── function NotesModeToggle({ value, onChange }) { const isExpanded = value === "expanded"; return ( ); } // ── Helpers ────────────────────────────────────────────────────────────────── function primaryContact(customer, type) { const contacts = customer.contacts || []; const primary = contacts.find((c) => c.type === type && c.primary); return primary?.value || contacts.find((c) => c.type === type)?.value || null; } function ActionsDropdown({ customer }) { const navigate = useNavigate(); return ( ); } // ── Main component ─────────────────────────────────────────────────────────── export default function CustomerList() { const [customers, setCustomers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [search, setSearch] = useState(""); const [sort, setSort] = useState("default"); const [activeFilters, setActiveFilters] = useState(new Set()); const [hoveredRow, setHoveredRow] = useState(null); const [colPrefs, setColPrefs] = useState(loadColumnPrefs); // Map of customer_id → "inbound" | "outbound" | null const [commDirections, setCommDirections] = useState({}); // Map of customer_id → ISO date string of last comm const [lastCommDates, setLastCommDates] = useState({}); const [notesMode, setNotesMode] = useState( () => localStorage.getItem(NOTES_MODE_KEY) || "quick" ); const [pageSize, setPageSize] = useState(20); const [page, setPage] = useState(1); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("crm", "edit"); const handleNotesModeChange = (mode) => { setNotesMode(mode); localStorage.setItem(NOTES_MODE_KEY, mode); }; const fetchCustomers = async () => { setLoading(true); setError(""); try { const params = new URLSearchParams(); if (search) params.set("search", search); if (sort) params.set("sort", sort); const qs = params.toString(); const data = await api.get(`/crm/customers${qs ? `?${qs}` : ""}`); setCustomers(data.customers); // Fetch last-comm directions only for customers with a status flag fetchDirections(data.customers); } catch (err) { setError(err.message); } finally { setLoading(false); } }; const fetchDirections = async (list) => { // Fetch last-comm for: active customers (for status icon + churn detection) // and customers with active issues/support (for sub-row context) const flagged = list.filter(c => { const s = c.crm_summary || {}; const rel = c.relationship_status || "lead"; return rel === "active" || (s.active_issues_count || 0) > 0 || (s.active_support_count || 0) > 0; }); if (!flagged.length) return; const results = await Promise.allSettled( flagged.map(c => api.get(`/crm/customers/${c.id}/last-comm-direction`) .then(r => [c.id, r.direction, r.occurred_at || r.date || null]) .catch(() => [c.id, null, null]) ) ); const dirMap = {}; const dateMap = {}; for (const r of results) { if (r.status === "fulfilled") { const [id, dir, date] = r.value; dirMap[id] = dir; if (date) dateMap[id] = date; } } setCommDirections(prev => ({ ...prev, ...dirMap })); setLastCommDates(prev => ({ ...prev, ...dateMap })); }; const handleChurnUpdate = async (customerId) => { // Idempotent: only patch if still active setCustomers(prev => { const c = prev.find(x => x.id === customerId); if (!c || c.relationship_status !== "active") return prev; return prev.map(x => x.id === customerId ? { ...x, relationship_status: "churned" } : x); }); try { await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "churned" }); } catch { // silently ignore — local state already updated } }; useEffect(() => { fetchCustomers(); }, [search, sort]); const updateColVisible = (id, vis) => { const col = ALL_COLUMNS.find(c => c.id === id); if (col?.locked) return; // can't toggle locked columns const next = { ...colPrefs.visible, [id]: vis }; setColPrefs((p) => ({ ...p, visible: next })); saveColumnPrefs(next, colPrefs.orderedIds); }; const updateColOrder = (orderedIds) => { setColPrefs((p) => ({ ...p, orderedIds })); saveColumnPrefs(colPrefs.visible, orderedIds); }; const visibleCols = colPrefs.orderedIds .map((id) => ALL_COLUMNS.find((c) => c.id === id)) .filter((c) => c && colPrefs.visible[c.id]); const filteredCustomers = activeFilters.size === 0 ? customers : customers.filter(c => { const summary = c.crm_summary || {}; const relStatus = c.relationship_status || "lead"; const relFilters = ["lead", "prospect", "active", "inactive", "churned"].filter(v => activeFilters.has(v)); if (relFilters.length > 0 && !relFilters.includes(relStatus)) return false; if (activeFilters.has("has_issue") && !(summary.active_issues_count > 0)) return false; if (activeFilters.has("has_support") && !(summary.active_support_count > 0)) return false; return true; }); const totalPages = pageSize > 0 ? Math.ceil(filteredCustomers.length / pageSize) : 1; const safePage = Math.min(page, Math.max(1, totalPages)); const pagedCustomers = pageSize > 0 ? filteredCustomers.slice((safePage - 1) * pageSize, safePage * pageSize) : filteredCustomers; const handleCustomerUpdate = (updated) => { setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c)); }; const renderCell = (col, c, direction, lastCommDate) => { const loc = c.location || {}; switch (col.id) { case "name": return ( {[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")} ); case "status": return ; case "support": return ; case "organization": return {c.organization || "—"}; case "address": { const parts = [loc.address, loc.postal_code, loc.city, loc.region, loc.country].filter(Boolean); return {parts.join(", ") || "—"}; } case "location": { const cityCountry = [loc.city, loc.country].filter(Boolean).join(", "); return {cityCountry || "—"}; } case "email": return {primaryContact(c, "email") || "—"}; case "phone": return {primaryContact(c, "phone") || "—"}; case "religion": return {c.religion || "—"}; case "language": return {resolveLanguage(c.language)}; case "tags": return (
{(c.tags || []).slice(0, 3).map((tag) => ( {tag} ))} {(c.tags || []).length > 3 && ( +{c.tags.length - 3} )}
); default: return —; } }; const visibleColsForMode = visibleCols; // Total column count for colSpan on expanded sub-rows const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0); // Index of the "name" column among visible columns (sub-rows align under it) const nameColIndex = visibleColsForMode.findIndex(c => c.id === "name"); // Row gradient background for customers with active issues or support items function rowGradient(customer) { const summary = customer.crm_summary || {}; const hasIssue = (summary.active_issues_count || 0) > 0; const hasSupport = (summary.active_support_count || 0) > 0; if (!hasIssue && !hasSupport) return undefined; const color = hasIssue ? "rgba(224, 53, 53, 0.05)" : "rgba(247, 103, 7, 0.05)"; return `linear-gradient(to right, ${color} 0%, transparent 70%)`; } return (

Customers

{canEdit && ( )}
setSearch(e.target.value)} className="flex-1 px-3 py-2 text-sm rounded-md border" style={inputStyle} />
{error && (
{error}
)} {loading ? (
Loading...
) : filteredCustomers.length === 0 ? (
{activeFilters.size > 0 ? "No customers match the current filters." : "No customers found."}
) : (
{visibleColsForMode.map((col) => ( ))} {canEdit && } {pagedCustomers.map((c, index) => { const direction = commDirections[c.id] ?? null; const lastDate = lastCommDates[c.id] ?? null; const summary = c.crm_summary || {}; const hasStatus = (summary.active_issues_count || 0) > 0 || (summary.active_support_count || 0) > 0; const isLast = index === pagedCustomers.length - 1; const gradient = rowGradient(c); const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; const zebraBase = index % 2 === 1 ? "var(--bg-row-alt)" : "transparent"; const rowBackground = gradient ? `${gradient}, ${zebraBase}` : zebraBase; const rowStyle = { borderBottom: (!isLast && notesMode !== "expanded") ? "1px solid var(--border-secondary)" : "none", background: rowBg ? rowBg : rowBackground, }; // In expanded mode, hue overlay is applied on sub-rows (computed there) const mainRow = ( navigate(`/crm/customers/${c.id}`)} className="cursor-pointer" style={rowStyle} onMouseEnter={() => setHoveredRow(c.id)} onMouseLeave={() => setHoveredRow(null)} > {visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))} {canEdit && ( )} ); if (notesMode === "expanded") { const subRowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined; const issueCount = summary.active_issues_count || 0; const supportCount = summary.active_support_count || 0; const activeOrderStatus = summary.active_order_status; const activeOrderNumber = summary.active_order_number; const activeOrderTitle = summary.active_order_title; // Sub-rows alternate tint relative to main row const subRowLines = []; if (activeOrderStatus) subRowLines.push("order"); if (issueCount > 0) subRowLines.push("issue"); if (supportCount > 0) subRowLines.push("support"); // Icon box size — fixed so text aligns regardless of icon const SUB_ICON_BOX = 40; // Hue tint for the whole customer block when issues/support exist const hueGradient = issueCount > 0 ? "linear-gradient(to right, rgba(224, 53, 53, 0.07) 0%, transparent 70%)" : supportCount > 0 ? "linear-gradient(to right, rgba(247, 103, 7, 0.07) 0%, transparent 70%)" : null; // All rows in this customer's block share the same zebra+hue tint const sharedBg = subRowBg ? subRowBg : hueGradient ? `${hueGradient}, ${zebraBase}` : zebraBase; // Columns before "name" get empty cells; content spans from name onward const colsBeforeName = nameColIndex > 0 ? nameColIndex : 0; const colsFromName = totalCols - colsBeforeName; const makeSubRow = (key, content, isLastSubRow = false) => ( navigate(`/crm/customers/${c.id}`)} style={{ borderBottom: "none", background: sharedBg }} onMouseEnter={() => setHoveredRow(c.id)} onMouseLeave={() => setHoveredRow(null)} > {colsBeforeName > 0 && ( ); const { color: statusColor } = resolveStatusIcon(c); const subRows = subRowLines.map((type, idx) => { const isLastSubRow = idx === subRowLines.length - 1; if (type === "issue") { const label = `${issueCount} active technical issue${issueCount > 1 ? "s" : ""}`; return makeSubRow(`${c.id}-iss`, (
{renderMaskedIcon(exclamationIcon, "var(--crm-customer-icon-declined)", "Issue", 15)}
{label}
), isLastSubRow); } if (type === "support") { const label = `${supportCount} active support ticket${supportCount > 1 ? "s" : ""}`; return makeSubRow(`${c.id}-sup`, (
{renderMaskedIcon(wrenchIcon, "var(--crm-support-active-text)", "Support", 15)}
{label}
), isLastSubRow); } if (type === "order") { const orderLabel = ORDER_STATUS_LABELS[activeOrderStatus] || activeOrderStatus; const parts = ["Order", activeOrderNumber, orderLabel].filter(Boolean); return makeSubRow(`${c.id}-ord`, (
{renderMaskedIcon(orderIcon, statusColor, orderLabel, 18)}
{parts.map((p, i) => ( {i > 0 && ·}{p} ))} {activeOrderTitle && ( · {activeOrderTitle} )}
), isLastSubRow); } return null; }).filter(Boolean); if (!isLast) { subRows.push( ); } return [mainRow, ...subRows].filter(Boolean); } return ( navigate(`/crm/customers/${c.id}`)} className="cursor-pointer" style={{ borderBottom: !isLast ? "1px solid var(--border-secondary)" : "none", background: rowBg ? rowBg : gradient || "transparent", }} onMouseEnter={() => setHoveredRow(c.id)} onMouseLeave={() => setHoveredRow(null)} > {visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))} {canEdit && ( )} ); })}
{col.label}
)} {content}
{pageSize > 0 && totalPages > 1 && (
Page {safePage} of {totalPages} — {filteredCustomers.length} total
{[ { label: "«", onClick: () => setPage(1), disabled: safePage === 1 }, { label: "‹", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 }, ].map(({ label, onClick, disabled }) => ( ))} {Array.from({ length: totalPages }, (_, i) => i + 1) .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2) .reduce((acc, p, idx, arr) => { if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…"); acc.push(p); return acc; }, []) .map((p, idx) => p === "…" ? ( ) : ( ) )} {[ { label: "›", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages }, { label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages }, ].map(({ label, onClick, disabled }) => ( ))}
)}
)}
); }