fix: Bugs created after the overhaul, performance and layout fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user