Implements #17, #18, #19, #20, #21 - Add CSS custom properties for density-dependent spacing (--d-* vars) in theme.css with comfortable (default) and compact modes - Create useDensity hook with localStorage persistence and DOM attribute sync - Add FOUC prevention in main.tsx (sync density before first paint) - Create shared PageFooter component merging stats + pagination - Refactor AppShell to flex layout with density toggle button (COM/CMP) - Consolidate inline pagination from ItemsPage/AuditPage into PageFooter - Delete FooterStats.tsx (replaced by PageFooter) - Replace all hardcoded padding/font/gap values in ItemTable, AuditTable, ItemsToolbar, and AuditToolbar with var(--d-*) references Comfortable mode is already tighter than the previous hardcoded values. Compact mode reduces further for power-user density.
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
import { useState, useCallback } from "react";
|
|
import type { Item } from "../../api/types";
|
|
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
|
|
|
export interface ColumnDef {
|
|
key: string;
|
|
label: string;
|
|
}
|
|
|
|
export const ALL_COLUMNS: ColumnDef[] = [
|
|
{ key: "part_number", label: "Part Number" },
|
|
{ key: "item_type", label: "Type" },
|
|
{ key: "description", label: "Description" },
|
|
{ key: "revision", label: "Rev" },
|
|
{ key: "projects", label: "Projects" },
|
|
{ key: "created", label: "Created" },
|
|
{ key: "actions", label: "Actions" },
|
|
];
|
|
|
|
export const DEFAULT_COLUMNS_H = [
|
|
"part_number",
|
|
"item_type",
|
|
"description",
|
|
"revision",
|
|
];
|
|
export const DEFAULT_COLUMNS_V = [
|
|
"part_number",
|
|
"item_type",
|
|
"description",
|
|
"revision",
|
|
"created",
|
|
"actions",
|
|
];
|
|
|
|
interface ItemTableProps {
|
|
items: Item[];
|
|
loading: boolean;
|
|
selectedPN: string | null;
|
|
onSelect: (pn: string) => void;
|
|
visibleColumns: string[];
|
|
onColumnsChange: (cols: string[]) => void;
|
|
onEdit?: (pn: string) => void;
|
|
onDelete?: (pn: string) => void;
|
|
sortKey: string;
|
|
sortDir: "asc" | "desc";
|
|
onSort: (key: string) => void;
|
|
}
|
|
|
|
const typeColors: Record<string, { bg: string; color: string }> = {
|
|
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
|
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
|
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
|
|
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
|
};
|
|
|
|
function formatDate(s: string) {
|
|
if (!s) return "";
|
|
const d = new Date(s);
|
|
return d.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
function copyPN(pn: string) {
|
|
void navigator.clipboard.writeText(pn);
|
|
}
|
|
|
|
export function ItemTable({
|
|
items,
|
|
loading,
|
|
selectedPN,
|
|
onSelect,
|
|
visibleColumns,
|
|
onColumnsChange,
|
|
onEdit,
|
|
onDelete,
|
|
sortKey,
|
|
sortDir,
|
|
onSort,
|
|
}: ItemTableProps) {
|
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
const handleHeaderContext = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
}, []);
|
|
|
|
const toggleColumn = useCallback(
|
|
(key: string) => {
|
|
if (key === "part_number") return; // always visible
|
|
const next = visibleColumns.includes(key)
|
|
? visibleColumns.filter((c) => c !== key)
|
|
: [...visibleColumns, key];
|
|
if (next.length > 0) onColumnsChange(next);
|
|
},
|
|
[visibleColumns, onColumnsChange],
|
|
);
|
|
|
|
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
|
|
|
|
const sortedItems = [...items].sort((a, b) => {
|
|
let av: string | number = "";
|
|
let bv: string | number = "";
|
|
switch (sortKey) {
|
|
case "part_number":
|
|
av = a.part_number;
|
|
bv = b.part_number;
|
|
break;
|
|
case "item_type":
|
|
av = a.item_type;
|
|
bv = b.item_type;
|
|
break;
|
|
case "description":
|
|
av = a.description;
|
|
bv = b.description;
|
|
break;
|
|
case "revision":
|
|
av = a.current_revision;
|
|
bv = b.current_revision;
|
|
break;
|
|
case "created":
|
|
av = a.created_at;
|
|
bv = b.created_at;
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
if (av < bv) return sortDir === "asc" ? -1 : 1;
|
|
if (av > bv) return sortDir === "asc" ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
const thStyle: React.CSSProperties = {
|
|
padding: "var(--d-th-py) var(--d-th-px)",
|
|
textAlign: "left",
|
|
borderBottom: "1px solid var(--ctp-surface1)",
|
|
color: "var(--ctp-subtext1)",
|
|
fontWeight: 600,
|
|
fontSize: "var(--d-th-font)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
cursor: "pointer",
|
|
userSelect: "none",
|
|
whiteSpace: "nowrap",
|
|
};
|
|
|
|
const tdStyle: React.CSSProperties = {
|
|
padding: "var(--d-td-py) var(--d-td-px)",
|
|
fontSize: "var(--d-td-font)",
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
maxWidth: 300,
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
|
|
Loading items...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div style={{ overflow: "auto", height: "100%" }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead onContextMenu={handleHeaderContext}>
|
|
<tr>
|
|
{cols.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
style={thStyle}
|
|
onClick={() => col.key !== "actions" && onSort(col.key)}
|
|
>
|
|
{col.label}
|
|
{sortKey === col.key && (
|
|
<span style={{ marginLeft: 4 }}>
|
|
{sortDir === "asc" ? "▲" : "▼"}
|
|
</span>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedItems.map((item, idx) => {
|
|
const isSelected = item.part_number === selectedPN;
|
|
const rowBg = isSelected
|
|
? "var(--ctp-surface1)"
|
|
: idx % 2 === 0
|
|
? "var(--ctp-base)"
|
|
: "var(--ctp-surface0)";
|
|
|
|
return (
|
|
<tr
|
|
key={item.id}
|
|
onClick={() => onSelect(item.part_number)}
|
|
style={{
|
|
backgroundColor: rowBg,
|
|
cursor: "pointer",
|
|
borderBottom: "1px solid var(--ctp-surface0)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isSelected)
|
|
e.currentTarget.style.backgroundColor =
|
|
"var(--ctp-surface0)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isSelected)
|
|
e.currentTarget.style.backgroundColor = rowBg;
|
|
}}
|
|
>
|
|
{cols.map((col) => {
|
|
switch (col.key) {
|
|
case "part_number":
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
<span
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
copyPN(item.part_number);
|
|
}}
|
|
title="Click to copy"
|
|
style={{
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
color: "var(--ctp-peach)",
|
|
cursor: "copy",
|
|
}}
|
|
>
|
|
{item.part_number}
|
|
</span>
|
|
</td>
|
|
);
|
|
case "item_type": {
|
|
const tc = typeColors[item.item_type] ?? {
|
|
bg: "var(--ctp-surface1)",
|
|
color: "var(--ctp-text)",
|
|
};
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
<span
|
|
style={{
|
|
padding: "0.1rem 0.5rem",
|
|
borderRadius: "1rem",
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
backgroundColor: tc.bg,
|
|
color: tc.color,
|
|
}}
|
|
>
|
|
{item.item_type}
|
|
</span>
|
|
</td>
|
|
);
|
|
}
|
|
case "description":
|
|
return (
|
|
<td
|
|
key={col.key}
|
|
style={{ ...tdStyle, maxWidth: 400 }}
|
|
>
|
|
{item.description}
|
|
</td>
|
|
);
|
|
case "revision":
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
Rev {item.current_revision}
|
|
</td>
|
|
);
|
|
case "projects":
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
—
|
|
</td>
|
|
);
|
|
case "created":
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
{formatDate(item.created_at)}
|
|
</td>
|
|
);
|
|
case "actions":
|
|
return (
|
|
<td key={col.key} style={tdStyle}>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onEdit?.(item.part_number);
|
|
}}
|
|
style={actionBtnStyle}
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete?.(item.part_number);
|
|
}}
|
|
style={{
|
|
...actionBtnStyle,
|
|
color: "var(--ctp-red)",
|
|
}}
|
|
>
|
|
Del
|
|
</button>
|
|
</td>
|
|
);
|
|
default:
|
|
return <td key={col.key} style={tdStyle} />;
|
|
}
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
{sortedItems.length === 0 && (
|
|
<tr>
|
|
<td
|
|
colSpan={cols.length}
|
|
style={{
|
|
padding: "2rem",
|
|
textAlign: "center",
|
|
color: "var(--ctp-subtext0)",
|
|
}}
|
|
>
|
|
No items found
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
onClose={() => setCtxMenu(null)}
|
|
items={ALL_COLUMNS.map(
|
|
(col): ContextMenuItem => ({
|
|
label: col.label,
|
|
checked: visibleColumns.includes(col.key),
|
|
onToggle: () => toggleColumn(col.key),
|
|
disabled: col.key === "part_number",
|
|
}),
|
|
)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const actionBtnStyle: React.CSSProperties = {
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--ctp-subtext1)",
|
|
cursor: "pointer",
|
|
fontSize: "0.8rem",
|
|
padding: "0.15rem 0.4rem",
|
|
borderRadius: "0.25rem",
|
|
};
|