Files
bellsystems-cp/frontend/src/crm/inbox/InboxPage.jsx

328 lines
13 KiB
JavaScript

import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import api from "../../api/client";
const TYPE_COLORS = {
email: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" },
whatsapp: { bg: "#dcfce7", color: "#166534" },
call: { bg: "#fef9c3", color: "#854d0e" },
sms: { bg: "#fef3c7", color: "#92400e" },
note: { bg: "var(--bg-card-hover)", color: "var(--text-secondary)" },
in_person: { bg: "#ede9fe", color: "#5b21b6" },
};
const COMMS_TYPES = ["email", "whatsapp", "call", "sms", "note", "in_person"];
const DIRECTIONS = ["inbound", "outbound", "internal"];
function TypeBadge({ type }) {
const s = TYPE_COLORS[type] || TYPE_COLORS.note;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: s.bg, color: s.color }}
>
{type}
</span>
);
}
function DirectionIcon({ direction }) {
if (direction === "inbound")
return <span title="Inbound" style={{ color: "var(--success-text)" }}></span>;
if (direction === "outbound")
return <span title="Outbound" style={{ color: "var(--accent)" }}></span>;
return <span title="Internal" style={{ color: "var(--text-muted)" }}></span>;
}
export default function InboxPage() {
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 [expanded, setExpanded] = useState({});
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null); // { new_count } | null
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 || []);
// Build id→name map
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);
}
};
const toggleExpand = (id) =>
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
// Client-side customer filter
const filtered = custFilter
? entries.filter((e) => e.customer_id === custFilter)
: entries;
const customerOptions = Object.values(customers).sort((a, b) =>
(a.name || "").localeCompare(b.name || "")
);
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",
};
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Inbox</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
All communications across all customers
</p>
</div>
<div className="flex items-center gap-2">
{syncResult && (
<span className="text-xs" style={{ color: syncResult.error ? "var(--danger-text)" : "var(--success-text)" }}>
{syncResult.error ? syncResult.error : `${syncResult.new_count} new email${syncResult.new_count !== 1 ? "s" : ""}`}
</span>
)}
<button
onClick={syncEmails}
disabled={syncing || loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ border: "1px solid", borderColor: "var(--border-primary)", color: "var(--text-secondary)", opacity: (syncing || loading) ? 0.6 : 1 }}
>
{syncing ? "Syncing..." : "Sync Emails"}
</button>
<button
onClick={loadAll}
disabled={loading}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ borderColor: "var(--border-primary)", border: "1px solid", color: "var(--text-secondary)", opacity: loading ? 0.6 : 1 }}
>
Refresh
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} style={selectStyle}>
<option value="">All types</option>
{COMMS_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select value={dirFilter} onChange={(e) => setDirFilter(e.target.value)} style={selectStyle}>
<option value="">All directions</option>
{DIRECTIONS.map((d) => <option key={d} value={d}>{d}</option>)}
</select>
<select value={custFilter} onChange={(e) => setCustFilter(e.target.value)} style={selectStyle}>
<option value="">All customers</option>
{customerOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.organization ? `${c.organization}` : ""}</option>
))}
</select>
{(typeFilter || dirFilter || custFilter) && (
<button
onClick={() => { setTypeFilter(""); setDirFilter(""); setCustFilter(""); }}
className="px-3 py-1.5 text-sm rounded-md cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)", backgroundColor: "var(--danger-bg)", borderRadius: 6 }}
>
Clear filters
</button>
)}
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : filtered.length === 0 ? (
<div className="rounded-lg p-10 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No communications found.
</div>
) : (
<div>
<div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
{filtered.length} entr{filtered.length !== 1 ? "ies" : "y"}
</div>
{/* Timeline */}
<div style={{ position: "relative" }}>
{/* Connector line */}
<div
style={{
position: "absolute",
left: 19,
top: 12,
bottom: 12,
width: 2,
backgroundColor: "var(--border-secondary)",
zIndex: 0,
}}
/>
<div className="space-y-2">
{filtered.map((entry) => {
const customer = customers[entry.customer_id];
const isExpanded = !!expanded[entry.id];
const typeStyle = TYPE_COLORS[entry.type] || TYPE_COLORS.note;
return (
<div
key={entry.id}
style={{ position: "relative", paddingLeft: 44 }}
>
{/* Dot */}
<div
style={{
position: "absolute",
left: 12,
top: 14,
width: 14,
height: 14,
borderRadius: "50%",
backgroundColor: typeStyle.bg,
border: `2px solid ${typeStyle.color}`,
zIndex: 1,
}}
/>
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
>
{/* Entry header */}
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
<TypeBadge type={entry.type} />
<DirectionIcon direction={entry.direction} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{entry.direction}</span>
{customer ? (
<Link
to={`/crm/customers/${entry.customer_id}`}
className="text-xs font-medium hover:underline"
style={{ color: "var(--accent)" }}
onClick={(e) => e.stopPropagation()}
>
{customer.name}
{customer.organization ? ` · ${customer.organization}` : ""}
</Link>
) : (
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{entry.customer_id}</span>
)}
<span className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}>
{entry.occurred_at ? new Date(entry.occurred_at).toLocaleString() : ""}
</span>
{entry.body && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
</span>
)}
</div>
{/* Subject / body preview */}
{(entry.subject || entry.body) && (
<div className="px-4 pb-3 border-t" style={{ borderColor: "var(--border-secondary)" }}>
{entry.subject && (
<p className="text-sm font-medium mt-2" style={{ color: "var(--text-heading)" }}>
{entry.subject}
</p>
)}
{entry.body && (
<p
className="text-sm mt-1"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 2,
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
{entry.body}
</p>
)}
</div>
)}
{/* Footer */}
{(entry.logged_by || (entry.attachments && entry.attachments.length > 0)) && (
<div className="px-4 pb-2 flex items-center gap-3">
{entry.logged_by && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
by {entry.logged_by}
</span>
)}
{entry.attachments && entry.attachments.length > 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}