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 && (
)}
{/* 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()}
/>
);
}