Files
bellsystems-cp/frontend/src/crm/customers/CustomerDetail/OverviewTab.jsx
bonamin 2b05ff8b02 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>
2026-03-25 20:21:10 +02:00

755 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
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",
zh:"Chinese",hr:"Croatian",cs:"Czech",da:"Danish",nl:"Dutch",en:"English",
et:"Estonian",fi:"Finnish",fr:"French",ka:"Georgian",de:"German",el:"Greek",
gu:"Gujarati",he:"Hebrew",hi:"Hindi",hu:"Hungarian",id:"Indonesian",it:"Italian",
ja:"Japanese",kn:"Kannada",kk:"Kazakh",ko:"Korean",lv:"Latvian",lt:"Lithuanian",
mk:"Macedonian",ms:"Malay",ml:"Malayalam",mt:"Maltese",mr:"Marathi",mn:"Mongolian",
ne:"Nepali",no:"Norwegian",fa:"Persian",pl:"Polish",pt:"Portuguese",pa:"Punjabi",
ro:"Romanian",ru:"Russian",sr:"Serbian",si:"Sinhala",sk:"Slovak",sl:"Slovenian",
es:"Spanish",sw:"Swahili",sv:"Swedish",tl:"Tagalog",ta:"Tamil",te:"Telugu",
th:"Thai",tr:"Turkish",uk:"Ukrainian",ur:"Urdu",uz:"Uzbek",vi:"Vietnamese",
cy:"Welsh",yi:"Yiddish",zu:"Zulu",
};
const CONTACT_TYPE_ICONS = { email:"📧", phone:"📞", whatsapp:"💬", other:"🔗" };
const COMM_TYPE_LABELS = {
email:"e-mail", whatsapp:"whatsapp", call:"phonecall", sms:"sms", note:"note", in_person:"in person",
};
const labelStyle = { fontSize: 11, fontWeight: 600, color: "var(--text-muted)", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 4 };
function AddressField({ loc }) {
if (!loc) return null;
const parts = [loc.address, loc.city, loc.postal_code, loc.region, loc.country].filter(Boolean);
if (!parts.length) return null;
return (
<div style={{ minWidth: 0 }}>
<div style={labelStyle}>Address</div>
<div style={{ fontSize: 14, color: "var(--text-primary)", whiteSpace: "nowrap" }}>{parts.join(", ")}</div>
</div>
);
}
function TagsField({ tags }) {
if (!tags || !tags.length) return null;
return (
<div style={{ minWidth: 0 }}>
<div style={labelStyle}>Tags</div>
<div style={{ display: "flex", flexWrap: "nowrap", gap: 4, overflow: "hidden" }}>
{tags.map((tag) => (
<span key={tag} className="px-2 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}>
{tag}
</span>
))}
</div>
</div>
);
}
// 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 }) {
const statuses = ["lead", "prospect", "active", "inactive", "churned"];
const current = customer.relationship_status || "lead";
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleClick = async (status) => {
if (!canEdit || status === current) return;
setOpen(false);
try {
const updated = await api.patch(`/crm/customers/${customer.id}/relationship-status`, { status });
onUpdated(updated);
} catch (err) {
alert(err.message);
}
};
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%)`;
return (
<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>
);
}
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)" },
};
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={{
"--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: "box-shadow 0.2s",
boxShadow: `0 0 14px ${s.border}33`,
position: "relative", zIndex: 0,
textAlign: "left",
}}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = `0 0 22px ${s.border}55`; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = `0 0 14px ${s.border}33`; }}
>
{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>
);
}
// Modal to show full note text — also doubles as quick-edit modal
function NoteExpandModal({ note, noteIndex, onClose, canEdit, onSaveEdit, startEditing }) {
const [editing, setEditing] = useState(!!startEditing);
const [editText, setEditText] = useState(note.text);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!editText.trim()) return;
setSaving(true);
try {
await onSaveEdit(noteIndex, editText.trim());
onClose();
} catch (err) {
alert(err.message);
} finally {
setSaving(false);
}
};
return (
<div
style={{ position: "fixed", inset: 0, backgroundColor: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200 }}
onClick={onClose}
>
<div
style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 20, maxWidth: 480, width: "90%", border: "1px solid var(--border-primary)", maxHeight: "80vh", overflowY: "auto" }}
onClick={(e) => e.stopPropagation()}
>
{editing ? (
<>
<textarea
autoFocus
rows={8}
value={editText}
onChange={(e) => setEditText(e.target.value)}
style={{ width: "100%", fontSize: 14, backgroundColor: "var(--bg-input)", border: "1px solid var(--border-primary)", borderRadius: 6, padding: "8px 10px", color: "var(--text-primary)", resize: "vertical", lineHeight: 1.6 }}
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 12 }}>
<button type="button" onClick={() => { setEditing(false); setEditText(note.text); }}
style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleSave} disabled={saving || !editText.trim()}
style={{ fontSize: 13, fontWeight: 600, padding: "5px 16px", borderRadius: 6, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: saving ? 0.6 : 1 }}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</>
) : (
<>
<p style={{ fontSize: 14, color: "var(--text-primary)", whiteSpace: "pre-wrap", lineHeight: 1.6, marginBottom: 12 }}>{note.text}</p>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}</p>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 14 }}>
{canEdit && (
<button type="button" onClick={() => setEditing(true)}
style={{ fontSize: 13, padding: "5px 14px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--accent)", cursor: "pointer" }}>
Edit
</button>
)}
<button type="button" onClick={onClose}
style={{ fontSize: 13, padding: "5px 16px", borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-secondary)", cursor: "pointer" }}>
Close
</button>
</div>
</>
)}
</div>
</div>
);
}
// Single note card with 3-line clamp, hover edit button, whole-card click to expand
function NoteCard({ note, noteIndex, onExpand, canEdit }) {
const [hovered, setHovered] = useState(false);
const isTruncated = note.text.length > 180 || (note.text.match(/\n/g) || []).length >= 3;
return (
<div
className="px-3 py-2 rounded-md text-sm"
style={{ backgroundColor: "var(--bg-primary)", position: "relative", cursor: "pointer" }}
onClick={() => onExpand(note, noteIndex)}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Quick-edit button on hover */}
{canEdit && hovered && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onExpand(note, noteIndex, true); }}
style={{
position: "absolute", top: 6, right: 6,
fontSize: 10, fontWeight: 600,
padding: "2px 8px", borderRadius: 4,
border: "1px solid var(--border-primary)",
backgroundColor: "var(--bg-card)",
color: "var(--accent)",
cursor: "pointer",
}}
>
Edit
</button>
)}
<p style={{
color: "var(--text-primary)",
whiteSpace: "pre-wrap",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
paddingRight: canEdit ? 40 : 0,
}}>
{note.text}
</p>
{isTruncated && (
<span style={{ fontSize: 11, color: "var(--accent)", marginTop: 4, display: "block" }}>
(click to expand...)
</span>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>
</div>
);
}
export default function OverviewTab({
customer,
orders,
comms,
latestQuotations,
allDevices,
canEdit,
onCustomerUpdated,
onTabChange,
onExpandComm,
user,
}) {
const navigate = useNavigate();
const loc = customer.location || {};
// Responsive notes columns
const [winWidth, setWinWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWinWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
const notesCols = winWidth >= 2000 ? 3 : winWidth >= 1100 ? 2 : 1;
// Note expand/edit modal: { note, index, startEditing }
const [expandedNote, setExpandedNote] = useState(null);
// Add-note inline state
const [showAddNote, setShowAddNote] = useState(false);
const [newNoteText, setNewNoteText] = useState("");
const [savingNote, setSavingNote] = useState(false);
// Stat counts
const summary = customer.crm_summary || {};
const openIssues = (summary.active_issues_count || 0);
const supportInquiries = (summary.active_support_count || 0);
const openOrders = orders.filter((o) => !["declined", "complete"].includes(o.status)).length;
const handleAddNote = async () => {
if (!newNoteText.trim()) return;
setSavingNote(true);
try {
const newNote = {
text: newNoteText.trim(),
by: user?.name || "Staff",
at: new Date().toISOString(),
};
const updated = await api.put(`/crm/customers/${customer.id}`, {
notes: [...(customer.notes || []), newNote],
});
onCustomerUpdated(updated);
setNewNoteText("");
setShowAddNote(false);
} catch (err) {
alert(err.message);
} finally {
setSavingNote(false);
}
};
const handleEditNote = async (index, newText) => {
const updatedNotes = (customer.notes || []).map((n, i) =>
i === index ? { ...n, text: newText, by: user?.name || n.by, at: new Date().toISOString() } : n
);
const updated = await api.put(`/crm/customers/${customer.id}`, { notes: updatedNotes });
onCustomerUpdated(updated);
};
const notes = customer.notes || [];
return (
<div>
{/* Main hero info card */}
<div className="ui-section-card mb-4">
{/* 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 && (
<StatCard count={openIssues} onClick={() => onTabChange("Support")} type="issue" />
)}
{supportInquiries > 0 && (
<StatCard count={supportInquiries} onClick={() => onTabChange("Support")} type="support" />
)}
{openOrders > 0 && (
<StatCard count={openOrders} onClick={() => onTabChange("Orders")} type="order" />
)}
</div>
{/* Separator between rows */}
<div style={{ borderTop: "1px solid var(--border-secondary)", marginBottom: 16 }} />
{/* Row 2: info fields ← adjust gap here: "gap-row gap-col" */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "16px 70px", alignItems: "start" }}>
{(LANGUAGE_LABELS[customer.language] || customer.language) ? (
<div>
<div style={labelStyle}>Language</div>
<div style={{ fontSize: 14, color: "var(--text-primary)" }}>{LANGUAGE_LABELS[customer.language] || customer.language}</div>
</div>
) : null}
{customer.religion ? (
<div>
<div style={labelStyle}>Religion</div>
<div style={{ fontSize: 14, color: "var(--text-primary)" }}>{customer.religion}</div>
</div>
) : null}
<AddressField loc={loc} />
<TagsField tags={customer.tags} />
</div>
{/* Contacts */}
{(customer.contacts || []).length > 0 && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 8 }}>Contacts</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 0" }}>
{customer.contacts.map((c, i) => (
<div key={i} style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
<div className="flex items-center gap-1.5 text-sm" style={{ padding: "2px 12px 2px 0" }}>
<span className="w-4 flex-shrink-0 text-center">{CONTACT_TYPE_ICONS[c.type] || "🔗"}</span>
<span style={{ color: "var(--text-muted)", flexShrink: 0, fontSize: 11 }}>{c.type}{c.label ? ` (${c.label})` : ""}</span>
<span style={{ color: "var(--text-primary)" }}>{c.value}</span>
{c.primary && (
<span className="px-1.5 py-0.5 text-xs rounded-full flex-shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
Primary
</span>
)}
</div>
{i < customer.contacts.length - 1 && (
<span style={{ color: "var(--border-primary)", paddingRight: 12, fontSize: 16, lineHeight: 1 }}>|</span>
)}
</div>
))}
</div>
</div>
)}
{/* Notes — CSS columns masonry (browser packs items into shortest column natively) */}
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div style={{ ...labelStyle, marginBottom: 8 }}>Notes</div>
<div style={{
columns: notesCols,
columnGap: 8,
}}>
{notes.map((note, idx) => (
<div key={idx} style={{ breakInside: "avoid", marginBottom: 8 }}>
<NoteCard
note={note}
noteIndex={idx}
canEdit={canEdit}
onExpand={(n, i, startEditing) => setExpandedNote({ note: n, index: i, startEditing: !!startEditing })}
/>
</div>
))}
{canEdit && (
<div style={{ breakInside: "avoid", marginBottom: 8 }}>
{showAddNote ? (
<div className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}>
<textarea
autoFocus
rows={3}
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
placeholder="Write a note..."
style={{ width: "100%", fontSize: 13, backgroundColor: "var(--bg-input)", border: "1px solid var(--border-primary)", borderRadius: 5, padding: "6px 8px", color: "var(--text-primary)", resize: "vertical" }}
/>
<div style={{ display: "flex", gap: 6, marginTop: 6, justifyContent: "flex-end" }}>
<button type="button" onClick={() => { setShowAddNote(false); setNewNoteText(""); }}
style={{ fontSize: 11, padding: "3px 10px", borderRadius: 5, border: "1px solid var(--border-primary)", backgroundColor: "transparent", color: "var(--text-muted)", cursor: "pointer" }}>
Cancel
</button>
<button type="button" onClick={handleAddNote} disabled={savingNote || !newNoteText.trim()}
style={{ fontSize: 11, fontWeight: 600, padding: "3px 10px", borderRadius: 5, border: "none", backgroundColor: "var(--btn-primary)", color: "#fff", cursor: "pointer", opacity: savingNote ? 0.6 : 1 }}>
{savingNote ? "Saving..." : "Add"}
</button>
</div>
</div>
) : (
<button type="button" onClick={() => setShowAddNote(true)}
className="px-3 py-2 rounded-md text-sm"
style={{
width: "100%", backgroundColor: "var(--bg-primary)", border: "1px dashed var(--border-primary)",
color: "var(--text-muted)", cursor: "pointer", textAlign: "left",
fontSize: 12, fontStyle: "italic",
}}>
+ add new note
</button>
)}
</div>
)}
</div>
</div>
</div>
{/* Latest Orders — only shown if orders exist */}
{orders.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Orders</div>
<button type="button" onClick={() => onTabChange("Orders")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[...orders].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 3).map((o) => (
<div key={o.id}
className="flex items-center gap-3 text-sm py-2 border-b last:border-0"
style={{ borderColor: "var(--border-secondary)", cursor: "pointer" }}
onClick={() => onTabChange("Orders", o.id)}
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
>
<span className="font-mono text-xs" style={{ color: "var(--text-heading)", minWidth: 110 }}>
{o.order_number || o.id.slice(0, 8)}
</span>
{o.title && <span style={{ color: "var(--text-primary)", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{o.title}</span>}
<OrderStatusChip status={o.status} />
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)", flexShrink: 0 }}>{fmtDate(o.created_at)}</span>
</div>
))}
</div>
</div>
)}
{/* Latest Communications — only shown if comms exist */}
{comms.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Communications</div>
<button type="button" onClick={() => onTabChange("Communication")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{[...comms].sort((a, b) => {
const ta = Date.parse(a?.occurred_at || a?.created_at || "") || 0;
const tb = Date.parse(b?.occurred_at || b?.created_at || "") || 0;
return tb - ta;
}).slice(0, 5).map((entry) => (
<div
key={entry.id}
className="flex items-center gap-2 text-sm py-2 border-b last:border-0 cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-secondary)" }}
onClick={() => { onTabChange("Communication"); setTimeout(() => onExpandComm(entry.id), 50); }}
>
<CommTypeIconBadge type={entry.type} />
<CommDirectionIcon direction={entry.direction} />
<div style={{ flex: 1, minWidth: 0 }}>
<p className="font-medium" style={{ color: "var(--text-primary)" }}>
{entry.subject || <span style={{ color: "var(--text-muted)", fontStyle: "italic" }}>{COMM_TYPE_LABELS[entry.type] || entry.type}</span>}
</p>
{entry.body && (
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{entry.body}</p>
)}
</div>
<span className="text-xs" style={{ color: "var(--text-muted)", flexShrink: 0, marginLeft: 16, whiteSpace: "nowrap" }}>
{fmtDate(entry.occurred_at)}
</span>
</div>
))}
</div>
</div>
)}
{/* Latest Quotations — only shown if quotations exist */}
{latestQuotations.length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Latest Quotations</div>
<button type="button" onClick={() => onTabChange("Quotations")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div>
{latestQuotations.map((q) => (
<div key={q.id} className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0 cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-secondary)" }}
onClick={() => onTabChange("Quotations")}>
<span className="font-mono text-xs" style={{ color: "var(--text-heading)" }}>{q.quotation_number}</span>
<span className="px-1.5 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>{q.status}</span>
<span className="ml-auto" style={{ color: "var(--text-primary)" }}>{Number(q.final_total || 0).toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
{/* Devices — only shown if owned items exist */}
{(customer.owned_items || []).length > 0 && (
<div className="ui-section-card mb-4">
<div className="flex items-center justify-between mb-3">
<div style={{ fontSize: 13, fontWeight: 600, color: "var(--text-heading)" }}>Devices</div>
<button type="button" onClick={() => onTabChange("Devices")}
style={{ fontSize: 12, color: "var(--accent)", background: "none", border: "none", cursor: "pointer" }}>
View all
</button>
</div>
<div>
{(customer.owned_items || []).map((item, i) => {
const matchedDevice = item.type === "console_device"
? allDevices.find((d) => (d.device_id || d.id) === item.device_id)
: null;
return (
<div key={i} className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
style={{ borderColor: "var(--border-secondary)", cursor: item.type === "console_device" && matchedDevice ? "pointer" : "default" }}
onClick={() => { if (item.type === "console_device" && matchedDevice) navigate(`/devices/${matchedDevice.id || matchedDevice.device_id}`); }}>
{item.type === "console_device" && <>
<span style={{ color: "var(--text-primary)", fontWeight: 500 }}>{item.label || matchedDevice?.device_name || item.device_id}</span>
{matchedDevice?.device_type && <span style={{ color: "var(--text-muted)" }}>· {matchedDevice.device_type}</span>}
</>}
{item.type === "product" && <>
<span style={{ color: "var(--text-primary)" }}>{item.product_name || item.product_id}</span>
<span className="text-xs ml-auto" style={{ color: "var(--text-muted)" }}>× {item.quantity || 1}</span>
</>}
{item.type === "freetext" && <span style={{ color: "var(--text-primary)" }}>{item.description}</span>}
</div>
);
})}
</div>
</div>
)}
{expandedNote && (
<NoteExpandModal
note={expandedNote.note}
noteIndex={expandedNote.index}
canEdit={canEdit}
onSaveEdit={handleEditNote}
onClose={() => setExpandedNote(null)}
startEditing={expandedNote.startEditing}
/>
)}
</div>
);
}