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

@@ -4,6 +4,13 @@ import api from "../../../api/client";
import { CommTypeIconBadge, CommDirectionIcon } from "../../components/CommIcons";
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 = {
af:"Afrikaans",sq:"Albanian",am:"Amharic",ar:"Arabic",hy:"Armenian",az:"Azerbaijani",
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
function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
function RelStatusSelector({ customer, onUpdated, canEdit }) {
const statuses = ["lead", "prospect", "active", "inactive", "churned"];
const current = customer.relationship_status || "lead";
const [open, setOpen] = useState(false);
@@ -81,125 +106,186 @@ function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
};
const st = REL_STATUS_STYLES[current] || REL_STATUS_STYLES.lead;
const icon = REL_STATUS_ICONS[current];
const description = REL_STATUS_DESCRIPTIONS[current] || "";
if (compact) {
return (
<div style={{ position: "relative", display: "flex" }} ref={ref}>
<button
type="button"
onClick={() => canEdit && setOpen((v) => !v)}
style={{
display: "flex", alignItems: "center", gap: 8,
padding: "7px 14px", borderRadius: 8,
border: `1px solid ${st.border}`,
backgroundColor: st.bg,
color: st.color,
cursor: canEdit ? "pointer" : "default",
fontSize: 13, fontWeight: 700,
width: "100%",
}}
>
<span style={{ fontSize: 10, fontWeight: 600, opacity: 0.65, textTransform: "uppercase", letterSpacing: "0.06em" }}>Status</span>
<span style={{ width: 1, height: 14, backgroundColor: "currentColor", opacity: 0.3, flexShrink: 0 }} />
<span>{REL_STATUS_LABELS[current] || current}</span>
{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 }}>
<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>
)}
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 30,
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
borderRadius: 8, minWidth: 160, boxShadow: "0 8px 24px rgba(0,0,0,0.18)", overflow: "hidden",
}}>
{statuses.map((s) => {
const sst = REL_STATUS_STYLES[s] || {};
const isActive = s === current;
return (
<button key={s} type="button" onClick={() => handleClick(s)}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 16px", fontSize: 13, fontWeight: isActive ? 700 : 400,
cursor: "pointer", background: "none", border: "none",
color: isActive ? sst.color : "var(--text-primary)",
backgroundColor: isActive ? sst.bg : "transparent",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "transparent"; }}
>
{REL_STATUS_LABELS[s]}
</button>
);
})}
</div>
)}
</div>
);
}
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%)`;
return (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{statuses.map((s) => {
const sst = REL_STATUS_STYLES[s] || {};
const isActive = s === current;
return (
<button
key={s}
type="button"
onClick={() => handleClick(s)}
disabled={!canEdit}
style={{
padding: "5px 14px",
borderRadius: 20,
fontSize: 12,
fontWeight: 600,
cursor: canEdit ? "pointer" : "default",
border: `1px solid ${isActive ? sst.border : "var(--border-primary)"}`,
backgroundColor: isActive ? sst.bg : "transparent",
color: isActive ? sst.color : "var(--text-muted)",
boxShadow: isActive ? `0 0 8px ${sst.bg}` : "none",
transition: "all 0.15s ease",
}}
>
{REL_STATUS_LABELS[s]}
</button>
);
})}
<div style={{ position: "relative", flex: 1, minWidth: 0 }} ref={ref}>
<button
type="button"
onClick={() => canEdit && setOpen((v) => !v)}
className="crm-shimmer-card"
style={{
"--crm-shimmer-gradient": shimmerGradient,
display: "flex", alignItems: "center", gap: 14,
padding: "14px 18px", borderRadius: 10,
border: `1.5px solid ${st.border}`,
backgroundColor: st.bg,
color: st.color,
cursor: canEdit ? "pointer" : "default",
width: "100%",
textAlign: "left",
boxShadow: `0 0 16px ${st.border}33`,
transition: "box-shadow 0.2s",
position: "relative", zIndex: 0,
}}
>
{/* Icon */}
{icon && renderMaskedIconOv(icon, st.color, 28)}
{/* 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 && (
<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"/>
</svg>
)}
</button>
{open && (
<div style={{
position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 30,
backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)",
borderRadius: 8, minWidth: 160, boxShadow: "0 8px 24px rgba(0,0,0,0.18)", overflow: "hidden",
}}>
{statuses.map((s) => {
const sst = REL_STATUS_STYLES[s] || {};
const isActive = s === current;
return (
<button key={s} type="button" onClick={() => handleClick(s)}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "9px 16px", fontSize: 13, fontWeight: isActive ? 700 : 400,
cursor: "pointer", background: "none", border: "none",
color: isActive ? sst.color : "var(--text-primary)",
backgroundColor: isActive ? sst.bg : "transparent",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "transparent"; }}
>
{REL_STATUS_LABELS[s]}
</button>
);
})}
</div>
)}
</div>
);
}
// bg/color/border per chip type
const CHIP_STYLES = {
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)" },
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)" },
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "var(--badge-blue-text)" },
const STAT_CARD_STYLES = {
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)" },
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)" },
order: { bg: "var(--badge-blue-bg,rgba(59,130,246,0.12))", color: "var(--badge-blue-text)", border: "rgba(59,130,246,0.35)" },
};
function StatChip({ count, label, onClick, type }) {
const s = CHIP_STYLES[type] || {};
const STAT_ICONS = {
issue: exclamationIcon,
support: wrenchIcon,
order: orderIcon,
};
const STAT_LABELS = {
issue: "Open Issues",
support: "Support Assists",
order: "Open Orders",
};
// Shared shimmer keyframes injected once
let _shimmerInjected = false;
function ensureShimmer() {
if (_shimmerInjected) return;
_shimmerInjected = true;
const style = document.createElement("style");
style.textContent = `
@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 (
<span style={{ width: size, height: size, display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}
dangerouslySetInnerHTML={{ __html: svgMarkup }} />
);
}
// Stat card — mirrors the status hero layout exactly so all cards share the same height
function StatCard({ count, onClick, type }) {
ensureShimmer();
const s = STAT_CARD_STYLES[type] || {};
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%)`;
return (
<button
type="button"
onClick={onClick}
className="crm-shimmer-card"
style={{
display: "flex", alignItems: "center", gap: 7,
padding: "7px 14px", borderRadius: 8, fontSize: 13,
border: `1px solid ${s.border || "var(--border-primary)"}`,
backgroundColor: s.bg || "var(--bg-primary)",
color: s.color || "var(--text-secondary)",
"--crm-shimmer-gradient": shimmerGradient,
display: "flex", alignItems: "center", gap: 12,
padding: "14px 18px",
border: `1.5px solid ${s.border}`,
borderRadius: 10,
backgroundColor: s.bg,
color: s.color,
cursor: "pointer",
flexShrink: 0,
whiteSpace: "nowrap",
transition: "opacity 0.15s",
fontWeight: 600,
transition: "box-shadow 0.2s",
boxShadow: `0 0 14px ${s.border}33`,
position: "relative", zIndex: 0,
textAlign: "left",
}}
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = `0 0 22px ${s.border}55`; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = `0 0 14px ${s.border}33`; }}
>
<span style={{ fontSize: 13, fontWeight: 700 }}>{count}</span>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", opacity: 0.8 }}>{label}</span>
{icon && renderMaskedIconOv(icon, s.color, 28)}
<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>
);
}
@@ -403,20 +489,17 @@ export default function OverviewTab({
<div>
{/* Main hero info card */}
<div className="ui-section-card mb-4">
{/* Row 1: Status badge + stat chips */}
<div style={{ display: "flex", alignItems: "stretch", gap: 18, flexWrap: "wrap", marginBottom: 30 }}>
{/* Status badge — includes inline change dropdown via gear */}
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} compact />
{/* Stat chips — only shown when count > 0 */}
{/* Hero row: status (flex-grow) + stat cards (shrink-to-fit), all on one line */}
<div style={{ display: "flex", alignItems: "stretch", gap: 8, flexWrap: "wrap", marginBottom: 20 }}>
<RelStatusSelector customer={customer} onUpdated={onCustomerUpdated} canEdit={canEdit} />
{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 && (
<StatChip count={supportInquiries} label={`Support${supportInquiries !== 1 ? " Tickets" : " Ticket"}`} onClick={() => onTabChange("Support")} type="support" />
<StatCard count={supportInquiries} onClick={() => onTabChange("Support")} type="support" />
)}
{openOrders > 0 && (
<StatChip count={openOrders} label={`Order${openOrders !== 1 ? "s" : ""}`} onClick={() => onTabChange("Orders")} type="order" />
<StatCard count={openOrders} onClick={() => onTabChange("Orders")} type="order" />
)}
</div>