- CustomerList, CustomerForm, CustomerDetail: various updates - Orders: removed OrderDetail and OrderForm, updated OrderList and index - DeviceDetail: updates - index.css: added new styles - CRM_STATUS_SYSTEM_PLAN.md: new planning document - Added customer-status assets and CustomerDetail subfolder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1134 lines
55 KiB
JavaScript
1134 lines
55 KiB
JavaScript
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(/<!DOCTYPE[\s\S]*?>/gi, "")
|
||
.replace(/<!--[\s\S]*?-->/g, "")
|
||
.replace(
|
||
/<svg\b([^>]*)>/i,
|
||
`<svg$1 width="${size}" height="${size}" aria-label="${title}" role="img" focusable="false" style="display:block;width:${size}px;height:${size}px;color:${color};fill:currentColor;">`,
|
||
);
|
||
|
||
return (
|
||
<span
|
||
title={title}
|
||
aria-label={title}
|
||
role="img"
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
verticalAlign: "middle",
|
||
flexShrink: 0,
|
||
lineHeight: 0,
|
||
}}
|
||
dangerouslySetInnerHTML={{ __html: svgMarkup }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<td className="px-3 py-3" style={{ textAlign: "center" }}>
|
||
{renderMaskedIcon(icon, color, title)}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
// ── 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 <td className="px-3 py-3" />;
|
||
return (
|
||
<td className="px-3 py-3">
|
||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
|
||
{hasIssue && (
|
||
<span title={`${summary.active_issues_count} active technical issue(s)`}
|
||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||
<span style={{ width: 10, height: 10, borderRadius: "50%", backgroundColor: "var(--crm-issue-active-text)", display: "inline-block", flexShrink: 0 }} />
|
||
<span style={{ fontSize: 11, color: "var(--crm-issue-active-text)", fontWeight: 700 }}>{summary.active_issues_count}</span>
|
||
</span>
|
||
)}
|
||
{hasSupport && (
|
||
<span title={`${summary.active_support_count} active support item(s)`}
|
||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||
<span style={{ width: 10, height: 10, borderRadius: "50%", backgroundColor: "var(--crm-support-active-text)", display: "inline-block", flexShrink: 0 }} />
|
||
<span style={{ fontSize: 11, color: "var(--crm-support-active-text)", fontWeight: 700 }}>{summary.active_support_count}</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ position: "relative" }} ref={ref}>
|
||
<button
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||
</svg>
|
||
Columns ({visibleCount})
|
||
</button>
|
||
{open && (
|
||
<div style={{ position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20, backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 8, width: 200, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", padding: 8 }}>
|
||
<p className="text-xs font-medium px-2 py-1 mb-1" style={{ color: "var(--text-muted)" }}>
|
||
Drag to reorder · Click to toggle
|
||
</p>
|
||
{orderedIds.map((id) => {
|
||
const col = ALL_COLUMNS.find((c) => c.id === id);
|
||
if (!col) return null;
|
||
const isLocked = !!col.locked;
|
||
return (
|
||
<div
|
||
key={id}
|
||
draggable={!isLocked}
|
||
onDragStart={() => !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"; }}
|
||
>
|
||
<span style={{ fontSize: 11, color: "var(--text-muted)", cursor: isLocked ? "default" : "grab" }}>⠿</span>
|
||
<div style={{
|
||
width: 14, height: 14, borderRadius: 3,
|
||
border: `2px solid ${isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
|
||
backgroundColor: isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "transparent",
|
||
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||
}}>
|
||
{(isLocked || visible[id]) && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}>✓</span>}
|
||
</div>
|
||
<span className="text-xs" style={{ color: "var(--text-primary)" }}>
|
||
{col.label}{isLocked ? " (locked)" : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ position: "relative" }} ref={ref}>
|
||
<button
|
||
onClick={() => setOpen(v => !v)}
|
||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||
style={{
|
||
backgroundColor: count > 0 ? "var(--accent)" : "var(--bg-input)",
|
||
borderColor: count > 0 ? "var(--accent)" : "var(--border-primary)",
|
||
color: count > 0 ? "#fff" : "var(--text-secondary)",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" />
|
||
</svg>
|
||
Filter{count > 0 ? ` (${count})` : ""}
|
||
</button>
|
||
{open && (
|
||
<div style={{
|
||
position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20,
|
||
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
|
||
borderRadius: 8, minWidth: 190, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", overflow: "hidden",
|
||
}}>
|
||
{FILTER_OPTIONS.map(opt => {
|
||
const checked = active.has(opt.value);
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
onClick={() => toggle(opt.value)}
|
||
style={{
|
||
display: "flex", alignItems: "center", gap: 10, width: "100%", textAlign: "left",
|
||
padding: "9px 14px", fontSize: 12, fontWeight: checked ? 600 : 400,
|
||
cursor: "pointer", background: "none", border: "none",
|
||
color: checked ? "var(--accent)" : "var(--text-primary)",
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||
>
|
||
<div style={{
|
||
width: 14, height: 14, borderRadius: 3, flexShrink: 0,
|
||
border: `2px solid ${checked ? "var(--accent)" : "var(--border-primary)"}`,
|
||
backgroundColor: checked ? "var(--accent)" : "transparent",
|
||
display: "flex", alignItems: "center", justifyContent: "center",
|
||
}}>
|
||
{checked && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}>✓</span>}
|
||
</div>
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
{count > 0 && (
|
||
<button
|
||
onClick={() => { onChange(new Set()); setOpen(false); }}
|
||
style={{
|
||
display: "block", width: "100%", textAlign: "center", padding: "7px 14px",
|
||
fontSize: 11, cursor: "pointer", background: "none", border: "none",
|
||
borderTop: "1px solid var(--border-secondary)", color: "var(--text-muted)",
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||
>
|
||
Clear filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ position: "relative" }} ref={ref}>
|
||
<button
|
||
onClick={() => setOpen(v => !v)}
|
||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||
</svg>
|
||
{current.label}
|
||
</button>
|
||
{open && (
|
||
<div style={{
|
||
position: "absolute", right: 0, top: "calc(100% + 4px)", zIndex: 20,
|
||
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
|
||
borderRadius: 8, minWidth: 180, boxShadow: "0 8px 24px rgba(0,0,0,0.15)", overflow: "hidden",
|
||
}}>
|
||
{SORT_OPTIONS.map(opt => (
|
||
<button
|
||
key={opt.value}
|
||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||
style={{
|
||
display: "block", width: "100%", textAlign: "left",
|
||
padding: "9px 14px", fontSize: 12, fontWeight: opt.value === value ? 600 : 400,
|
||
cursor: "pointer", background: "none", border: "none",
|
||
color: opt.value === value ? "var(--accent)" : "var(--text-primary)",
|
||
}}
|
||
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
|
||
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Notes mode toggle ────────────────────────────────────────────────────────
|
||
|
||
function NotesModeToggle({ value, onChange }) {
|
||
const isExpanded = value === "expanded";
|
||
return (
|
||
<button
|
||
onClick={() => onChange(isExpanded ? "quick" : "expanded")}
|
||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
|
||
title={isExpanded ? "Switch to Quick notes view" : "Switch to Expanded notes view"}
|
||
style={{
|
||
backgroundColor: isExpanded ? "color-mix(in srgb, var(--accent) 15%, var(--bg-input))" : "var(--bg-input)",
|
||
borderColor: isExpanded ? "var(--accent)" : "var(--border-primary)",
|
||
color: isExpanded ? "var(--accent)" : "var(--text-secondary)",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{isExpanded ? (
|
||
// Expanded icon: lines with detail
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" />
|
||
</svg>
|
||
) : (
|
||
// Quick icon: compact list
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||
</svg>
|
||
)}
|
||
Notes: {isExpanded ? "Expanded" : "Quick"}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); navigate(`/crm/customers/${customer.id}`); }}
|
||
style={{
|
||
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
|
||
border: "1px solid var(--border-primary)", cursor: "pointer",
|
||
backgroundColor: "transparent", color: "var(--text-secondary)",
|
||
}}
|
||
>
|
||
Open
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<td key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
||
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
|
||
</td>
|
||
);
|
||
case "status":
|
||
return <StatusCell key={col.id} customer={c} lastCommDate={lastCommDate} onChurnUpdate={handleChurnUpdate} />;
|
||
case "support":
|
||
return <SupportCell key={col.id} customer={c} />;
|
||
case "organization":
|
||
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
|
||
case "address": {
|
||
const parts = [loc.address, loc.postal_code, loc.city, loc.region, loc.country].filter(Boolean);
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
|
||
}
|
||
case "location": {
|
||
const cityCountry = [loc.city, loc.country].filter(Boolean).join(", ");
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{cityCountry || "—"}</td>;
|
||
}
|
||
case "email":
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;
|
||
case "phone":
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{primaryContact(c, "phone") || "—"}</td>;
|
||
case "religion":
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{c.religion || "—"}</td>;
|
||
case "language":
|
||
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{resolveLanguage(c.language)}</td>;
|
||
case "tags":
|
||
return (
|
||
<td key={col.id} className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-1">
|
||
{(c.tags || []).slice(0, 3).map((tag) => (
|
||
<span key={tag} className="px-2 py-0.5 text-xs rounded-full"
|
||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{(c.tags || []).length > 3 && (
|
||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>+{c.tags.length - 3}</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
);
|
||
default:
|
||
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-muted)" }}>—</td>;
|
||
}
|
||
};
|
||
|
||
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 (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Customers</h1>
|
||
{canEdit && (
|
||
<button
|
||
onClick={() => navigate("/crm/customers/new")}
|
||
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
|
||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||
>
|
||
New Customer
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex gap-3 mb-4">
|
||
<input
|
||
type="text"
|
||
placeholder="Search by name, organization, address, email, phone, religion, language, tags..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="flex-1 px-3 py-2 text-sm rounded-md border"
|
||
style={inputStyle}
|
||
/>
|
||
<SortDropdown value={sort} onChange={setSort} />
|
||
<NotesModeToggle value={notesMode} onChange={handleNotesModeChange} />
|
||
<FilterDropdown active={activeFilters} onChange={setActiveFilters} />
|
||
<select
|
||
value={String(pageSize)}
|
||
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||
className="px-3 py-2 text-sm rounded-md border cursor-pointer"
|
||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
|
||
>
|
||
<option value="10">10 / page</option>
|
||
<option value="20">20 / page</option>
|
||
<option value="50">50 / page</option>
|
||
<option value="0">All</option>
|
||
</select>
|
||
<ColumnToggle
|
||
visible={colPrefs.visible}
|
||
orderedIds={colPrefs.orderedIds}
|
||
onChange={updateColVisible}
|
||
onReorder={updateColOrder}
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="text-sm rounded-md p-3 mb-4 border"
|
||
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||
) : filteredCustomers.length === 0 ? (
|
||
<div className="rounded-lg p-8 text-center text-sm border"
|
||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
|
||
{activeFilters.size > 0 ? "No customers match the current filters." : "No customers found."}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg overflow-hidden border"
|
||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||
{visibleColsForMode.map((col) => (
|
||
<th key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)", textAlign: (col.id === "status" || col.id === "support") ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}), ...(col.id === "support" ? { width: 60 } : {}) }}>
|
||
{col.label}
|
||
</th>
|
||
))}
|
||
{canEdit && <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}></th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 = (
|
||
<tr
|
||
key={`${c.id}-main`}
|
||
onClick={() => 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 && (
|
||
<td className="px-4 py-3 text-right">
|
||
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
|
||
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) => (
|
||
<tr
|
||
key={key}
|
||
className="cursor-pointer"
|
||
onClick={() => navigate(`/crm/customers/${c.id}`)}
|
||
style={{ borderBottom: "none", background: sharedBg }}
|
||
onMouseEnter={() => setHoveredRow(c.id)}
|
||
onMouseLeave={() => setHoveredRow(null)}
|
||
>
|
||
{colsBeforeName > 0 && (
|
||
<td colSpan={colsBeforeName} style={{ padding: 0 }} />
|
||
)}
|
||
<td colSpan={colsFromName} style={{ padding: isLastSubRow ? "4px 14px 14px 0" : "4px 16px 4px 0" }}>
|
||
{content}
|
||
</td>
|
||
</tr>
|
||
);
|
||
|
||
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`, (
|
||
<div style={{ display: "flex", alignItems: "center" }}>
|
||
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
{renderMaskedIcon(exclamationIcon, "var(--crm-customer-icon-declined)", "Issue", 15)}
|
||
</div>
|
||
<span style={{ fontSize: 11.5, color: "var(--crm-issue-active-text)", fontWeight: 500 }}>{label}</span>
|
||
</div>
|
||
), isLastSubRow);
|
||
}
|
||
if (type === "support") {
|
||
const label = `${supportCount} active support ticket${supportCount > 1 ? "s" : ""}`;
|
||
return makeSubRow(`${c.id}-sup`, (
|
||
<div style={{ display: "flex", alignItems: "center" }}>
|
||
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
{renderMaskedIcon(wrenchIcon, "var(--crm-support-active-text)", "Support", 15)}
|
||
</div>
|
||
<span style={{ fontSize: 11.5, color: "var(--crm-support-active-text)", fontWeight: 500 }}>{label}</span>
|
||
</div>
|
||
), isLastSubRow);
|
||
}
|
||
if (type === "order") {
|
||
const orderLabel = ORDER_STATUS_LABELS[activeOrderStatus] || activeOrderStatus;
|
||
const parts = ["Order", activeOrderNumber, orderLabel].filter(Boolean);
|
||
return makeSubRow(`${c.id}-ord`, (
|
||
<div style={{ display: "flex", alignItems: "center" }}>
|
||
<div style={{ width: SUB_ICON_BOX, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||
{renderMaskedIcon(orderIcon, statusColor, orderLabel, 18)}
|
||
</div>
|
||
<span style={{ fontSize: 11.5, color: statusColor, fontWeight: 500 }}>
|
||
{parts.map((p, i) => (
|
||
<span key={i}>{i > 0 && <span style={{ margin: "0 5px", color: "var(--text-muted)" }}>·</span>}{p}</span>
|
||
))}
|
||
</span>
|
||
{activeOrderTitle && (
|
||
<span style={{ fontSize: 11, color: "var(--text-muted)", marginLeft: 6 }}>· {activeOrderTitle}</span>
|
||
)}
|
||
</div>
|
||
), isLastSubRow);
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
|
||
if (!isLast) {
|
||
subRows.push(
|
||
<tr key={`${c.id}-gap`} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||
<td colSpan={totalCols} style={{ padding: 0 }} />
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
return [mainRow, ...subRows].filter(Boolean);
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
key={c.id}
|
||
onClick={() => 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 && (
|
||
<td className="px-4 py-3 text-right">
|
||
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
|
||
</td>
|
||
)}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{pageSize > 0 && totalPages > 1 && (
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-primary)" }}>
|
||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||
Page {safePage} of {totalPages} — {filteredCustomers.length} total
|
||
</span>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||
{[
|
||
{ label: "«", onClick: () => setPage(1), disabled: safePage === 1 },
|
||
{ label: "‹", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 },
|
||
].map(({ label, onClick, disabled }) => (
|
||
<button key={label} onClick={onClick} disabled={disabled}
|
||
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
{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 === "…" ? (
|
||
<span key={`e-${idx}`} style={{ padding: "0 4px", fontSize: 12, color: "var(--text-muted)" }}>…</span>
|
||
) : (
|
||
<button key={p} onClick={() => setPage(p)}
|
||
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: "pointer", fontWeight: p === safePage ? 700 : 400, backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", border: "none" }}>
|
||
{p}
|
||
</button>
|
||
)
|
||
)}
|
||
{[
|
||
{ label: "›", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages },
|
||
{ label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages },
|
||
].map(({ label, onClick, disabled }) => (
|
||
<button key={label} onClick={onClick} disabled={disabled}
|
||
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|