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>
This commit is contained in:
2026-03-25 10:39:38 +02:00
parent fee686a9f3
commit 5d8ef96d4c
33 changed files with 3699 additions and 1455 deletions

View File

@@ -3,6 +3,23 @@ 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)",
@@ -46,6 +63,7 @@ function resolveLanguage(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 },
@@ -95,35 +113,6 @@ function saveColumnPrefs(visible, orderedIds) {
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 helpers ─────────────────────────────────────────────────────
@@ -136,46 +125,205 @@ function statusColors(direction) {
return { negColor, issColor, pendingOurReply };
}
// ── Quick mode status cell ───────────────────────────────────────────────────
// Icon sizes — edit these to adjust icon dimensions per mode:
// Quick mode icons: QUICK_ICON_SIZE (negotiations slightly larger than issues)
const QUICK_NEG_ICON_SIZE = 25; // px — negotiations icon in Quick mode
const QUICK_ISS_ICON_SIZE = 20; // px — issues icon in Quick mode
const QUICK_IMP_ICON_SIZE = 17; // px — exclamation icon in Quick mode
// Expanded sub-row icons:
const EXP_NEG_ICON_SIZE = 22; // px — negotiations icon in Expanded sub-rows
const EXP_ISS_ICON_SIZE = 16; // px — issues icon in Expanded sub-rows
const EXP_IMP_ICON_SIZE = 12; // px — exclamation icon in Expanded sub-rows
// ── 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",
]);
function StatusIconsCell({ customer, direction }) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
if (!hasNeg && !hasIssue) return <td className="px-3 py-3" />;
// 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 || [];
const { negColor, issColor, pendingOurReply } = statusColors(direction);
// ── 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" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{hasNeg && (
<span
title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"}
style={{ color: negColor, display: "inline-flex" }}
>
<IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span>
)}
{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={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"}
style={{ color: issColor, display: "inline-flex" }}
>
<IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
<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>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" />
{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>
@@ -183,18 +331,6 @@ function StatusIconsCell({ customer, direction }) {
);
}
// ── Original inline icons (small, in name cell) ──────────────────────────────
function relDays(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d)) return null;
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
if (days === 0) return "today";
if (days === 1) return "yesterday";
return `${days} days ago`;
}
// ── Column toggle ────────────────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
@@ -284,8 +420,13 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
// ── Filter dropdown ──────────────────────────────────────────────────────────
const FILTER_OPTIONS = [
{ value: "negotiating", label: "Negotiating" },
{ value: "has_problem", label: "Has Open Issue" },
{ 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 }) {
@@ -470,105 +611,19 @@ function primaryContact(customer, type) {
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);
}
};
function ActionsDropdown({ customer }) {
const navigate = useNavigate();
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>
<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>
);
}
@@ -621,7 +676,13 @@ export default function CustomerList() {
};
const fetchDirections = async (list) => {
const flagged = list.filter(c => c.negotiating || c.has_problem);
// 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 =>
@@ -643,6 +704,20 @@ export default function CustomerList() {
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) => {
@@ -662,10 +737,15 @@ export default function CustomerList() {
.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 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));
@@ -675,18 +755,9 @@ export default function CustomerList() {
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 }));
if (r.occurred_at || r.date) setLastCommDates(prev => ({ ...prev, [updated.id]: r.occurred_at || r.date }));
})
.catch(() => {});
}
};
const renderCell = (col, c, direction) => {
const renderCell = (col, c, direction, lastCommDate) => {
const loc = c.location || {};
switch (col.id) {
case "name":
@@ -696,7 +767,9 @@ export default function CustomerList() {
</td>
);
case "status":
return <StatusIconsCell key={col.id} customer={c} direction={direction} />;
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": {
@@ -736,23 +809,23 @@ export default function CustomerList() {
}
};
// In expanded mode, hide the status column — info is shown as sub-rows instead
const visibleColsForMode = notesMode === "expanded"
? visibleCols.filter(c => c.id !== "status")
: visibleCols;
const visibleColsForMode = visibleCols;
// Total column count for colSpan on expanded sub-rows
const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0);
// Row gradient background for customers with active status flags
function rowGradient(customer, direction) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
if (!hasNeg && !hasIssue) return undefined;
const pendingOurReply = direction === "inbound";
// 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
? (pendingOurReply ? "rgba(224, 53, 53, 0.07)" : "rgba(224, 53, 53, 0.05)")
: (pendingOurReply ? "rgba(247, 103, 7, 0.07)" : "rgba(232, 165, 4, 0.05)");
? "rgba(224, 53, 53, 0.05)"
: "rgba(247, 103, 7, 0.05)";
return `linear-gradient(to right, ${color} 0%, transparent 70%)`;
}
@@ -824,7 +897,7 @@ export default function CustomerList() {
<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" ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}) }}>
<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>
))}
@@ -835,21 +908,24 @@ export default function CustomerList() {
{pagedCustomers.map((c, index) => {
const direction = commDirections[c.id] ?? null;
const lastDate = lastCommDates[c.id] ?? null;
const hasStatus = c.negotiating || c.has_problem;
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, direction);
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" && hasStatus))
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`}
@@ -859,7 +935,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
@@ -868,92 +944,106 @@ export default function CustomerList() {
</tr>
);
if (notesMode === "expanded" && hasStatus) {
const { negColor, issColor, pendingOurReply } = statusColors(direction);
const when = relDays(lastDate);
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;
const subRows = [];
// 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");
if (c.negotiating) {
let text;
if (pendingOurReply) {
text = when
? `Undergoing negotiations — client last contacted us ${when}. Reply needed.`
: "Undergoing negotiations — client is awaiting our reply.";
} else {
text = when
? `Undergoing negotiations — we last reached out ${when}.`
: "Undergoing negotiations.";
}
subRows.push(
<tr
key={`${c.id}-neg`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg ? subRowBg : rowBackground,
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: negColor, display: "inline-flex" }}>
<IconNegotiations style={{ width: EXP_NEG_ICON_SIZE, height: EXP_NEG_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: negColor, fontWeight: 500 }}>{text}</span>
// 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>
</td>
</tr>
);
}
if (c.has_problem) {
let text;
if (pendingOurReply) {
text = when
? `Open issue — client reached out ${when} and is awaiting our response.`
: "Open issue — client is awaiting our response.";
} else {
text = when
? `Open issue — we last contacted the client ${when}.`
: "Open issue — under investigation.";
<span style={{ fontSize: 11.5, color: "var(--crm-issue-active-text)", fontWeight: 500 }}>{label}</span>
</div>
), isLastSubRow);
}
subRows.push(
<tr
key={`${c.id}-iss`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg ? subRowBg : rowBackground,
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={{ width: EXP_ISS_ICON_SIZE, height: EXP_ISS_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-danger)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: issColor, fontWeight: 500 }}>{text}</span>
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>
</td>
</tr>
);
}
<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(
@@ -980,7 +1070,7 @@ export default function CustomerList() {
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{visibleColsForMode.map((col) => renderCell(col, c, direction, lastDate))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />