import { useState, useEffect, useCallback, useRef } from "react"; import { Link } from "react-router-dom"; import api from "../../api/client"; import MailViewModal from "../components/MailViewModal"; import ComposeEmailModal from "../components/ComposeEmailModal"; import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons"; // Display labels for transport types - always lowercase const TYPE_LABELS = { email: "e-mail", whatsapp: "whatsapp", call: "phonecall", sms: "sms", note: "note", in_person: "in person", }; const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"]; const DIRECTIONS = ["inbound", "outbound", "internal"]; const COMM_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }); const COMM_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); function formatCommDateTime(value) { if (!value) return ""; const d = new Date(value); if (Number.isNaN(d.getTime())) return ""; return `${COMM_DATE_FMT.format(d)} · ${COMM_TIME_FMT.format(d).toLowerCase()}`; } const selectStyle = { backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)", fontSize: 13, padding: "6px 10px", borderRadius: 6, border: "1px solid", cursor: "pointer", }; // Customer search mini modal (replaces the giant dropdown) function CustomerPickerModal({ open, onClose, customers, value, onChange }) { const [q, setQ] = useState(""); const inputRef = useRef(null); useEffect(() => { if (open) { setQ(""); setTimeout(() => inputRef.current?.focus(), 60); } }, [open]); // ESC to close useEffect(() => { if (!open) return; const handler = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [open, onClose]); if (!open) return null; const lower = q.trim().toLowerCase(); const filtered = customers.filter((c) => !lower || (c.name || "").toLowerCase().includes(lower) || (c.surname || "").toLowerCase().includes(lower) || (c.organization || "").toLowerCase().includes(lower) || (c.contacts || []).some((ct) => (ct.value || "").toLowerCase().includes(lower)) ); return (
e.stopPropagation()} >
setQ(e.target.value)} placeholder="Search customer..." style={{ width: "100%", padding: "7px 10px", fontSize: 13, borderRadius: 6, border: "1px solid var(--border-primary)", backgroundColor: "var(--bg-input)", color: "var(--text-primary)", outline: "none" }} />
{/* All customers option */}
{ onChange(""); onClose(); }} style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === "" ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === "" ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent", fontWeight: value === "" ? 600 : 400 }} onMouseEnter={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} onMouseLeave={(e) => { if (value !== "") e.currentTarget.style.backgroundColor = "transparent"; }} > All customers
{filtered.map((c) => (
{ onChange(c.id); onClose(); }} style={{ padding: "9px 14px", fontSize: 13, cursor: "pointer", color: value === c.id ? "var(--accent)" : "var(--text-primary)", backgroundColor: value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent" }} onMouseEnter={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }} onMouseLeave={(e) => { if (value !== c.id) e.currentTarget.style.backgroundColor = value === c.id ? "color-mix(in srgb, var(--accent) 8%, var(--bg-card))" : "transparent"; }} >
{c.name}{c.surname ? ` ${c.surname}` : ""}
{c.organization &&
{c.organization}
}
))} {filtered.length === 0 && q && (
No customers found
)}
); } export default function CommsPage() { const [entries, setEntries] = useState([]); const [customers, setCustomers] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const [dirFilter, setDirFilter] = useState(""); const [custFilter, setCustFilter] = useState(""); const [expandedId, setExpandedId] = useState(null); // only 1 at a time const [syncing, setSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); const [custPickerOpen, setCustPickerOpen] = useState(false); // Modals const [viewEntry, setViewEntry] = useState(null); const [composeOpen, setComposeOpen] = useState(false); const [composeTo, setComposeTo] = useState(""); const [composeFromAccount, setComposeFromAccount] = useState(""); const loadAll = useCallback(async () => { setLoading(true); setError(""); try { const params = new URLSearchParams({ limit: 200 }); if (typeFilter) params.set("type", typeFilter); if (dirFilter) params.set("direction", dirFilter); const [commsData, custsData] = await Promise.all([ api.get(`/crm/comms/all?${params}`), api.get("/crm/customers"), ]); setEntries(commsData.entries || []); const map = {}; for (const c of custsData.customers || []) map[c.id] = c; setCustomers(map); } catch (err) { setError(err.message); } finally { setLoading(false); } }, [typeFilter, dirFilter]); useEffect(() => { loadAll(); }, [loadAll]); const syncEmails = async () => { setSyncing(true); setSyncResult(null); try { const data = await api.post("/crm/comms/email/sync", {}); setSyncResult(data); await loadAll(); } catch (err) { setSyncResult({ error: err.message }); } finally { setSyncing(false); } }; // Toggle expand — only one at a time const toggleExpand = (id) => setExpandedId((prev) => (prev === id ? null : id)); const openReply = (entry) => { const toAddr = entry.direction === "inbound" ? (entry.from_addr || "") : (Array.isArray(entry.to_addrs) ? entry.to_addrs[0] : ""); setViewEntry(null); setComposeTo(toAddr); setComposeOpen(true); }; const filtered = custFilter ? entries.filter((e) => e.customer_id === custFilter) : entries; const sortedFiltered = [...filtered].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; if (tb !== ta) return tb - ta; return String(b?.id || "").localeCompare(String(a?.id || "")); }); const customerOptions = Object.values(customers).sort((a, b) => (a.name || "").localeCompare(b.name || "") ); const selectedCustomerLabel = custFilter && customers[custFilter] ? customers[custFilter].name + (customers[custFilter].organization ? ` — ${customers[custFilter].organization}` : "") : "All customers"; return (
{/* Header */}

Activity Log

All customer communications across all channels

{syncResult && ( {syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`} )}
{/* Filters */}
{/* Customer picker button */} {(typeFilter || dirFilter || custFilter) && ( )}
{error && (
{error}
)} {loading ? (
Loading...
) : sortedFiltered.length === 0 ? (
No communications found.
) : (
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
{/* Connector line */}
{sortedFiltered.map((entry) => { const customer = customers[entry.customer_id]; const isExpanded = expandedId === entry.id; const isEmail = entry.type === "email"; return (
{/* Type icon marker */}
entry.body && toggleExpand(entry.id)} > {/* Entry header */}
{customer ? ( e.stopPropagation()} > {customer.name} {customer.organization ? ` · ${customer.organization}` : ""} ) : ( {entry.from_addr || entry.customer_id || "—"} )} {entry.subject && ( {entry.subject} )}
{/* Full View button (for email entries) */} {isEmail && ( )} {formatCommDateTime(entry.occurred_at)} {entry.body && ( {isExpanded ? "▲" : "▼"} )}
{/* Body */} {entry.body && (

{entry.body}

)} {/* Footer: logged_by + attachments + Quick Reply */} {(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
{entry.logged_by && ( by {entry.logged_by} )} {entry.attachments?.length > 0 && ( 📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""} )} {isExpanded && isEmail && ( )}
)}
); })}
)} {/* Customer Picker Modal */} setCustPickerOpen(false)} customers={customerOptions} value={custFilter} onChange={setCustFilter} /> {/* Mail View Modal */} setViewEntry(null)} entry={viewEntry} customerName={viewEntry ? customers[viewEntry.customer_id]?.name : null} onReply={(toAddr, sourceAccount) => { setViewEntry(null); setComposeTo(toAddr); setComposeFromAccount(sourceAccount || ""); setComposeOpen(true); }} /> {/* Compose Modal */} { setComposeOpen(false); setComposeFromAccount(""); }} defaultTo={composeTo} defaultFromAccount={composeFromAccount} requireFromAccount={true} onSent={() => loadAll()} />
); }