fix: devices list accepts ips as single strings / various ui changes

This commit is contained in:
2026-03-14 10:58:25 +02:00
parent 6f9fd5cba3
commit 15c419b7bf
11 changed files with 1707 additions and 647 deletions

View File

@@ -44,15 +44,16 @@ function resolveLanguage(val) {
}
const ALL_COLUMNS = [
{ id: "name", label: "Name", default: true },
{ id: "organization", label: "Organization", default: true },
{ id: "address", label: "Full Address", default: true },
{ id: "location", label: "Location", default: true },
{ id: "email", label: "Email", default: true },
{ id: "phone", label: "Phone", default: true },
{ id: "tags", label: "Tags", default: true },
{ id: "religion", label: "Religion", default: false },
{ id: "language", label: "Language", default: false },
{ id: "name", label: "Name", default: true, locked: true },
{ id: "status", label: "Status", default: true },
{ id: "organization", label: "Organization", default: true },
{ id: "address", label: "Full Address", default: true },
{ id: "location", label: "Location", default: true },
{ id: "email", label: "Email", default: true },
{ id: "phone", label: "Phone", default: true },
{ id: "tags", label: "Tags", default: true },
{ id: "religion", label: "Religion", default: false },
{ id: "language", label: "Language", default: false },
];
const SORT_OPTIONS = [
@@ -62,8 +63,9 @@ const SORT_OPTIONS = [
{ value: "latest_comm", label: "Latest Communication" },
];
const COL_STORAGE_KEY = "crm_customers_columns";
const COL_ORDER_KEY = "crm_customers_col_order";
const COL_STORAGE_KEY = "crm_customers_columns";
const COL_ORDER_KEY = "crm_customers_col_order";
const NOTES_MODE_KEY = "crm_customers_notes_mode";
function loadColumnPrefs() {
try {
@@ -75,6 +77,10 @@ function loadColumnPrefs() {
if (!orderedIds.includes(c.id)) orderedIds.push(c.id);
}
const filtered = orderedIds.filter(id => ALL_COLUMNS.find(c => c.id === id));
// Always force locked columns visible
for (const c of ALL_COLUMNS) {
if (c.locked) visible[c.id] = true;
}
return { visible, orderedIds: filtered };
} catch {
return {
@@ -119,46 +125,76 @@ function IconImportant({ style, className }) {
);
}
// ── Status icons next to customer name ──────────────────────────────────────
// direction: "inbound" = client sent last, "outbound" = we sent last, null = unknown
// ── Status icons helpers ─────────────────────────────────────────────────────
function CustomerStatusIcons({ customer, direction }) {
function statusColors(direction) {
const pendingOurReply = direction === "inbound";
// negotiations: yellow if we sent last, orange if client sent last
// issues: yellow if we sent last, red if client sent last
const negColor = pendingOurReply ? "var(--crm-status-alert)" : "#e8a504";
const issColor = pendingOurReply ? "var(--crm-status-danger)" : "#e03535";
return { negColor, issColor, pendingOurReply };
}
// ── Quick mode status cell ───────────────────────────────────────────────────
// Icon sizes — edit these to adjust icon dimensions per mode:
// Quick mode icons: QUICK_ICON_SIZE (negotiations slightly larger than issues)
const QUICK_NEG_ICON_SIZE = 25; // px — negotiations icon in Quick mode
const QUICK_ISS_ICON_SIZE = 20; // px — issues icon in Quick mode
const QUICK_IMP_ICON_SIZE = 17; // px — exclamation icon in Quick mode
// Expanded sub-row icons:
const EXP_NEG_ICON_SIZE = 22; // px — negotiations icon in Expanded sub-rows
const EXP_ISS_ICON_SIZE = 16; // px — issues icon in Expanded sub-rows
const EXP_IMP_ICON_SIZE = 12; // px — exclamation icon in Expanded sub-rows
function StatusIconsCell({ customer, direction }) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
// "important" = we have an open issue or negotiation AND we sent the last message
// (pending reply from client) — shown as breathing exclamation
const pendingOurReply = direction === "inbound";
if (!hasNeg && !hasIssue) return <td className="px-3 py-3" />;
if (!hasNeg && !hasIssue) return null;
// Color logic:
// negotiations: yellow (#f08c00) if outbound (we sent last), orange (#f76707) if inbound (client sent)
// issues: yellow (#f08c00) if outbound, red (#f34b4b) if inbound
const negColor = pendingOurReply ? "var(--crm-status-alert)" : "var(--crm-status-warn)";
const issColor = pendingOurReply ? "var(--crm-status-danger)" : "var(--crm-status-warn)";
const iconSize = { width: 13, height: 13, display: "inline-block", flexShrink: 0 };
const { negColor, issColor, pendingOurReply } = statusColors(direction);
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, marginLeft: 6, verticalAlign: "middle" }}>
{hasNeg && (
<span title="Negotiating" style={{ color: negColor, display: "inline-flex" }}>
<IconNegotiations style={iconSize} />
</span>
)}
{hasIssue && (
<span title="Has issue" style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={iconSize} />
</span>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-warn)", display: "inline-flex" }}>
<IconImportant style={iconSize} className="crm-icon-breathe" />
</span>
)}
</span>
<td className="px-3 py-3" style={{ textAlign: "center" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{hasNeg && (
<span
title={pendingOurReply ? "Negotiating — client awaiting our reply" : "Negotiating — we sent last"}
style={{ color: negColor, display: "inline-flex" }}
>
<IconNegotiations style={{ width: QUICK_NEG_ICON_SIZE, height: QUICK_NEG_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span>
)}
{hasIssue && (
<span
title={pendingOurReply ? "Open issue — client awaiting our reply" : "Open issue — we last contacted them"}
style={{ color: issColor, display: "inline-flex" }}
>
<IconIssues style={{ width: QUICK_ISS_ICON_SIZE, height: QUICK_ISS_ICON_SIZE, display: "inline-block", flexShrink: 0 }} />
</span>
)}
{(hasNeg || hasIssue) && pendingOurReply && (
<span title="Awaiting our reply" style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: QUICK_IMP_ICON_SIZE, height: QUICK_IMP_ICON_SIZE, display: "inline-block", flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
</div>
</td>
);
}
// ── Original inline icons (small, in name cell) ──────────────────────────────
function relDays(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d)) return null;
const days = Math.floor((Date.now() - d.getTime()) / 86400000);
if (days === 0) return "today";
if (days === 1) return "yesterday";
return `${days} days ago`;
}
// ── Column toggle ────────────────────────────────────────────────────────────
function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
@@ -206,31 +242,36 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
{orderedIds.map((id) => {
const col = ALL_COLUMNS.find((c) => c.id === id);
if (!col) return null;
const isLocked = !!col.locked;
return (
<div
key={id}
draggable
onDragStart={() => setDragging(id)}
draggable={!isLocked}
onDragStart={() => !isLocked && setDragging(id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragEnd={() => setDragging(null)}
onClick={() => onChange(id, !visible[id])}
onClick={() => !isLocked && onChange(id, !visible[id])}
style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6,
cursor: "pointer", userSelect: "none",
cursor: isLocked ? "default" : "pointer", userSelect: "none",
backgroundColor: dragging === id ? "var(--bg-card-hover)" : "transparent",
opacity: isLocked ? 0.5 : 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseEnter={(e) => { if (!isLocked) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = dragging === id ? "var(--bg-card-hover)" : "transparent"; }}
>
<span style={{ fontSize: 11, color: "var(--text-muted)", cursor: "grab" }}></span>
<span style={{ fontSize: 11, color: "var(--text-muted)", cursor: isLocked ? "default" : "grab" }}></span>
<div style={{
width: 14, height: 14, borderRadius: 3, border: `2px solid ${visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
backgroundColor: visible[id] ? "var(--accent)" : "transparent",
width: 14, height: 14, borderRadius: 3,
border: `2px solid ${isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "var(--border-primary)"}`,
backgroundColor: isLocked ? "var(--border-primary)" : visible[id] ? "var(--accent)" : "transparent",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
{visible[id] && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}></span>}
{(isLocked || visible[id]) && <span style={{ color: "#fff", fontSize: 9, lineHeight: 1 }}></span>}
</div>
<span className="text-xs" style={{ color: "var(--text-primary)" }}>{col.label}</span>
<span className="text-xs" style={{ color: "var(--text-primary)" }}>
{col.label}{isLocked ? " (locked)" : ""}
</span>
</div>
);
})}
@@ -389,6 +430,38 @@ function SortDropdown({ value, onChange }) {
);
}
// ── Notes mode toggle ────────────────────────────────────────────────────────
function NotesModeToggle({ value, onChange }) {
const isExpanded = value === "expanded";
return (
<button
onClick={() => onChange(isExpanded ? "quick" : "expanded")}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border hover:opacity-80 cursor-pointer transition-opacity"
title={isExpanded ? "Switch to Quick notes view" : "Switch to Expanded notes view"}
style={{
backgroundColor: isExpanded ? "color-mix(in srgb, var(--accent) 15%, var(--bg-input))" : "var(--bg-input)",
borderColor: isExpanded ? "var(--accent)" : "var(--border-primary)",
color: isExpanded ? "var(--accent)" : "var(--text-secondary)",
whiteSpace: "nowrap",
}}
>
{isExpanded ? (
// Expanded icon: lines with detail
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h10M4 18h7" />
</svg>
) : (
// Quick icon: compact list
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
Notes: {isExpanded ? "Expanded" : "Quick"}
</button>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function primaryContact(customer, type) {
@@ -512,10 +585,22 @@ export default function CustomerList() {
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
// Map of customer_id → "inbound" | "outbound" | null
const [commDirections, setCommDirections] = useState({});
// Map of customer_id → ISO date string of last comm
const [lastCommDates, setLastCommDates] = useState({});
const [notesMode, setNotesMode] = useState(
() => localStorage.getItem(NOTES_MODE_KEY) || "quick"
);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEdit = hasPermission("crm", "edit");
const handleNotesModeChange = (mode) => {
setNotesMode(mode);
localStorage.setItem(NOTES_MODE_KEY, mode);
};
const fetchCustomers = async () => {
setLoading(true);
setError("");
@@ -541,23 +626,28 @@ export default function CustomerList() {
const results = await Promise.allSettled(
flagged.map(c =>
api.get(`/crm/customers/${c.id}/last-comm-direction`)
.then(r => [c.id, r.direction])
.catch(() => [c.id, null])
.then(r => [c.id, r.direction, r.occurred_at || r.date || null])
.catch(() => [c.id, null, null])
)
);
const map = {};
const dirMap = {};
const dateMap = {};
for (const r of results) {
if (r.status === "fulfilled") {
const [id, dir] = r.value;
map[id] = dir;
const [id, dir, date] = r.value;
dirMap[id] = dir;
if (date) dateMap[id] = date;
}
}
setCommDirections(prev => ({ ...prev, ...map }));
setCommDirections(prev => ({ ...prev, ...dirMap }));
setLastCommDates(prev => ({ ...prev, ...dateMap }));
};
useEffect(() => { fetchCustomers(); }, [search, sort]);
const updateColVisible = (id, vis) => {
const col = ALL_COLUMNS.find(c => c.id === id);
if (col?.locked) return; // can't toggle locked columns
const next = { ...colPrefs.visible, [id]: vis };
setColPrefs((p) => ({ ...p, visible: next }));
saveColumnPrefs(next, colPrefs.orderedIds);
@@ -577,28 +667,36 @@ export default function CustomerList() {
(!activeFilters.has("has_problem") || c.has_problem)
);
const totalPages = pageSize > 0 ? Math.ceil(filteredCustomers.length / pageSize) : 1;
const safePage = Math.min(page, Math.max(1, totalPages));
const pagedCustomers = pageSize > 0
? filteredCustomers.slice((safePage - 1) * pageSize, safePage * pageSize)
: filteredCustomers;
const handleCustomerUpdate = (updated) => {
setCustomers(prev => prev.map(c => c.id === updated.id ? updated : c));
// Refresh direction for this customer if it now has/lost a flag
if (updated.negotiating || updated.has_problem) {
api.get(`/crm/customers/${updated.id}/last-comm-direction`)
.then(r => setCommDirections(prev => ({ ...prev, [updated.id]: r.direction })))
.then(r => {
setCommDirections(prev => ({ ...prev, [updated.id]: r.direction }));
if (r.occurred_at || r.date) setLastCommDates(prev => ({ ...prev, [updated.id]: r.occurred_at || r.date }));
})
.catch(() => {});
}
};
const renderCell = (col, c) => {
const renderCell = (col, c, direction) => {
const loc = c.location || {};
switch (col.id) {
case "name":
return (
<td key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
<div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
<CustomerStatusIcons customer={c} direction={commDirections[c.id] ?? null} />
</div>
<span>{[TITLE_SHORT[c.title], c.name, c.surname].filter(Boolean).join(" ")}</span>
</td>
);
case "status":
return <StatusIconsCell key={col.id} customer={c} direction={direction} />;
case "organization":
return <td key={col.id} className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{c.organization || "—"}</td>;
case "address": {
@@ -638,6 +736,26 @@ export default function CustomerList() {
}
};
// In expanded mode, hide the status column — info is shown as sub-rows instead
const visibleColsForMode = notesMode === "expanded"
? visibleCols.filter(c => c.id !== "status")
: visibleCols;
// Total column count for colSpan on expanded sub-rows
const totalCols = visibleColsForMode.length + (canEdit ? 1 : 0);
// Row gradient background for customers with active status flags
function rowGradient(customer, direction) {
const hasNeg = customer.negotiating;
const hasIssue = customer.has_problem;
if (!hasNeg && !hasIssue) return undefined;
const pendingOurReply = direction === "inbound";
const color = hasIssue
? (pendingOurReply ? "rgba(224, 53, 53, 0.07)" : "rgba(224, 53, 53, 0.05)")
: (pendingOurReply ? "rgba(247, 103, 7, 0.07)" : "rgba(232, 165, 4, 0.05)");
return `linear-gradient(to right, ${color} 0%, transparent 70%)`;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -663,7 +781,19 @@ export default function CustomerList() {
style={inputStyle}
/>
<SortDropdown value={sort} onChange={setSort} />
<NotesModeToggle value={notesMode} onChange={handleNotesModeChange} />
<FilterDropdown active={activeFilters} onChange={setActiveFilters} />
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-3 py-2 text-sm rounded-md border cursor-pointer"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-primary)", color: "var(--text-primary)" }}
>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
<option value="0">All</option>
</select>
<ColumnToggle
visible={colPrefs.visible}
orderedIds={colPrefs.orderedIds}
@@ -693,8 +823,8 @@ export default function CustomerList() {
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{visibleCols.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>
{visibleColsForMode.map((col) => (
<th key={col.id} className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)", textAlign: col.id === "status" ? "center" : "left", ...(col.id === "status" ? { width: 90 } : {}) }}>
{col.label}
</th>
))}
@@ -702,29 +832,208 @@ export default function CustomerList() {
</tr>
</thead>
<tbody>
{filteredCustomers.map((c, index) => (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: index < filteredCustomers.length - 1 ? "1px solid var(--border-secondary)" : "none",
backgroundColor: hoveredRow === c.id ? "var(--bg-card-hover)" : "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleCols.map((col) => renderCell(col, c))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
)}
</tr>
))}
{pagedCustomers.map((c, index) => {
const direction = commDirections[c.id] ?? null;
const lastDate = lastCommDates[c.id] ?? null;
const hasStatus = c.negotiating || c.has_problem;
const isLast = index === pagedCustomers.length - 1;
const gradient = rowGradient(c, direction);
const rowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
const rowStyle = {
borderBottom: (!isLast && !(notesMode === "expanded" && hasStatus))
? "1px solid var(--border-secondary)"
: "none",
background: rowBg
? rowBg
: gradient || "transparent",
};
const mainRow = (
<tr
key={`${c.id}-main`}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={rowStyle}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
)}
</tr>
);
if (notesMode === "expanded" && hasStatus) {
const { negColor, issColor, pendingOurReply } = statusColors(direction);
const when = relDays(lastDate);
const subRowBg = hoveredRow === c.id ? "var(--bg-card-hover)" : undefined;
const subRows = [];
if (c.negotiating) {
let text;
if (pendingOurReply) {
text = when
? `Undergoing negotiations — client last contacted us ${when}. Reply needed.`
: "Undergoing negotiations — client is awaiting our reply.";
} else {
text = when
? `Undergoing negotiations — we last reached out ${when}.`
: "Undergoing negotiations.";
}
subRows.push(
<tr
key={`${c.id}-neg`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg || gradient || "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: negColor, display: "inline-flex" }}>
<IconNegotiations style={{ width: EXP_NEG_ICON_SIZE, height: EXP_NEG_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-alert)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: negColor, fontWeight: 500 }}>{text}</span>
</div>
</td>
</tr>
);
}
if (c.has_problem) {
let text;
if (pendingOurReply) {
text = when
? `Open issue — client reached out ${when} and is awaiting our response.`
: "Open issue — client is awaiting our response.";
} else {
text = when
? `Open issue — we last contacted the client ${when}.`
: "Open issue — under investigation.";
}
subRows.push(
<tr
key={`${c.id}-iss`}
className="cursor-pointer"
onClick={() => navigate(`/crm/customers/${c.id}`)}
style={{
borderBottom: "none",
background: subRowBg || gradient || "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
<td colSpan={totalCols} style={{ padding: "0px 16px 5px 16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ color: issColor, display: "inline-flex" }}>
<IconIssues style={{ width: EXP_ISS_ICON_SIZE, height: EXP_ISS_ICON_SIZE, flexShrink: 0 }} />
</span>
{pendingOurReply && (
<span style={{ color: "var(--crm-status-danger)", display: "inline-flex" }}>
<IconImportant style={{ width: EXP_IMP_ICON_SIZE, height: EXP_IMP_ICON_SIZE, flexShrink: 0 }} className="crm-icon-breathe" />
</span>
)}
<span style={{ fontSize: 11.5, color: issColor, fontWeight: 500 }}>{text}</span>
</div>
</td>
</tr>
);
}
if (!isLast) {
subRows.push(
<tr key={`${c.id}-gap`} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td colSpan={totalCols} style={{ padding: 0 }} />
</tr>
);
}
return [mainRow, ...subRows].filter(Boolean);
}
return (
<tr
key={c.id}
onClick={() => navigate(`/crm/customers/${c.id}`)}
className="cursor-pointer"
style={{
borderBottom: !isLast ? "1px solid var(--border-secondary)" : "none",
background: rowBg
? rowBg
: gradient || "transparent",
}}
onMouseEnter={() => setHoveredRow(c.id)}
onMouseLeave={() => setHoveredRow(null)}
>
{visibleColsForMode.map((col) => renderCell(col, c, direction))}
{canEdit && (
<td className="px-4 py-3 text-right">
<ActionsDropdown customer={c} onUpdate={handleCustomerUpdate} />
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{pageSize > 0 && totalPages > 1 && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-primary)" }}>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {filteredCustomers.length} total
</span>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{[
{ label: "«", onClick: () => setPage(1), disabled: safePage === 1 },
{ label: "", onClick: () => setPage((p) => Math.max(1, p - 1)), disabled: safePage === 1 },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2)
.reduce((acc, p, idx, arr) => {
if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, idx) =>
p === "…" ? (
<span key={`e-${idx}`} style={{ padding: "0 4px", fontSize: 12, color: "var(--text-muted)" }}></span>
) : (
<button key={p} onClick={() => setPage(p)}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: "pointer", fontWeight: p === safePage ? 700 : 400, backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", border: "none" }}>
{p}
</button>
)
)}
{[
{ label: "", onClick: () => setPage((p) => Math.min(totalPages, p + 1)), disabled: safePage === totalPages },
{ label: "»", onClick: () => setPage(totalPages), disabled: safePage === totalPages },
].map(({ label, onClick, disabled }) => (
<button key={label} onClick={onClick} disabled={disabled}
style={{ padding: "3px 8px", fontSize: 12, borderRadius: 4, cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.4 : 1, backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "none" }}>
{label}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>