653 lines
33 KiB
JavaScript
653 lines
33 KiB
JavaScript
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 = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 8L21 3M21 3H16M21 3V8M8 8L3 3M3 3L3 8M3 3L8 3M8 16L3 21M3 21H8M3 21L3 16M16 16L21 21M21 21V16M21 21H16"/></svg>;
|
|
const IconReply = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
|
|
const IconEdit = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20,16v4a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V6A2,2,0,0,1,4,4H8"/><polygon points="12.5 15.8 22 6.2 17.8 2 8.3 11.5 8 16 12.5 15.8"/></svg>;
|
|
const IconDelete = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M5.755,20.283,4,8H20L18.245,20.283A2,2,0,0,1,16.265,22H7.735A2,2,0,0,1,5.755,20.283ZM21,4H16V3a1,1,0,0,0-1-1H9A1,1,0,0,0,8,3V4H3A1,1,0,0,0,3,6H21a1,1,0,0,0,0-2Z"/></svg>;
|
|
|
|
// 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 (
|
|
<div
|
|
style={{ position: "fixed", inset: 0, zIndex: 500, backgroundColor: "rgba(0,0,0,0.45)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
style={{ backgroundColor: "var(--bg-card)", border: "1px solid var(--border-primary)", borderRadius: 10, width: 380, maxHeight: 460, display: "flex", flexDirection: "column", boxShadow: "0 16px 48px rgba(0,0,0,0.35)", overflow: "hidden" }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div style={{ padding: "12px 14px", borderBottom: "1px solid var(--border-secondary)" }}>
|
|
<input
|
|
ref={inputRef}
|
|
value={q}
|
|
onChange={(e) => 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" }}
|
|
/>
|
|
</div>
|
|
<div style={{ overflowY: "auto", flex: 1 }}>
|
|
{/* All customers option */}
|
|
<div
|
|
onClick={() => { 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
|
|
</div>
|
|
{filtered.map((c) => (
|
|
<div
|
|
key={c.id}
|
|
onClick={() => { 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"; }}
|
|
>
|
|
<div style={{ fontWeight: 500 }}>{c.name}{c.surname ? ` ${c.surname}` : ""}</div>
|
|
{c.organization && <div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 1 }}>{c.organization}</div>}
|
|
</div>
|
|
))}
|
|
{filtered.length === 0 && q && (
|
|
<div style={{ padding: "16px 14px", textAlign: "center", fontSize: 13, color: "var(--text-muted)" }}>No customers found</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Communications Log</h1>
|
|
<p className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>
|
|
All customer communications across all channels
|
|
</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}
|
|
title="Connect to mail server and download new emails into the log"
|
|
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}
|
|
title="Reload from local database"
|
|
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: 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}>{TYPE_LABELS[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>
|
|
|
|
{/* Customer picker button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setCustPickerOpen(true)}
|
|
style={{
|
|
...selectStyle,
|
|
minWidth: 180,
|
|
textAlign: "left",
|
|
color: custFilter ? "var(--accent)" : "var(--text-primary)",
|
|
fontWeight: custFilter ? 600 : 400,
|
|
}}
|
|
>
|
|
{selectedCustomerLabel} ▾
|
|
</button>
|
|
|
|
{(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>
|
|
) : sortedFiltered.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)" }}>
|
|
{sortedFiltered.length} entr{sortedFiltered.length !== 1 ? "ies" : "y"}
|
|
</div>
|
|
|
|
<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">
|
|
{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 (
|
|
<div key={entry.id} style={{ position: "relative", paddingLeft: 44 }}
|
|
onMouseEnter={() => setHoveredId(entry.id)}
|
|
onMouseLeave={() => setHoveredId(null)}>
|
|
{/* Type icon marker */}
|
|
<div style={{ position: "absolute", left: 8, top: 11, zIndex: 1 }}>
|
|
<CommTypeIconBadge type={entry.type} />
|
|
</div>
|
|
|
|
<div
|
|
className="rounded-lg border"
|
|
style={{
|
|
backgroundColor: "var(--bg-card)",
|
|
borderColor: isPendingDelete ? "var(--danger)" : isEditing ? "var(--accent)" : "var(--border-primary)",
|
|
cursor: entry.body && !isEditing ? "pointer" : "default",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
}}
|
|
onClick={() => !isEditing && entry.body && toggleExpand(entry.id)}
|
|
>
|
|
{/* Hover overlay: gradient + 3-col action panel (no layout shift) */}
|
|
{isHov && !isPendingDelete && !isEditing && (
|
|
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 2, background: "linear-gradient(to left, rgba(24, 35, 48, 0.95) 0%, rgba(31, 41, 55, 0.2) 32%, transparent 45%)", borderRadius: "inherit" }}>
|
|
<div style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", display: "flex", flexDirection: "row", alignItems: "center", gap: 10, pointerEvents: "all" }}>
|
|
|
|
{/* Col 1 — date info */}
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 3 }}>
|
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.55)", whiteSpace: "nowrap", lineHeight: 1.4 }}>
|
|
{entry.direction === "inbound" ? "Received" : entry.direction === "outbound" ? "Sent" : "Logged"} via {TYPE_LABELS[entry.type] || entry.type}
|
|
</span>
|
|
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap", lineHeight: 1.4 }}>
|
|
{formatFullDateTime(entry.occurred_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div style={{ width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.18)", flexShrink: 0, margin: "2px 0" }} />
|
|
|
|
{/* Col 2 — Full View / Reply */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); isEmail && setViewEntry(entry); }}
|
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: isEmail ? "pointer" : "default", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: isEmail ? "#fff" : "rgba(255,255,255,0.3)", backgroundColor: isEmail ? "rgba(255,255,255,0.12)" : "transparent", border: `1px solid ${isEmail ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.1)"}` }}>
|
|
<IconExpand /><span>Full View</span>
|
|
</button>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); openReply(entry); }}
|
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.25)" }}>
|
|
<IconReply /><span>Reply</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Col 3 — Edit / Delete (canEdit only) */}
|
|
{canEdit && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); startEdit(entry); }}
|
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.25)" }}>
|
|
<IconEdit /><span>Edit</span>
|
|
</button>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); setDeleteId(entry.id); }}
|
|
style={{ display: "flex", alignItems: "center", gap: 5, width: 90, justifyContent: "center", padding: "4px 0", fontSize: 11, borderRadius: 5, cursor: "pointer", backdropFilter: "blur(4px)", whiteSpace: "nowrap", color: "#fff", backgroundColor: "rgba(185,28,28,0.7)", border: "1px solid rgba(220,38,38,0.5)" }}>
|
|
<IconDelete /><span>Delete</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Entry header */}
|
|
<div className="flex items-center gap-2 px-4 py-3 flex-wrap">
|
|
<CommDirectionIcon direction={entry.direction} />
|
|
{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.from_addr || entry.customer_id || "—"}
|
|
</span>
|
|
)}
|
|
|
|
{entry.subject && (
|
|
<span className="text-sm font-medium truncate" style={{ color: "var(--text-heading)", maxWidth: 280 }}>
|
|
{entry.subject}
|
|
</span>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
|
{formatRelativeTime(entry.occurred_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
{entry.body && (
|
|
<div className="pb-3" style={{ paddingLeft: 16, paddingRight: 16 }}>
|
|
<div style={{ borderTop: "1px solid var(--border-secondary)" }} />
|
|
<p className="text-sm mt-2"
|
|
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?.length > 0) || isPendingDelete) && (
|
|
<div className="px-4 pb-3 flex items-center gap-3 flex-wrap">
|
|
{entry.logged_by && (
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>by {entry.logged_by}</span>
|
|
)}
|
|
{entry.attachments?.length > 0 && (
|
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
📎 {entry.attachments.length} attachment{entry.attachments.length !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
|
|
{/* Delete confirmation */}
|
|
{isPendingDelete && (
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<span className="text-xs" style={{ color: "var(--danger-text)" }}>Delete this entry?</span>
|
|
<button type="button" disabled={deleting} onClick={(e) => { e.stopPropagation(); handleDelete(entry.id); }}
|
|
className="text-xs px-2 py-1 rounded cursor-pointer hover:opacity-90"
|
|
style={{ backgroundColor: "var(--danger)", color: "#fff", opacity: deleting ? 0.7 : 1 }}>
|
|
{deleting ? "..." : "Confirm"}
|
|
</button>
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); setDeleteId(null); }}
|
|
className="text-xs px-2 py-1 rounded cursor-pointer hover:opacity-80"
|
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)" }}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Inline edit form */}
|
|
{isEditing && (
|
|
<div className="px-4 pb-4 border-t" style={{ borderColor: "var(--border-secondary)" }} onClick={e => e.stopPropagation()}>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginTop: 12, marginBottom: 10 }}>
|
|
<div>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Type</div>
|
|
<select value={editForm.type} onChange={e => setEditForm(f => ({...f, type: 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)" }}>
|
|
{["email","whatsapp","call","sms","note","in_person"].map(t => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Direction</div>
|
|
<select value={editForm.direction} onChange={e => setEditForm(f => ({...f, direction: 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)" }}>
|
|
{["inbound","outbound","internal"].map(d => <option key={d} value={d}>{d}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Date & Time</div>
|
|
<input type="datetime-local" value={editForm.occurred_at}
|
|
onChange={e => 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)" }} />
|
|
</div>
|
|
</div>
|
|
<div style={{ marginBottom: 8 }}>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Subject</div>
|
|
<input value={editForm.subject} onChange={e => 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)" }} />
|
|
</div>
|
|
<div style={{ marginBottom: 8 }}>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Body</div>
|
|
<textarea value={editForm.body} onChange={e => setEditForm(f => ({...f, body: e.target.value}))}
|
|
rows={3} className="w-full px-2 py-1.5 text-xs rounded-md border resize-none"
|
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }} />
|
|
</div>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<div style={{ fontSize: 11, color: "var(--text-muted)", marginBottom: 4 }}>Logged By</div>
|
|
<input value={editForm.logged_by} onChange={e => setEditForm(f => ({...f, logged_by: 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)" }} />
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<button type="button" onClick={(e) => { e.stopPropagation(); setEditId(null); }}
|
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
|
|
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-input)" }}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" disabled={editSaving} onClick={(e) => { e.stopPropagation(); handleSaveEdit(); }}
|
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-90"
|
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none", opacity: editSaving ? 0.6 : 1 }}>
|
|
{editSaving ? "Saving…" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Customer Picker Modal */}
|
|
<CustomerPickerModal
|
|
open={custPickerOpen}
|
|
onClose={() => setCustPickerOpen(false)}
|
|
customers={customerOptions}
|
|
value={custFilter}
|
|
onChange={setCustFilter}
|
|
/>
|
|
|
|
{/* Mail View Modal */}
|
|
<MailViewModal
|
|
open={!!viewEntry}
|
|
onClose={() => 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 */}
|
|
<ComposeEmailModal
|
|
open={composeOpen}
|
|
onClose={() => { setComposeOpen(false); setComposeFromAccount(""); }}
|
|
defaultTo={composeTo}
|
|
defaultFromAccount={composeFromAccount}
|
|
requireFromAccount={true}
|
|
onSent={() => loadAll()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|