Files
bellsystems-cp/frontend/src/crm/customers/CustomerList.jsx
bonamin 5d8ef96d4c update: CRM customers, orders, device detail, and status system changes
- 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>
2026-03-25 10:39:38 +02:00

1134 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}