fix: Bugs created after the overhaul, performance and layout fixes

This commit is contained in:
2026-03-08 22:30:56 +02:00
parent 8c15c932b6
commit 6f9fd5cba3
112 changed files with 5771 additions and 970 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import { useAuth } from "../../auth/AuthContext";
@@ -9,19 +9,509 @@ const inputStyle = {
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 },
{ 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";
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));
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));
}
// ── Inline SVG icon components (currentColor, no hardcoded fills) ──────────
function IconNegotiations({ style }) {
return (
<svg style={style} viewBox="0 -8 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M64,12.78v17s-3.63.71-4.38.81-3.08.85-4.78-.78C52.22,27.25,42.93,18,42.93,18a3.54,3.54,0,0,0-4.18-.21c-2.36,1.24-5.87,3.07-7.33,3.78a3.37,3.37,0,0,1-5.06-2.64,3.44,3.44,0,0,1,2.1-3c3.33-2,10.36-6,13.29-7.52,1.78-1,3.06-1,5.51,1C50.27,12,53,14.27,53,14.27a2.75,2.75,0,0,0,2.26.43C58.63,14,64,12.78,64,12.78ZM27,41.5a3,3,0,0,0-3.55-4.09,3.07,3.07,0,0,0-.64-3,3.13,3.13,0,0,0-3-.75,3.07,3.07,0,0,0-.65-3,3.38,3.38,0,0,0-4.72.13c-1.38,1.32-2.27,3.72-1,5.14s2.64.55,3.72.3c-.3,1.07-1.2,2.07-.09,3.47s2.64.55,3.72.3c-.3,1.07-1.16,2.16-.1,3.46s2.84.61,4,.25c-.45,1.15-1.41,2.39-.18,3.79s4.08.75,5.47-.58a3.32,3.32,0,0,0,.3-4.68A3.18,3.18,0,0,0,27,41.5Zm25.35-8.82L41.62,22a3.53,3.53,0,0,0-3.77-.68c-1.5.66-3.43,1.56-4.89,2.24a8.15,8.15,0,0,1-3.29,1.1,5.59,5.59,0,0,1-3-10.34C29,12.73,34.09,10,34.09,10a6.46,6.46,0,0,0-5-2C25.67,8,18.51,12.7,18.51,12.7a5.61,5.61,0,0,1-4.93.13L8,10.89v19.4s1.59.46,3,1a6.33,6.33,0,0,1,1.56-2.47,6.17,6.17,0,0,1,8.48-.06,5.4,5.4,0,0,1,1.34,2.37,5.49,5.49,0,0,1,2.29,1.4A5.4,5.4,0,0,1,26,34.94a5.47,5.47,0,0,1,3.71,4,5.38,5.38,0,0,1,2.39,1.43,5.65,5.65,0,0,1,1.48,4.89,0,0,0,0,1,0,0s.8.9,1.29,1.39a2.46,2.46,0,0,0,3.48-3.48s2,2.48,4.28,1c2-1.4,1.69-3.06.74-4a3.19,3.19,0,0,0,4.77.13,2.45,2.45,0,0,0,.13-3.3s1.33,1.81,4,.12c1.89-1.6,1-3.43,0-4.39Z"/>
</svg>
);
}
function IconIssues({ style }) {
return (
<svg style={style} viewBox="0 0 283.722 283.722" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M184.721,128.156c4.398-14.805,7.516-29.864,8.885-43.783c0.06-0.607-0.276-1.159-0.835-1.373l-70.484-26.932c-0.152-0.058-0.312-0.088-0.475-0.088c-0.163,0-0.322,0.03-0.474,0.088L50.851,83c-0.551,0.21-0.894,0.775-0.835,1.373c2.922,29.705,13.73,64.62,28.206,91.12c14.162,25.923,30.457,41.4,43.589,41.4c8.439,0,18.183-6.4,27.828-17.846l-16.375-16.375c-14.645-14.645-14.645-38.389,0-53.033C147.396,115.509,169.996,115.017,184.721,128.156z"/>
<path d="M121.812,236.893c-46.932,0-85.544-87.976-91.7-150.562c-0.94-9.56,4.627-18.585,13.601-22.013l70.486-26.933c2.451-0.937,5.032-1.405,7.613-1.405c2.581,0,5.162,0.468,7.614,1.405l70.484,26.932c8.987,3.434,14.542,12.439,13.6,22.013c-1.773,18.028-6.244,38.161-12.826,57.693l11.068,11.068l17.865-17.866c6.907-20.991,11.737-42.285,13.845-61.972c1.322-12.347-5.53-24.102-16.934-29.017l-93.512-40.3c-7.152-3.082-15.257-3.082-22.409,0l-93.512,40.3C5.705,51.147-1.159,62.922,0.162,75.255c8.765,81.851,64.476,191.512,121.65,191.512c0.356,0,0.712-0.023,1.068-0.032c-1.932-10.793,0.888-22.262,8.456-31.06C128.205,236.465,125.029,236.893,121.812,236.893z"/>
<path d="M240.037,208.125c7.327-7.326,30.419-30.419,37.827-37.827c7.81-7.811,7.81-20.475,0-28.285c-7.811-7.811-20.475-7.811-28.285,0c-7.41,7.41-30.5,30.5-37.827,37.827l-37.827-37.827c-7.81-7.811-20.475-7.811-28.285,0c-7.811,7.811-7.811,20.475,0,28.285l37.827,37.827c-7.326,7.326-30.419,30.419-37.827,37.827c-7.811,7.811-7.811,20.475,0,28.285c7.809,7.809,20.474,7.811,28.285,0c7.41-7.41,30.5-30.499,37.827-37.827l37.827,37.827c7.809,7.809,20.474,7.811,28.285,0c7.81-7.81,7.81-20.475,0-28.285L240.037,208.125z"/>
</svg>
);
}
function IconImportant({ style, className }) {
return (
<svg style={style} className={className} viewBox="0 0 299.467 299.467" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M293.588,219.182L195.377,32.308c-8.939-17.009-26.429-27.575-45.644-27.575s-36.704,10.566-45.644,27.575L5.879,219.182c-8.349,15.887-7.77,35.295,1.509,50.647c9.277,15.36,26.189,24.903,44.135,24.903h196.422c17.943,0,34.855-9.542,44.133-24.899C301.357,254.477,301.936,235.069,293.588,219.182z M266.4,254.319c-3.881,6.424-10.953,10.414-18.456,10.414H51.522c-7.505,0-14.576-3.99-18.457-10.417c-3.88-6.419-4.121-14.534-0.63-21.177l98.211-186.876c3.737-7.112,11.052-11.531,19.087-11.531s15.35,4.418,19.087,11.531l98.211,186.876C270.522,239.782,270.281,247.897,266.4,254.319z"/>
<polygon points="144.037,201.424 155.429,201.424 166.545,87.288 132.92,87.288"/>
<path d="M149.733,212.021c-8.98,0-16.251,7.272-16.251,16.252c0,8.971,7.271,16.251,16.251,16.251c8.979,0,16.251-7.28,16.251-16.251C165.984,219.294,158.713,212.021,149.733,212.021z"/>
</svg>
);
}
// ── Status icons next to customer name ──────────────────────────────────────
// direction: "inbound" = client sent last, "outbound" = we sent last, null = unknown
function CustomerStatusIcons({ customer, direction }) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
// "important" = we have an open issue or negotiation AND we sent the last message
// (pending reply from client) — shown as breathing exclamation
const pendingOurReply = direction === "inbound";
if (!hasNeg && !hasIssue) return null;
// Color logic:
// negotiations: yellow (#f08c00) if outbound (we sent last), orange (#f76707) if inbound (client sent)
// issues: yellow (#f08c00) if outbound, red (#f34b4b) if inbound
const negColor = pendingOurReply ? "var(--crm-status-alert)" : "var(--crm-status-warn)";
const issColor = pendingOurReply ? "var(--crm-status-danger)" : "var(--crm-status-warn)";
const iconSize = { width: 13, height: 13, display: "inline-block", flexShrink: 0 };
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, marginLeft: 6, verticalAlign: "middle" }}>
{hasNeg && (
<span title="Negotiating" style={{ color: negColor, display: "inline-flex" }}>
<IconNegotiations style={iconSize} />
</span>
)}
{hasIssue && (
<span title="Has issue" style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={iconSize} />
</span>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-warn)", display: "inline-flex" }}>
<IconImportant style={iconSize} className="crm-icon-breathe" />
</span>
)}
</span>
);
}
// ── 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;
return (
<div
key={id}
draggable
onDragStart={() => setDragging(id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragEnd={() => setDragging(null)}
onClick={() => onChange(id, !visible[id])}
style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6,
cursor: "pointer", userSelect: "none",
backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={(e) => { 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: "grab" }}></span>
<div style={{
width: 14, height: 14, borderRadius: 3, border: `2px solid ${visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
backgroundColor: visible[id] ? "var(--accent)" : "transparent",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
{visible[id] && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}></span>}
</div>
<span className="text-xs" style={{ color: "var(--text-primary)" }}>{col.label}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Filter dropdown ──────────────────────────────────────────────────────────
const FILTER_OPTIONS = [
{ value: "negotiating", label: "Negotiating" },
{ value: "has_problem", label: "Has Open Issue" },
];
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>
);
}
// ── 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, onUpdate }) {
const [open, setOpen] = useState(false);
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
const [loading, setLoading] = useState(null);
const btnRef = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
const handler = (e) => {
if (
btnRef.current && !btnRef.current.contains(e.target) &&
menuRef.current && !menuRef.current.contains(e.target)
) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleOpen = (e) => {
e.stopPropagation();
if (open) { setOpen(false); return; }
const rect = btnRef.current.getBoundingClientRect();
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
setOpen(true);
};
const toggle = async (type, e) => {
e.stopPropagation();
setLoading(type);
try {
const endpoint = type === "negotiating"
? `/crm/customers/${customer.id}/toggle-negotiating`
: `/crm/customers/${customer.id}/toggle-problem`;
const updated = await api.post(endpoint);
onUpdate(updated);
} catch {
alert("Failed to update status");
} finally {
setLoading(null);
setOpen(false);
}
};
return (
<div onClick={e => e.stopPropagation()}>
<button
ref={btnRef}
onClick={handleOpen}
style={{
padding: "4px 10px", fontSize: 11, fontWeight: 600, borderRadius: 5,
border: "1px solid var(--border-primary)", cursor: "pointer",
backgroundColor: "transparent", color: "var(--text-secondary)",
}}
>
Actions
</button>
{open && (
<div
ref={menuRef}
onClick={e => e.stopPropagation()}
style={{
position: "fixed", top: menuPos.top, right: menuPos.right, zIndex: 9999,
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
borderRadius: 8, minWidth: 180, boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<button
onClick={(e) => toggle("negotiating", e)}
disabled={loading === "negotiating"}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
background: "none", border: "none",
color: customer.negotiating ? "#a16207" : "var(--text-primary)",
borderBottom: "1px solid var(--border-secondary)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
>
{loading === "negotiating" ? "..." : customer.negotiating ? "End Negotiations" : "Start Negotiating"}
</button>
<button
onClick={(e) => toggle("problem", e)}
disabled={loading === "problem"}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 14px", fontSize: 12, fontWeight: 500, cursor: "pointer",
background: "none", border: "none",
color: customer.has_problem ? "#b91c1c" : "var(--text-primary)",
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"}
onMouseLeave={e => e.currentTarget.style.backgroundColor = ""}
>
{loading === "problem" ? "..." : customer.has_problem ? "Resolve Issue" : "Has Problem"}
</button>
</div>
)}
</div>
);
}
// ── Main component ───────────────────────────────────────────────────────────
export default function CustomerList() {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [tagFilter, setTagFilter] = 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({});
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
@@ -32,10 +522,12 @@ export default function CustomerList() {
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (tagFilter) params.set("tag", tagFilter);
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 {
@@ -43,9 +535,108 @@ export default function CustomerList() {
}
};
useEffect(() => {
fetchCustomers();
}, [search, tagFilter]);
const fetchDirections = async (list) => {
const flagged = list.filter(c => c.negotiating || c.has_problem);
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])
.catch(() => [c.id, null])
)
);
const map = {};
for (const r of results) {
if (r.status === "fulfilled") {
const [id, dir] = r.value;
map[id] = dir;
}
}
setCommDirections(prev => ({ ...prev, ...map }));
};
useEffect(() => { fetchCustomers(); }, [search, sort]);
const updateColVisible = (id, vis) => {
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 =>
(!activeFilters.has("negotiating") || c.negotiating) &&
(!activeFilters.has("has_problem") || c.has_problem)
);
const handleCustomerUpdate = (updated) => {
setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c));
// Refresh direction for this customer if it now has/lost a flag
if (updated.negotiating || updated.has_problem) {
api.get(`/crm/customers/${updated.id}/last-comm-direction`)
.then(r => setCommDirections(prev => ({ ...prev, [updated.id]: r.direction })))
.catch(() => {});
}
};
const renderCell = (col, c) => {
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)" }}>
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
<CustomerStatusIcons customer={c} direction={commDirections[c.id] ?? null} />
</div>
</td>
);
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>;
}
};
return (
<div>
@@ -65,105 +656,72 @@ export default function CustomerList() {
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Search by name, location, email, phone, tags..."
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}
/>
<input
type="text"
placeholder="Filter by tag..."
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="w-40 px-3 py-2 text-sm rounded-md border"
style={inputStyle}
<SortDropdown value={sort} onChange={setSort} />
<FilterDropdown active={activeFilters} onChange={setActiveFilters} />
<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)" }}
>
<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>
) : customers.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)" }}
>
No customers found.
) : 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="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)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Organization</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Location</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Phone</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Tags</th>
{visibleCols.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>
{col.label}
</th>
))}
{canEdit && <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--text-secondary)" }}></th>}
</tr>
</thead>
<tbody>
{customers.map((c, index) => {
const loc = c.location || {};
const locationStr = [loc.city, loc.country].filter(Boolean).join(", ");
return (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < customers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{[c.title, c.name, c.surname].filter(Boolean).join(" ")}
{filteredCustomers.map((c, index) => (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleCols.map((col) => renderCell(col, c))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>
<td className="px-4 py-3" style={{ color: "var(--text-muted)" }}>{locationStr || "—"}</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "email") || "—"}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{primaryContact(c, "phone") || "—"}
</td>
<td 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>
</tr>
);
})}
)}
</tr>
))}
</tbody>
</table>
</div>