feat: CRM customer/order UI overhaul

Orders:
- Auto-set customer status to ACTIVE when creating a new order (both "+ New Order" and "Init Negotiations")
- Update Status panel now resets datetime to current time each time it opens
- Empty note on status update saves as empty string instead of falling back to previous note
- Default note pre-filled per status type when Update Status panel opens or status changes
- Timeline items now show verbose date/time ("25 March 2026, 4:49 pm") with muted updated-by indicator

CustomerDetail:
- Reordered tabs: Overview | Communication | Quotations | Orders | Finance | Files & Media | Devices | Support
- Renamed "Financials" tab to "Finance"

CustomerList:
- Location column shows city only, falls back to country if city is empty

OverviewTab:
- Hero status container redesigned: icon + status name + verbose description + shimmer border
- Issues, Support, Orders shown as matching hero cards on the same row (status flex-grows to fill space)
- All four cards share identical height, padding, and animated shimmer border effect
- Stat card borders use muted opacity to stay visually consistent with the status card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:21:10 +02:00
parent 5d8ef96d4c
commit 2b05ff8b02
5 changed files with 263 additions and 126 deletions

View File

@@ -141,7 +141,7 @@ function ReadField({ label, value }) {
); );
} }
const TABS = ["Overview", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"]; const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
const LANGUAGE_LABELS = { const LANGUAGE_LABELS = {
el: "Greek", el: "Greek",
@@ -457,7 +457,9 @@ export default function CustomerDetail() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [activeTab, setActiveTab] = useState(() => { const [activeTab, setActiveTab] = useState(() => {
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
const TABS = ["Overview", "Support", "Financials", "Orders", "Quotations", "Communication", "Files & Media", "Devices"]; const TABS = ["Overview", "Communication", "Quotations", "Orders", "Finance", "Files & Media", "Devices", "Support"];
// Also accept old tab names for backwards compat
if (tab === "Financials") return "Finance";
return TABS.includes(tab) ? tab : "Overview"; return TABS.includes(tab) ? tab : "Overview";
}); });
@@ -682,7 +684,7 @@ export default function CustomerDetail() {
useEffect(() => { useEffect(() => {
if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); } if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); }
if (activeTab === "Support") { /* customer data already loaded */ } if (activeTab === "Support") { /* customer data already loaded */ }
if (activeTab === "Financials") { loadOrders(); } if (activeTab === "Finance") { loadOrders(); }
if (activeTab === "Orders") loadOrders(); if (activeTab === "Orders") loadOrders();
if (activeTab === "Communication") loadComms(); if (activeTab === "Communication") loadComms();
if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); } if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); }
@@ -1491,8 +1493,8 @@ export default function CustomerDetail() {
/> />
)} )}
{/* Financials Tab */} {/* Finance Tab */}
{activeTab === "Financials" && ( {activeTab === "Finance" && (
<FinancialsTab <FinancialsTab
customer={customer} customer={customer}
orders={orders} orders={orders}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import api from "../../../api/client"; import api from "../../../api/client";
import { import {
ORDER_STATUS_LABELS, ORDER_STATUS_LABELS,
TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateTime, fmtDateFull, TIMELINE_TYPE_LABELS, OrderStatusChip, fmtDateFull,
} from "./shared"; } from "./shared";
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" }; const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em" };
@@ -11,6 +11,29 @@ const inputStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--bor
const STATUSES = Object.entries(ORDER_STATUS_LABELS); const STATUSES = Object.entries(ORDER_STATUS_LABELS);
const TIMELINE_TYPES = Object.entries(TIMELINE_TYPE_LABELS); const TIMELINE_TYPES = Object.entries(TIMELINE_TYPE_LABELS);
const STATUS_DEFAULT_NOTES = {
negotiating: "Just started Negotiating with the customer on a possible new order",
awaiting_quotation: "We agreed on what the customer needs, and currently drafting a Quote for them",
awaiting_customer_confirmation: "The Quotation has been sent to the Customer. Awaiting their Confirmation",
awaiting_fulfilment: "Customer has accepted the Quotation, and no further action is needed from them. First Chance possible we are going to build their device",
awaiting_payment: "Customer has accepted the Quotation, but a payment|advance is due before we proceed",
manufacturing: "We have begun manufacturing the Customer's Device",
shipped: "The order has been Shipped ! Awaiting Customer Feedback",
installed: "Customer has informed us that the device has been successfully Installed !",
declined: "Customer sadly declined our offer",
complete: "Customer has successfully installed, and operated their product. No further action needed ! The order is complete !",
};
const VERBOSE_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
const TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
function fmtVerboseDateTime(iso) {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return `${VERBOSE_DATE_FMT.format(d)}, ${TIME_FMT.format(d).toLowerCase()}`;
}
// ── Delete confirm modal ────────────────────────────────────────────────────── // ── Delete confirm modal ──────────────────────────────────────────────────────
function DeleteConfirm({ message, onConfirm, onCancel, deleting }) { function DeleteConfirm({ message, onConfirm, onCancel, deleting }) {
return ( return (
@@ -46,8 +69,11 @@ function TimelineEventRow({ event, index, canEdit, onEdit, onDelete }) {
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)" }}> <div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-heading)" }}>
{TIMELINE_TYPE_LABELS[event.type] || event.type} {TIMELINE_TYPE_LABELS[event.type] || event.type}
</div> </div>
{event.note && <div style={{ fontSize: 12, color: "var(--text-muted)", marginTop: 2, whiteSpace: "pre-wrap" }}>{event.note}</div>} {event.note && <div style={{ fontSize: 12, color: "var(--text-primary)", marginTop: 3, whiteSpace: "pre-wrap" }}>{event.note}</div>}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 2 }}>{fmtDateTime(event.date)} · {event.updated_by}</div> <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 3 }}>
{fmtVerboseDateTime(event.date)}
{event.updated_by && <span style={{ opacity: 0.55 }}> · {event.updated_by}</span>}
</div>
</div> </div>
{canEdit && hovered && ( {canEdit && hovered && (
<div style={{ display: "flex", gap: 4, flexShrink: 0, alignSelf: "flex-start" }}> <div style={{ display: "flex", gap: 4, flexShrink: 0, alignSelf: "flex-start" }}>
@@ -146,7 +172,6 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
); );
if (!existingMatch) { if (!existingMatch) {
const statusToType = { const statusToType = {
// "negotiating" as the first archived entry → "Started Negotiations"
negotiating: "negotiations_started", negotiating: "negotiations_started",
awaiting_quotation: "quote_request", awaiting_quotation: "quote_request",
awaiting_customer_confirmation: "quote_sent", awaiting_customer_confirmation: "quote_sent",
@@ -167,13 +192,13 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
archived_status: order.status, archived_status: order.status,
}); });
} }
// Step 2: update to new status (and optionally title) // Step 2: update to new status — if note is left empty, save as empty (not the old note)
await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, { await api.patch(`/crm/customers/${customerId}/orders/${order.id}`, {
status: statusUpdateForm.newStatus, status: statusUpdateForm.newStatus,
title: statusUpdateForm.title || order.title || null, title: statusUpdateForm.title || order.title || null,
status_updated_date: new Date(statusUpdateForm.datetime).toISOString(), status_updated_date: new Date(statusUpdateForm.datetime).toISOString(),
status_updated_by: user?.name || "Staff", status_updated_by: user?.name || "Staff",
notes: statusUpdateForm.note || order.notes || null, notes: statusUpdateForm.note,
}); });
setShowStatusUpdate(false); setShowStatusUpdate(false);
setStatusUpdateForm({ newStatus: order.status || "negotiating", title: order.title || "", note: "", datetime: new Date().toISOString().slice(0, 16) }); setStatusUpdateForm({ newStatus: order.status || "negotiating", title: order.title || "", note: "", datetime: new Date().toISOString().slice(0, 16) });
@@ -262,7 +287,21 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
{/* Update Status button — neutral style */} {/* Update Status button — neutral style */}
{canEdit && ( {canEdit && (
<button type="button" <button type="button"
onClick={(e) => { e.stopPropagation(); setShowStatusUpdate((v) => !v); setShowEditForm(false); }} onClick={(e) => {
e.stopPropagation();
setShowStatusUpdate((v) => {
if (!v) {
// Reset datetime to NOW and pre-fill default note each time panel opens
setStatusUpdateForm((f) => ({
...f,
datetime: new Date().toISOString().slice(0, 16),
note: STATUS_DEFAULT_NOTES[f.newStatus] || "",
}));
}
return !v;
});
setShowEditForm(false);
}}
style={{ style={{
fontSize: 12, padding: "4px 12px", borderRadius: 6, fontSize: 12, padding: "4px 12px", borderRadius: 6,
border: "1px solid var(--border-primary)", border: "1px solid var(--border-primary)",
@@ -306,7 +345,7 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginBottom: 10 }}>
<div> <div>
<div style={{ ...labelStyle, marginBottom: 4 }}>New Status</div> <div style={{ ...labelStyle, marginBottom: 4 }}>New Status</div>
<select value={statusUpdateForm.newStatus} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, newStatus: e.target.value }))} style={inputStyle}> <select value={statusUpdateForm.newStatus} onChange={(e) => setStatusUpdateForm((f) => ({ ...f, newStatus: e.target.value, note: STATUS_DEFAULT_NOTES[e.target.value] || "" }))} style={inputStyle}>
{STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)} {STATUSES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select> </select>
</div> </div>
@@ -508,6 +547,14 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl
} }
// ── New Order form ──────────────────────────────────────────────────────────── // ── New Order form ────────────────────────────────────────────────────────────
async function setCustomerActive(customerId) {
try {
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
} catch {
// Non-critical — silently ignore
}
}
function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumber }) { function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumber }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
order_number: suggestedOrderNumber || "", order_number: suggestedOrderNumber || "",
@@ -531,6 +578,7 @@ function NewOrderForm({ customerId, user, onSaved, onCancel, suggestedOrderNumbe
status_updated_date: new Date(form.date).toISOString(), status_updated_date: new Date(form.date).toISOString(),
status_updated_by: user?.name || "Staff", status_updated_by: user?.name || "Staff",
}); });
await setCustomerActive(customerId);
onSaved(); onSaved();
} catch (err) { } catch (err) {
alert(err.message); alert(err.message);

View File

@@ -4,6 +4,13 @@ import api from "../../../api/client";
import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons"; import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons";
import { REL_STATUS_LABELS, REL_STATUS_STYLES, OrderStatusChip, fmtDate } from "./shared"; import { REL_STATUS_LABELS, REL_STATUS_STYLES, OrderStatusChip, fmtDate } from "./shared";
import clientIcon from "../../../assets/customer-status/client.svg?raw";
import inactiveIcon from "../../../assets/customer-status/inactive.svg?raw";
import churnedIcon from "../../../assets/customer-status/churned.svg?raw";
import exclamationIcon from "../../../assets/customer-status/exclamation.svg?raw";
import wrenchIcon from "../../../assets/customer-status/wrench.svg?raw";
import orderIcon from "../../../assets/customer-status/order.svg?raw";
const LANGUAGE_LABELS = { const LANGUAGE_LABELS = {
af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani", af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani",
eu:"Basque",be:"Belarusian",bn:"Bengali",bs:"Bosnian",bg:"Bulgarian",ca:"Catalan", eu:"Basque",be:"Belarusian",bn:"Bengali",bs:"Bosnian",bg:"Bulgarian",ca:"Catalan",
@@ -56,8 +63,26 @@ function TagsField({ tags }) {
); );
} }
// Verbose description per relationship status
const REL_STATUS_DESCRIPTIONS = {
lead: "This contact is a potential new lead. No active engagement yet.",
prospect: "Actively engaged — exploring possibilities before a formal order.",
active: "Active customer with ongoing or recent commercial activity.",
inactive: "No recent engagement. May need a follow-up to re-activate.",
churned: "Customer has disengaged. Orders declined or no activity for a long period.",
};
// Icon per relationship status (same logic as CustomerList resolveStatusIcon base cases)
const REL_STATUS_ICONS = {
lead: clientIcon,
prospect: clientIcon,
active: clientIcon,
inactive: inactiveIcon,
churned: churnedIcon,
};
// Status badge: shows current status + gear icon to open inline change dropdown // Status badge: shows current status + gear icon to open inline change dropdown
function RelStatusSelector({ customer, onUpdated, canEdit, compact }) { function RelStatusSelector({ customer, onUpdated, canEdit }) {
const statuses = ["lead", "prospect", "active", "inactive", "churned"]; const statuses = ["lead", "prospect", "active", "inactive", "churned"];
const current = customer.relationship_status || "lead"; const current = customer.relationship_status || "lead";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -81,29 +106,45 @@ function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
}; };
const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead; const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead;
const icon = REL_STATUS_ICONS[current];
const description = REL_STATUS_DESCRIPTIONS[current] || "";
ensureShimmer();
const shimmerGradient = `linear-gradient(120deg, ${st.border}44 0%, ${st.border}cc 40%, ${st.border}ff 50%, ${st.border}cc 60%, ${st.border}44 100%)`;
if (compact) {
return ( return (
<div style={{ position: "relative", display: "flex" }} ref={ref}> <div style={{ position: "relative", flex: 1, minWidth: 0 }} ref={ref}>
<button <button
type="button" type="button"
onClick={() => canEdit && setOpen((v) => !v)} onClick={() => canEdit && setOpen((v) => !v)}
className="crm-shimmer-card"
style={{ style={{
display: "flex", alignItems: "center", gap: 8, "--crm-shimmer-gradient": shimmerGradient,
padding: "7px 14px", borderRadius: 8, display: "flex", alignItems: "center", gap: 14,
border: `1px solid ${st.border}`, padding: "14px 18px", borderRadius: 10,
border: `1.5px solid ${st.border}`,
backgroundColor: st.bg, backgroundColor: st.bg,
color: st.color, color: st.color,
cursor: canEdit ? "pointer" : "default", cursor: canEdit ? "pointer" : "default",
fontSize: 13, fontWeight: 700,
width: "100%", width: "100%",
textAlign: "left",
boxShadow: `0 0 16px ${st.border}33`,
transition: "box-shadow 0.2s",
position: "relative", zIndex: 0,
}} }}
> >
<span style={{ fontSize: 10, fontWeight: 600, opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>Status</span> {/* Icon */}
<span style={{ width: 1, height: 14, backgroundColor: "currentColor", opacity: 0.3, flexShrink: 0 }} /> {icon && renderMaskedIconOv(icon, st.color, 28)}
<span>{REL_STATUS_LABELS[current] || current}</span> {/* Text block */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>Customer Status</span>
</div>
<div style={{ fontSize: 16, fontWeight: 800, marginTop: 1, letterSpacing: "0.01em" }}>{REL_STATUS_LABELS[current] || current}</div>
<div style={{ fontSize: 11, fontWeight: 400, opacity: 0.7, marginTop: 3, lineHeight: 1.4 }}>{description}</div>
</div>
{canEdit && ( {canEdit && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7, flexShrink: 0 }}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, flexShrink: 0, alignSelf: "flex-start", marginTop: 2 }}>
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/> <circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg> </svg>
)} )}
@@ -137,69 +178,114 @@ function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
)} )}
</div> </div>
); );
} }
return ( const STAT_CARD_STYLES = {
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}> issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "rgba(224,53,53,0.35)" },
{statuses.map((s) => { support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "rgba(247,103,7,0.35)" },
const sst = REL_STATUS_STYLES[s] || {}; order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "rgba(59,130,246,0.35)" },
const isActive = s === current; };
return (
<button const STAT_ICONS = {
key={s} issue: exclamationIcon,
type="button" support: wrenchIcon,
onClick={() => handleClick(s)} order: orderIcon,
disabled={!canEdit} };
style={{
padding: "5px 14px", const STAT_LABELS = {
borderRadius: 20, issue: "Open Issues",
fontSize: 12, support: "Support Assists",
fontWeight: 600, order: "Open Orders",
cursor: canEdit ? "pointer" : "default", };
border: `1px solid ${isActive ? sst.border : "var(--border-primary)"}`,
backgroundColor: isActive ? sst.bg : "transparent", // Shared shimmer keyframes injected once
color: isActive ? sst.color : "var(--text-muted)", let _shimmerInjected = false;
boxShadow: isActive ? `0 0 8px ${sst.bg}` : "none", function ensureShimmer() {
transition: "all 0.15s ease", if (_shimmerInjected) return;
}} _shimmerInjected = true;
> const style = document.createElement("style");
{REL_STATUS_LABELS[s]} style.textContent = `
</button> @keyframes crm-border-shimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.crm-shimmer-card {
position: relative;
border-radius: 10px;
overflow: visible;
}
.crm-shimmer-card::before {
content: "";
position: absolute;
inset: -1.5px;
border-radius: 11px;
padding: 1.5px;
background: var(--crm-shimmer-gradient);
background-size: 200% 200%;
animation: crm-border-shimmer 3s ease infinite;
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 0;
}
`;
document.head.appendChild(style);
}
function renderMaskedIconOv(icon, color, size = 16) {
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}" style="display:block;width:${size}px;height:${size}px;color:${color};fill:currentColor;">`,
); );
})} return (
</div> <span style={{ width: size, height: size, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}
dangerouslySetInnerHTML={{ __html: svgMarkup }} />
); );
} }
// bg/color/border per chip type // Stat card — mirrors the status hero layout exactly so all cards share the same height
const CHIP_STYLES = { function StatCard({ count, onClick, type }) {
issue: { bg: "var(--crm-issue-active-bg,rgba(224,53,53,0.12))", color: "var(--crm-issue-active-text)", border: "var(--crm-issue-active-text)" }, ensureShimmer();
support: { bg: "var(--crm-support-active-bg,rgba(247,103,7,0.12))", color: "var(--crm-support-active-text)", border: "var(--crm-support-active-text)" }, const s = STAT_CARD_STYLES[type] || {};
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "var(--badge-blue-text)" }, const icon = STAT_ICONS[type];
}; const label = STAT_LABELS[type] || type;
const shimmerGradient = `linear-gradient(120deg, ${s.border}44 0%, ${s.border}cc 40%, ${s.border}ff 50%, ${s.border}cc 60%, ${s.border}44 100%)`;
function StatChip({ count, label, onClick, type }) {
const s = CHIP_STYLES[type] || {};
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="crm-shimmer-card"
style={{ style={{
display: "flex", alignItems: "center", gap: 7, "--crm-shimmer-gradient": shimmerGradient,
padding: "7px 14px", borderRadius: 8, fontSize: 13, display: "flex", alignItems: "center", gap: 12,
border: `1px solid ${s.border || "var(--border-primary)"}`, padding: "14px 18px",
backgroundColor: s.bg || "var(--bg-primary)", border: `1.5px solid ${s.border}`,
color: s.color || "var(--text-secondary)", borderRadius: 10,
backgroundColor: s.bg,
color: s.color,
cursor: "pointer", cursor: "pointer",
flexShrink: 0,
whiteSpace: "nowrap", whiteSpace: "nowrap",
transition: "opacity 0.15s", transition: "box-shadow 0.2s",
fontWeight: 600, boxShadow: `0 0 14px ${s.border}33`,
position: "relative", zIndex: 0,
textAlign: "left",
}} }}
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"} onMouseEnter={(e) => { e.currentTarget.style.boxShadow = `0 0 22px ${s.border}55`; }}
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"} onMouseLeave={(e) => { e.currentTarget.style.boxShadow = `0 0 14px ${s.border}33`; }}
> >
<span style={{ fontSize: 13, fontWeight: 700 }}>{count}</span> {icon && renderMaskedIconOv(icon, s.color, 28)}
<span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", opacity: 0.8 }}>{label}</span> <div>
<div style={{ fontSize: 10, fontWeight: 700, opacity: 0.6, textTransform: "uppercase", letterSpacing: "0.08em" }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 800, lineHeight: 1.1, marginTop: 1 }}>{count}</div>
</div>
</button> </button>
); );
} }
@@ -403,20 +489,17 @@ export default function OverviewTab({
<div> <div>
{/* Main hero info card */} {/* Main hero info card */}
<div className="ui-section-card mb-4"> <div className="ui-section-card mb-4">
{/* Row 1: Status badge + stat chips */} {/* Hero row: status (flex-grow) + stat cards (shrink-to-fit), all on one line */}
<div style={{ display: "flex", alignItems: "stretch", gap: 18, flexWrap: "wrap", marginBottom: 30 }}> <div style={{ display: "flex", alignItems: "stretch", gap: 8, flexWrap: "wrap", marginBottom: 20 }}>
{/* Status badge — includes inline change dropdown via gear */} <RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} />
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} compact />
{/* Stat chips — only shown when count > 0 */}
{openIssues > 0 && ( {openIssues > 0 && (
<StatChip count={openIssues} label={`Issue${openIssues !== 1 ? "s" : ""}`} onClick={() => onTabChange("Support")} type="issue" /> <StatCard count={openIssues} onClick={() => onTabChange("Support")} type="issue" />
)} )}
{supportInquiries > 0 && ( {supportInquiries > 0 && (
<StatChip count={supportInquiries} label={`Support${supportInquiries !== 1 ? " Tickets" : " Ticket"}`} onClick={() => onTabChange("Support")} type="support" /> <StatCard count={supportInquiries} onClick={() => onTabChange("Support")} type="support" />
)} )}
{openOrders > 0 && ( {openOrders > 0 && (
<StatChip count={openOrders} label={`Order${openOrders !== 1 ? "s" : ""}`} onClick={() => onTabChange("Orders")} type="order" /> <StatCard count={openOrders} onClick={() => onTabChange("Orders")} type="order" />
)} )}
</div> </div>

View File

@@ -50,6 +50,10 @@ export function InitNegotiationsModal({ customerId, user, onClose, onSuccess })
date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(), date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(),
created_by: user?.name || "Staff", created_by: user?.name || "Staff",
}); });
// Auto-set customer to Active when a new order is initiated
try {
await api.patch(`/crm/customers/${customerId}/relationship-status`, { status: "active" });
} catch { /* non-critical */ }
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err) {

View File

@@ -777,8 +777,8 @@ export default function CustomerList() {
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{parts.join(", ") || "—"}</td>;
} }
case "location": { case "location": {
const cityCountry = [loc.city, loc.country].filter(Boolean).join(", "); const locationDisplay = loc.city || loc.country || "";
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{cityCountry || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)", whiteSpace: "nowrap" }}>{locationDisplay || "—"}</td>;
} }
case "email": case "email":
return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>; return <td key={col.id} className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>{primaryContact(c, "email") || "—"}</td>;