328 lines
13 KiB
JavaScript
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>
|
|
);
|
|
}
|