diff --git a/frontend/src/crm/customers/CustomerDetail.jsx b/frontend/src/crm/customers/CustomerDetail.jsx index db5277b..dad1917 100644 --- a/frontend/src/crm/customers/CustomerDetail.jsx +++ b/frontend/src/crm/customers/CustomerDetail.jsx @@ -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 = { el: "Greek", @@ -457,7 +457,9 @@ export default function CustomerDetail() { const [error, setError] = useState(""); const [activeTab, setActiveTab] = useState(() => { 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"; }); @@ -682,7 +684,7 @@ export default function CustomerDetail() { useEffect(() => { if (activeTab === "Overview") { loadOrders(); loadComms(); loadDevicesAndProducts(); loadLatestQuotations(); } if (activeTab === "Support") { /* customer data already loaded */ } - if (activeTab === "Financials") { loadOrders(); } + if (activeTab === "Finance") { loadOrders(); } if (activeTab === "Orders") loadOrders(); if (activeTab === "Communication") loadComms(); if (activeTab === "Files & Media") { setNcThumbMapState(null); loadMedia(); browseNextcloud(); } @@ -1491,8 +1493,8 @@ export default function CustomerDetail() { /> )} - {/* Financials Tab */} - {activeTab === "Financials" && ( + {/* Finance Tab */} + {activeTab === "Finance" && ( {TIMELINE_TYPE_LABELS[event.type] || event.type} - {event.note &&
{event.note}
} -
{fmtDateTime(event.date)} · {event.updated_by}
+ {event.note &&
{event.note}
} +
+ {fmtVerboseDateTime(event.date)} + {event.updated_by && · {event.updated_by}} +
{canEdit && hovered && (
@@ -146,7 +172,6 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl ); if (!existingMatch) { const statusToType = { - // "negotiating" as the first archived entry → "Started Negotiations" negotiating: "negotiations_started", awaiting_quotation: "quote_request", awaiting_customer_confirmation: "quote_sent", @@ -167,13 +192,13 @@ function OrderCard({ order, customerId, canEdit, user, onReload, isOpen, onToggl 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}`, { status: statusUpdateForm.newStatus, title: statusUpdateForm.title || order.title || null, status_updated_date: new Date(statusUpdateForm.datetime).toISOString(), status_updated_by: user?.name || "Staff", - notes: statusUpdateForm.note || order.notes || null, + notes: statusUpdateForm.note, }); setShowStatusUpdate(false); 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 */} {canEdit && ( - {open && ( -
- {statuses.map((s) => { - const sst = REL_STATUS_STYLES[s] || {}; - const isActive = s === current; - return ( - - ); - })} -
- )} -
- ); - } + 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 ( -
- {statuses.map((s) => { - const sst = REL_STATUS_STYLES[s] || {}; - const isActive = s === current; - return ( - - ); - })} +
+ + {open && ( +
+ {statuses.map((s) => { + const sst = REL_STATUS_STYLES[s] || {}; + const isActive = s === current; + return ( + + ); + })} +
+ )}
); } -// 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(//gi, "") + .replace(//g, "") + .replace( + /]*)>/i, + ``, + ); + return ( + + ); +} + +// 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 ( ); } @@ -403,20 +489,17 @@ export default function OverviewTab({
{/* Main hero info card */}
- {/* Row 1: Status badge + stat chips */} -
- {/* Status badge — includes inline change dropdown via gear */} - - - {/* Stat chips — only shown when count > 0 */} + {/* Hero row: status (flex-grow) + stat cards (shrink-to-fit), all on one line */} +
+ {openIssues > 0 && ( - onTabChange("Support")} type="issue" /> + onTabChange("Support")} type="issue" /> )} {supportInquiries > 0 && ( - onTabChange("Support")} type="support" /> + onTabChange("Support")} type="support" /> )} {openOrders > 0 && ( - onTabChange("Orders")} type="order" /> + onTabChange("Orders")} type="order" /> )}
diff --git a/frontend/src/crm/customers/CustomerDetail/QuickEntryModals.jsx b/frontend/src/crm/customers/CustomerDetail/QuickEntryModals.jsx index 084bf61..bf38fd2 100644 --- a/frontend/src/crm/customers/CustomerDetail/QuickEntryModals.jsx +++ b/frontend/src/crm/customers/CustomerDetail/QuickEntryModals.jsx @@ -50,6 +50,10 @@ export function InitNegotiationsModal({ customerId, user, onClose, onSuccess }) date: form.date ? new Date(form.date).toISOString() : new Date().toISOString(), 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(); onClose(); } catch (err) { diff --git a/frontend/src/crm/customers/CustomerList.jsx b/frontend/src/crm/customers/CustomerList.jsx index e46d873..7ea343d 100644 --- a/frontend/src/crm/customers/CustomerList.jsx +++ b/frontend/src/crm/customers/CustomerList.jsx @@ -777,8 +777,8 @@ export default function CustomerList() { return {parts.join(", ") || "—"}; } case "location": { - const cityCountry = [loc.city, loc.country].filter(Boolean).join(", "); - return {cityCountry || "—"}; + const locationDisplay = loc.city || loc.country || ""; + return {locationDisplay || "—"}; } case "email": return {primaryContact(c, "email") || "—"};