fix: Bugs created after the overhaul, performance and layout fixes

This commit is contained in:
2026-03-08 22:30:56 +02:00
parent 8c15c932b6
commit 6f9fd5cba3
112 changed files with 5771 additions and 970 deletions

View File

@@ -1,10 +1,17 @@
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",
@@ -17,14 +24,35 @@ const TYPE_LABELS = {
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 });
const COMM_FULL_DATE_FMT = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric" });
function formatCommDateTime(value) {
function formatRelativeTime(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 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 = {
@@ -116,6 +144,9 @@ function CustomerPickerModal({ open, onClose, customers, value, onChange }) {
}
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);
@@ -128,6 +159,14 @@ export default function CommsPage() {
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);
@@ -185,6 +224,51 @@ export default function CommsPage() {
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;
@@ -208,7 +292,7 @@ export default function CommsPage() {
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-xl font-bold" style={{ color: "var(--text-heading)" }}>Activity Log</h1>
<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>
@@ -309,9 +393,14 @@ export default function CommsPage() {
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 }}>
<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} />
@@ -321,11 +410,61 @@ export default function CommsPage() {
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
cursor: entry.body ? "pointer" : "default",
borderColor: isPendingDelete ? "var(--danger)" : isEditing ? "var(--accent)" : "var(--border-primary)",
cursor: entry.body && !isEditing ? "pointer" : "default",
position: "relative",
overflow: "hidden",
}}
onClick={() => entry.body && toggleExpand(entry.id)}
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} />
@@ -352,35 +491,17 @@ export default function CommsPage() {
)}
<div className="ml-auto flex items-center gap-2">
{/* Full View button (for email entries) */}
{isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setViewEntry(entry); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 flex-shrink-0"
style={{ border: "1px solid var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-primary)" }}
>
Full View
</button>
)}
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{formatCommDateTime(entry.occurred_at)}
</span>
{entry.body && (
<span className="text-xs flex-shrink-0" style={{ color: "var(--text-muted)" }}>
{isExpanded ? "▲" : "▼"}
{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)", marginLeft: 0, marginRight: 0 }} />
<p
className="text-sm mt-2"
<div style={{ borderTop: "1px solid var(--border-secondary)" }} />
<p className="text-sm mt-2"
style={{
color: "var(--text-primary)",
display: "-webkit-box",
@@ -388,38 +509,103 @@ export default function CommsPage() {
WebkitBoxOrient: "vertical",
overflow: isExpanded ? "visible" : "hidden",
whiteSpace: "pre-wrap",
}}
>
}}>
{entry.body}
</p>
</div>
)}
{/* Footer: logged_by + attachments + Quick Reply */}
{(entry.logged_by || (entry.attachments?.length > 0) || (isExpanded && isEmail)) && (
{/* 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>
<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>
)}
{isExpanded && isEmail && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); openReply(entry); }}
className="ml-auto text-xs px-2 py-1 rounded-md cursor-pointer hover:opacity-90"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", border: "none" }}
>
Quick Reply
</button>
{/* 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 &amp; 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>
);