import { useState, useEffect, useCallback, useRef } from "react"; import { Link } from "react-router-dom"; import api from "../../api/client"; import { useAuth } from "../../auth/AuthContext"; import MailViewModal from "../components/MailViewModal"; import ComposeEmailModal from "../components/ComposeEmailModal"; import { CommTypeIconBadge, CommDirectionIcon } from "../components/CommIcons"; // Inline SVG icons — all use currentColor so tinting via CSS color works const IconExpand = () => ; const IconReply = () => ; const IconEdit = () => ; const IconDelete = () => ; // 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_TIME_FMT = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }); const COMM_FULL_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" }); function formatRelativeTime(value) { if (!value) return ""; const d = new Date(value); if (Number.isNaN(d.getTime())) return ""; const diffMs = Date.now() - d.getTime(); const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return "just now"; const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return `${diffMin}m ago`; const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.floor(diffHr / 24); if (diffDay < 7) return diffDay === 1 ? "yesterday" : `${diffDay} days ago`; const diffWk = Math.floor(diffDay / 7); if (diffWk < 5) return diffWk === 1 ? "1 week ago" : `${diffWk} weeks ago`; const diffMo = Math.floor(diffDay / 30); if (diffMo < 12) return diffMo === 1 ? "1 month ago" : `${diffMo} months ago`; const diffYr = Math.floor(diffDay / 365); return diffYr === 1 ? "1 year ago" : `${diffYr} years ago`; } function formatFullDateTime(value) { if (!value) return ""; const d = new Date(value); if (Number.isNaN(d.getTime())) return ""; return `${COMM_FULL_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 { hasPermission } = useAuth(); const canEdit = hasPermission("crm", "edit"); 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); // Hover/edit/delete state for entries const [hoveredId, setHoveredId] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const [editId, setEditId] = useState(null); const [editForm, setEditForm] = useState({}); const [editSaving, setEditSaving] = 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 startEdit = (entry) => { setEditId(entry.id); setEditForm({ type: entry.type || "", direction: entry.direction || "", subject: entry.subject || "", body: entry.body || "", logged_by: entry.logged_by || "", occurred_at: entry.occurred_at ? entry.occurred_at.slice(0, 16) : "", }); }; const handleSaveEdit = async () => { setEditSaving(true); try { const payload = {}; if (editForm.type) payload.type = editForm.type; if (editForm.direction) payload.direction = editForm.direction; if (editForm.subject !== undefined) payload.subject = editForm.subject || null; if (editForm.body !== undefined) payload.body = editForm.body || null; if (editForm.logged_by !== undefined) payload.logged_by = editForm.logged_by || null; if (editForm.occurred_at) payload.occurred_at = new Date(editForm.occurred_at).toISOString(); await api.put(`/crm/comms/${editId}`, payload); setEditId(null); await loadAll(); } catch (err) { alert(err.message || "Failed to save"); } finally { setEditSaving(false); } }; const handleDelete = async (id) => { setDeleting(true); try { await api.delete(`/crm/comms/${id}`); setDeleteId(null); await loadAll(); } catch (err) { alert(err.message || "Failed to delete"); } finally { setDeleting(false); } }; 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 */}

Communications 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"; const isHov = hoveredId === entry.id; const isPendingDelete = deleteId === entry.id; const isEditing = editId === entry.id; return (
setHoveredId(entry.id)} onMouseLeave={() => setHoveredId(null)}> {/* Type icon marker */}
!isEditing && entry.body && toggleExpand(entry.id)} > {/* Hover overlay: gradient + 3-col action panel (no layout shift) */} {isHov && !isPendingDelete && !isEditing && (
{/* Col 1 — date info */}
{entry.direction === "inbound" ? "Received" : entry.direction === "outbound" ? "Sent" : "Logged"} via {TYPE_LABELS[entry.type] || entry.type} {formatFullDateTime(entry.occurred_at)}
{/* Divider */}
{/* Col 2 — Full View / Reply */}
{/* Col 3 — Edit / Delete (canEdit only) */} {canEdit && (
)}
)} {/* Entry header */}
{customer ? ( e.stopPropagation()} > {customer.name} {customer.organization ? ` · ${customer.organization}` : ""} ) : ( {entry.from_addr || entry.customer_id || "—"} )} {entry.subject && ( {entry.subject} )}
{formatRelativeTime(entry.occurred_at)}
{/* Body */} {entry.body && (

{entry.body}

)} {/* Footer */} {(entry.logged_by || (entry.attachments?.length > 0) || isPendingDelete) && (
{entry.logged_by && ( by {entry.logged_by} )} {entry.attachments?.length > 0 && ( 📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""} )} {/* Delete confirmation */} {isPendingDelete && (
Delete this entry?
)}
)} {/* Inline edit form */} {isEditing && (
e.stopPropagation()}>
Type
Direction
Date & Time
setEditForm(f => ({...f, occurred_at: e.target.value}))} className="w-full px-2 py-1.5 text-xs rounded-md border" style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
Subject
setEditForm(f => ({...f, subject: e.target.value}))} className="w-full px-2 py-1.5 text-xs rounded-md border" style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
Body