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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user