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:
671
frontend/src/crm/customers/CustomerDetail/OverviewTab.jsx
Normal file
671
frontend/src/crm/customers/CustomerDetail/OverviewTab.jsx
Normal file
@@ -0,0 +1,671 @@
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge: shows current status + gear icon to open inline change dropdown
|
||||
function RelStatusSelector({ customer, onUpdated, canEdit, compact }) {
|
||||
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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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)" },
|
||||
};
|
||||
|
||||
function StatChip({ count, label, onClick, type }) {
|
||||
const s = CHIP_STYLES[type] || {};
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
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)",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "opacity 0.15s",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.opacity = "0.75"}
|
||||
onMouseLeave={(e) => e.currentTarget.style.opacity = "1"}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 700 }}>{count}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.06em", opacity: 0.8 }}>{label}</span>
|
||||
</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">
|
||||
{/* 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 */}
|
||||
{openIssues > 0 && (
|
||||
<StatChip count={openIssues} label={`Issue${openIssues !== 1 ? "s" : ""}`} onClick={() => onTabChange("Support")} type="issue" />
|
||||
)}
|
||||
{supportInquiries > 0 && (
|
||||
<StatChip count={supportInquiries} label={`Support${supportInquiries !== 1 ? " Tickets" : " Ticket"}`} onClick={() => onTabChange("Support")} type="support" />
|
||||
)}
|
||||
{openOrders > 0 && (
|
||||
<StatChip count={openOrders} label={`Order${openOrders !== 1 ? "s" : ""}`} 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user