@viliha/vui-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +73 -0
- package/src/avatar.tsx +33 -0
- package/src/badge.tsx +42 -0
- package/src/button.tsx +70 -0
- package/src/card.tsx +77 -0
- package/src/chart.tsx +144 -0
- package/src/checkbox.tsx +20 -0
- package/src/code.tsx +11 -0
- package/src/confirm-dialog.tsx +59 -0
- package/src/dialog.tsx +137 -0
- package/src/dropdown-menu.tsx +123 -0
- package/src/input.tsx +23 -0
- package/src/menu.tsx +80 -0
- package/src/record-view.tsx +1506 -0
- package/src/select.tsx +122 -0
- package/src/table-io.ts +114 -0
- package/src/table.tsx +100 -0
- package/src/theme.css +296 -0
- package/src/utils.ts +7 -0
|
@@ -0,0 +1,1506 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
CodeIcon as Code,
|
|
6
|
+
DownloadIcon as Download,
|
|
7
|
+
FileTextIcon as FileText,
|
|
8
|
+
ReaderIcon as Reader,
|
|
9
|
+
TableIcon as SheetIcon,
|
|
10
|
+
UploadIcon as Upload,
|
|
11
|
+
ArrowDownIcon as ArrowDown,
|
|
12
|
+
ArrowTopRightIcon as ArrowUpRight,
|
|
13
|
+
ArrowUpIcon as ArrowUp,
|
|
14
|
+
CaretSortIcon as ArrowUpDown,
|
|
15
|
+
CheckIcon as Check,
|
|
16
|
+
ChevronLeftIcon as ChevronLeft,
|
|
17
|
+
ChevronRightIcon as ChevronRight,
|
|
18
|
+
CircleIcon as Circle,
|
|
19
|
+
CopyIcon as Copy,
|
|
20
|
+
CopyIcon as CopyPlus,
|
|
21
|
+
Cross2Icon as X,
|
|
22
|
+
DotsHorizontalIcon as MoreHorizontal,
|
|
23
|
+
DragHandleDots2Icon as GripVertical,
|
|
24
|
+
EyeOpenIcon as Eye,
|
|
25
|
+
MagnifyingGlassIcon as Search,
|
|
26
|
+
MixerHorizontalIcon as ListFilter,
|
|
27
|
+
MixerHorizontalIcon as SlidersHorizontal,
|
|
28
|
+
Pencil1Icon as Pencil,
|
|
29
|
+
PlusIcon as Plus,
|
|
30
|
+
RowsIcon as Rows3,
|
|
31
|
+
TrashIcon as Trash2,
|
|
32
|
+
} from "@radix-ui/react-icons";
|
|
33
|
+
|
|
34
|
+
import { cn } from "./utils";
|
|
35
|
+
import { Button } from "./button";
|
|
36
|
+
import { Checkbox } from "./checkbox";
|
|
37
|
+
import { Input } from "./input";
|
|
38
|
+
import {
|
|
39
|
+
Table,
|
|
40
|
+
TableBody,
|
|
41
|
+
TableCell,
|
|
42
|
+
TableHead,
|
|
43
|
+
TableHeader,
|
|
44
|
+
TableRow,
|
|
45
|
+
} from "./table";
|
|
46
|
+
import { Dropdown, DropdownItem, DropdownLabel } from "./dropdown-menu";
|
|
47
|
+
import { ConfirmDialog } from "./confirm-dialog";
|
|
48
|
+
import {
|
|
49
|
+
downloadFile,
|
|
50
|
+
parseCSV,
|
|
51
|
+
printTable,
|
|
52
|
+
rowsToCSV,
|
|
53
|
+
rowsToTableHTML,
|
|
54
|
+
type IoColumn,
|
|
55
|
+
} from "./table-io";
|
|
56
|
+
|
|
57
|
+
type RowId = string | number;
|
|
58
|
+
type FieldGroup = "General" | "Work" | "Social" | "System";
|
|
59
|
+
const GROUP_ORDER: FieldGroup[] = ["General", "Work", "Social", "System"];
|
|
60
|
+
|
|
61
|
+
/** Fixed (non-resizable) leading/trailing column widths, in px. */
|
|
62
|
+
const CHECKBOX_W = 44;
|
|
63
|
+
const ACTIONS_W = 120;
|
|
64
|
+
const NAME_COL = "__name";
|
|
65
|
+
const NAME_DEFAULT_W = 190;
|
|
66
|
+
const MIN_COL_W = 80;
|
|
67
|
+
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
|
68
|
+
/** Fallback column-header icon so every column title shows an icon. */
|
|
69
|
+
const DEFAULT_FIELD_ICON = Circle;
|
|
70
|
+
|
|
71
|
+
type ColAlign = "left" | "center" | "right";
|
|
72
|
+
|
|
73
|
+
/** Flexbox + text classes per alignment (for the cell content wrapper). */
|
|
74
|
+
const ALIGN_BOX: Record<ColAlign, string> = {
|
|
75
|
+
left: "",
|
|
76
|
+
center: "justify-center text-center",
|
|
77
|
+
right: "justify-end text-right",
|
|
78
|
+
};
|
|
79
|
+
/** Text-align only (for inputs / render wrappers). */
|
|
80
|
+
const ALIGN_TEXT: Record<ColAlign, string> = {
|
|
81
|
+
left: "",
|
|
82
|
+
center: "text-center",
|
|
83
|
+
right: "text-right",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Auto-align columns from their data: numeric columns and short codes
|
|
88
|
+
* (all values ≤ 4 chars, e.g. "USD", "EN") center; everything else stays left.
|
|
89
|
+
* An explicit `field.align` always wins.
|
|
90
|
+
*/
|
|
91
|
+
function computeColumnAligns<T extends { id: RowId }>(
|
|
92
|
+
fields: RecordField<T>[],
|
|
93
|
+
data: T[],
|
|
94
|
+
): Record<string, ColAlign> {
|
|
95
|
+
const map: Record<string, ColAlign> = {};
|
|
96
|
+
for (const f of fields) {
|
|
97
|
+
if (f.align) {
|
|
98
|
+
map[f.key] = f.align;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const vals = data
|
|
102
|
+
.map((r) => r[f.key])
|
|
103
|
+
.filter((v) => v !== null && v !== undefined && String(v).trim() !== "");
|
|
104
|
+
if (vals.length === 0) {
|
|
105
|
+
map[f.key] = "left";
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const allNumeric = vals.every(
|
|
109
|
+
(v) =>
|
|
110
|
+
typeof v === "number" ||
|
|
111
|
+
(typeof v === "string" && !Number.isNaN(Number(v))),
|
|
112
|
+
);
|
|
113
|
+
const allShort = vals.every((v) => String(v).trim().length <= 4);
|
|
114
|
+
map[f.key] = allNumeric || allShort ? "center" : "left";
|
|
115
|
+
}
|
|
116
|
+
return map;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fieldDefaultWidth<T>(field: RecordField<T>): number {
|
|
120
|
+
return field.width ?? (field.align && field.align !== "left" ? 110 : 160);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Shared icon component type (all Radix icons share this shape). */
|
|
124
|
+
export type IconType = typeof Circle;
|
|
125
|
+
|
|
126
|
+
/** Mandatory-field marker — an asterisk icon (Radix has no asterisk glyph, so
|
|
127
|
+
it's an inline SVG) sized to the label text; no border chip (no width="15"). */
|
|
128
|
+
function RequiredMark() {
|
|
129
|
+
return (
|
|
130
|
+
<svg
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth={2.5}
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
className="size-3.5 shrink-0 self-center text-destructive"
|
|
137
|
+
aria-label="required"
|
|
138
|
+
>
|
|
139
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
140
|
+
<line x1="5.5" y1="8.5" x2="18.5" y2="15.5" />
|
|
141
|
+
<line x1="18.5" y1="8.5" x2="5.5" y2="15.5" />
|
|
142
|
+
</svg>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type PageMeta = { title: string; icon?: IconType };
|
|
147
|
+
|
|
148
|
+
const PageChromeContext = React.createContext<{
|
|
149
|
+
titleLeading?: React.ReactNode;
|
|
150
|
+
/** Current page's title/icon, registered by the active view (e.g. RecordView). */
|
|
151
|
+
page: PageMeta | null;
|
|
152
|
+
setPage: (page: PageMeta | null) => void;
|
|
153
|
+
}>({ page: null, setPage: () => {} });
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Shares page chrome across the app shell: a leading node for the header
|
|
157
|
+
* (e.g. a sidebar-expand toggle) plus the current page's title/icon so a global
|
|
158
|
+
* top bar can display it. Wrap the top bar AND the page content with this.
|
|
159
|
+
*/
|
|
160
|
+
export function PageChromeProvider({
|
|
161
|
+
titleLeading,
|
|
162
|
+
children,
|
|
163
|
+
}: {
|
|
164
|
+
titleLeading?: React.ReactNode;
|
|
165
|
+
children: React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
const [page, setPage] = React.useState<PageMeta | null>(null);
|
|
168
|
+
return (
|
|
169
|
+
<PageChromeContext.Provider value={{ titleLeading, page, setPage }}>
|
|
170
|
+
{children}
|
|
171
|
+
</PageChromeContext.Provider>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read the current page chrome (title/icon, leading node). */
|
|
176
|
+
export function usePageChrome() {
|
|
177
|
+
return React.useContext(PageChromeContext);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Register the current page's title/icon into the shell (clears on unmount). */
|
|
181
|
+
export function usePageTitle(title: string, icon?: IconType) {
|
|
182
|
+
const { setPage } = React.useContext(PageChromeContext);
|
|
183
|
+
React.useEffect(() => {
|
|
184
|
+
setPage({ title, icon });
|
|
185
|
+
return () => setPage(null);
|
|
186
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
187
|
+
}, [title]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface RecordField<T> {
|
|
191
|
+
key: Extract<keyof T, string>;
|
|
192
|
+
label: string;
|
|
193
|
+
icon?: IconType;
|
|
194
|
+
editable?: boolean;
|
|
195
|
+
/** Mark the field mandatory — shows a `*` next to its label. */
|
|
196
|
+
required?: boolean;
|
|
197
|
+
/** Column alignment. Omit to auto-align: numbers and short codes (≤ 4 chars)
|
|
198
|
+
* center, everything else stays left. Set explicitly to override. */
|
|
199
|
+
align?: "left" | "center" | "right";
|
|
200
|
+
group?: FieldGroup;
|
|
201
|
+
/** Initial column width in px (user-resizable via the header handle). */
|
|
202
|
+
width?: number;
|
|
203
|
+
/** Show a copy-to-clipboard action on hover (e.g. email, phone). */
|
|
204
|
+
copyable?: boolean;
|
|
205
|
+
/** Show in the detail panel only, not as a table column (e.g. first/last name). */
|
|
206
|
+
hideInTable?: boolean;
|
|
207
|
+
/** Custom, non-editable cell/value renderer. */
|
|
208
|
+
render?: (row: T) => React.ReactNode;
|
|
209
|
+
/** If set, the field becomes a choice field: the selection toolbar offers a
|
|
210
|
+
* "Set {label}" bulk action listing these values. */
|
|
211
|
+
options?: { value: string; label: string }[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
interface RecordViewProps<T extends { id: RowId }> {
|
|
215
|
+
title: string;
|
|
216
|
+
singular: string;
|
|
217
|
+
icon?: IconType;
|
|
218
|
+
fields: RecordField<T>[];
|
|
219
|
+
initialData: T[];
|
|
220
|
+
makeEmptyRow: () => T;
|
|
221
|
+
getPrimary: (row: T) => {
|
|
222
|
+
title: string;
|
|
223
|
+
initials: string;
|
|
224
|
+
subtitle?: string;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function RecordView<T extends { id: RowId }>({
|
|
229
|
+
title,
|
|
230
|
+
singular,
|
|
231
|
+
icon: TitleIcon,
|
|
232
|
+
fields,
|
|
233
|
+
initialData,
|
|
234
|
+
makeEmptyRow,
|
|
235
|
+
getPrimary,
|
|
236
|
+
}: RecordViewProps<T>) {
|
|
237
|
+
const { titleLeading } = React.useContext(PageChromeContext);
|
|
238
|
+
// Surface the page title/icon in the app's global top bar.
|
|
239
|
+
usePageTitle(title, TitleIcon);
|
|
240
|
+
const [rows, setRows] = React.useState<T[]>(initialData);
|
|
241
|
+
const [filter, setFilter] = React.useState("");
|
|
242
|
+
const [sort, setSort] = React.useState<{
|
|
243
|
+
key: string;
|
|
244
|
+
dir: "asc" | "desc";
|
|
245
|
+
} | null>(null);
|
|
246
|
+
const [hidden, setHidden] = React.useState<Set<string>>(new Set());
|
|
247
|
+
const [selected, setSelected] = React.useState<Set<RowId>>(new Set());
|
|
248
|
+
const [editing, setEditing] = React.useState<{
|
|
249
|
+
id: RowId;
|
|
250
|
+
key: string;
|
|
251
|
+
} | null>(null);
|
|
252
|
+
const [draft, setDraft] = React.useState("");
|
|
253
|
+
const [activeId, setActiveId] = React.useState<RowId | null>(null);
|
|
254
|
+
// A row created via "add" but not yet saved — Cancel/close removes it.
|
|
255
|
+
const [newRowId, setNewRowId] = React.useState<RowId | null>(null);
|
|
256
|
+
// Row pending delete confirmation.
|
|
257
|
+
const [confirmDeleteId, setConfirmDeleteId] = React.useState<RowId | null>(null);
|
|
258
|
+
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
|
259
|
+
// Whether the detail panel opened read-only (View) or editable (Edit / Add).
|
|
260
|
+
const [panelReadOnly, setPanelReadOnly] = React.useState(false);
|
|
261
|
+
const [page, setPage] = React.useState(1);
|
|
262
|
+
const [pageSize, setPageSize] = React.useState<number>(25);
|
|
263
|
+
const [flashId, setFlashId] = React.useState<RowId | null>(null);
|
|
264
|
+
const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
|
|
265
|
+
const [dragId, setDragId] = React.useState<RowId | null>(null);
|
|
266
|
+
const [dragOverId, setDragOverId] = React.useState<RowId | null>(null);
|
|
267
|
+
const [menu, setMenu] = React.useState<{
|
|
268
|
+
id: RowId;
|
|
269
|
+
x: number;
|
|
270
|
+
y: number;
|
|
271
|
+
} | null>(null);
|
|
272
|
+
// Empty by default: columns auto-size to their header text via CSS (`w-max`).
|
|
273
|
+
// A key is only set once the user drags a column's resize handle.
|
|
274
|
+
const [colWidths, setColWidths] = React.useState<Record<string, number>>({});
|
|
275
|
+
|
|
276
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
277
|
+
const nextId = React.useRef(1_000_000);
|
|
278
|
+
React.useEffect(() => {
|
|
279
|
+
if (editing) {
|
|
280
|
+
inputRef.current?.focus();
|
|
281
|
+
inputRef.current?.select();
|
|
282
|
+
}
|
|
283
|
+
}, [editing]);
|
|
284
|
+
|
|
285
|
+
React.useEffect(() => {
|
|
286
|
+
if (!menu) return;
|
|
287
|
+
const close = () => setMenu(null);
|
|
288
|
+
const onKey = (e: KeyboardEvent) => {
|
|
289
|
+
if (e.key === "Escape") setMenu(null);
|
|
290
|
+
};
|
|
291
|
+
window.addEventListener("mousedown", close);
|
|
292
|
+
window.addEventListener("scroll", close, true);
|
|
293
|
+
window.addEventListener("resize", close);
|
|
294
|
+
window.addEventListener("keydown", onKey);
|
|
295
|
+
return () => {
|
|
296
|
+
window.removeEventListener("mousedown", close);
|
|
297
|
+
window.removeEventListener("scroll", close, true);
|
|
298
|
+
window.removeEventListener("resize", close);
|
|
299
|
+
window.removeEventListener("keydown", onKey);
|
|
300
|
+
};
|
|
301
|
+
}, [menu]);
|
|
302
|
+
|
|
303
|
+
const tableFields = fields.filter((f) => !f.hideInTable);
|
|
304
|
+
const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
|
|
305
|
+
|
|
306
|
+
const nameWidth = colWidths[NAME_COL] ?? NAME_DEFAULT_W;
|
|
307
|
+
const totalWidth =
|
|
308
|
+
CHECKBOX_W +
|
|
309
|
+
ACTIONS_W +
|
|
310
|
+
nameWidth +
|
|
311
|
+
visibleFields.reduce(
|
|
312
|
+
(sum, f) => sum + (colWidths[f.key] ?? fieldDefaultWidth(f)),
|
|
313
|
+
0,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const resizeHandle = (col: string, label: string) => (
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
aria-label={`Resize ${label} column`}
|
|
320
|
+
title="Drag to resize"
|
|
321
|
+
onMouseDown={(e) => startResize(col, e)}
|
|
322
|
+
onClick={(e) => e.stopPropagation()}
|
|
323
|
+
onKeyDown={(e) => {
|
|
324
|
+
if (e.key === "ArrowLeft") {
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
nudgeColumn(col, -1);
|
|
327
|
+
}
|
|
328
|
+
if (e.key === "ArrowRight") {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
nudgeColumn(col, 1);
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
className="absolute right-0 top-0 z-10 h-full w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus-visible:outline-none"
|
|
334
|
+
/>
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const processed = React.useMemo(() => {
|
|
338
|
+
let out = rows;
|
|
339
|
+
const q = filter.trim().toLowerCase();
|
|
340
|
+
if (q) {
|
|
341
|
+
out = out.filter((row) => {
|
|
342
|
+
const primary = getPrimary(row).title.toLowerCase();
|
|
343
|
+
if (primary.includes(q)) return true;
|
|
344
|
+
return fields.some((f) =>
|
|
345
|
+
String(row[f.key] ?? "")
|
|
346
|
+
.toLowerCase()
|
|
347
|
+
.includes(q),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (sort) {
|
|
352
|
+
const { key, dir } = sort;
|
|
353
|
+
out = [...out].sort((a, b) => {
|
|
354
|
+
const av = a[key as keyof T];
|
|
355
|
+
const bv = b[key as keyof T];
|
|
356
|
+
let cmp: number;
|
|
357
|
+
if (typeof av === "number" && typeof bv === "number") {
|
|
358
|
+
cmp = av - bv;
|
|
359
|
+
} else {
|
|
360
|
+
cmp = String(av ?? "").localeCompare(String(bv ?? ""));
|
|
361
|
+
}
|
|
362
|
+
return dir === "asc" ? cmp : -cmp;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}, [rows, filter, sort, fields, getPrimary]);
|
|
367
|
+
|
|
368
|
+
const activeRow = rows.find((r) => r.id === activeId) ?? null;
|
|
369
|
+
const deleteTarget =
|
|
370
|
+
confirmDeleteId != null
|
|
371
|
+
? (rows.find((r) => r.id === confirmDeleteId) ?? null)
|
|
372
|
+
: null;
|
|
373
|
+
|
|
374
|
+
// Pagination (derived; `page` is clamped so it never points past the last page).
|
|
375
|
+
const totalPages = Math.max(1, Math.ceil(processed.length / pageSize));
|
|
376
|
+
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
377
|
+
const rangeStart = processed.length === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
|
378
|
+
const rangeEnd = Math.min(safePage * pageSize, processed.length);
|
|
379
|
+
const paged = processed.slice((safePage - 1) * pageSize, safePage * pageSize);
|
|
380
|
+
|
|
381
|
+
// Reset to the first page when the filter or page size changes.
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
setPage(1);
|
|
384
|
+
}, [filter, pageSize]);
|
|
385
|
+
|
|
386
|
+
function startEdit(row: T, key: string) {
|
|
387
|
+
setEditing({ id: row.id, key });
|
|
388
|
+
setDraft(String(row[key as keyof T] ?? ""));
|
|
389
|
+
}
|
|
390
|
+
function commit() {
|
|
391
|
+
if (!editing) return;
|
|
392
|
+
setRows((prev) =>
|
|
393
|
+
prev.map((row) =>
|
|
394
|
+
row.id === editing.id ? { ...row, [editing.key]: draft } : row,
|
|
395
|
+
),
|
|
396
|
+
);
|
|
397
|
+
setEditing(null);
|
|
398
|
+
}
|
|
399
|
+
function startResize(key: string, e: React.MouseEvent) {
|
|
400
|
+
e.preventDefault();
|
|
401
|
+
e.stopPropagation();
|
|
402
|
+
const startX = e.clientX;
|
|
403
|
+
const startW = colWidths[key] ?? 160;
|
|
404
|
+
const onMove = (ev: MouseEvent) => {
|
|
405
|
+
setColWidths((prev) => ({
|
|
406
|
+
...prev,
|
|
407
|
+
[key]: Math.max(MIN_COL_W, startW + (ev.clientX - startX)),
|
|
408
|
+
}));
|
|
409
|
+
};
|
|
410
|
+
const onUp = () => {
|
|
411
|
+
document.removeEventListener("mousemove", onMove);
|
|
412
|
+
document.removeEventListener("mouseup", onUp);
|
|
413
|
+
document.body.style.userSelect = "";
|
|
414
|
+
document.body.style.cursor = "";
|
|
415
|
+
};
|
|
416
|
+
document.addEventListener("mousemove", onMove);
|
|
417
|
+
document.addEventListener("mouseup", onUp);
|
|
418
|
+
document.body.style.userSelect = "none";
|
|
419
|
+
document.body.style.cursor = "col-resize";
|
|
420
|
+
}
|
|
421
|
+
function nudgeColumn(key: string, dir: -1 | 1) {
|
|
422
|
+
setColWidths((prev) => ({
|
|
423
|
+
...prev,
|
|
424
|
+
[key]: Math.max(MIN_COL_W, (prev[key] ?? 160) + dir * 16),
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
function toggleSort(key: string) {
|
|
428
|
+
setSort((prev) =>
|
|
429
|
+
prev?.key === key
|
|
430
|
+
? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
|
|
431
|
+
: { key, dir: "asc" },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
function toggleHidden(key: string) {
|
|
435
|
+
setHidden((prev) => {
|
|
436
|
+
const next = new Set(prev);
|
|
437
|
+
if (next.has(key)) next.delete(key);
|
|
438
|
+
else next.add(key);
|
|
439
|
+
return next;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function toggleSelect(id: RowId) {
|
|
443
|
+
setSelected((prev) => {
|
|
444
|
+
const next = new Set(prev);
|
|
445
|
+
if (next.has(id)) next.delete(id);
|
|
446
|
+
else next.add(id);
|
|
447
|
+
return next;
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function toggleSelectAll() {
|
|
451
|
+
setSelected((prev) =>
|
|
452
|
+
prev.size === processed.length
|
|
453
|
+
? new Set()
|
|
454
|
+
: new Set(processed.map((r) => r.id)),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
/** Bulk-set a choice field on every selected row (keeps the selection). */
|
|
458
|
+
function bulkSetField(key: keyof T, value: string) {
|
|
459
|
+
setRows((prev) =>
|
|
460
|
+
prev.map((r) => (selected.has(r.id) ? ({ ...r, [key]: value } as T) : r)),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
/** Delete every selected row, then clear the selection. */
|
|
464
|
+
function bulkDelete() {
|
|
465
|
+
setRows((prev) => prev.filter((r) => !selected.has(r.id)));
|
|
466
|
+
if (activeId != null && selected.has(activeId)) setActiveId(null);
|
|
467
|
+
setSelected(new Set());
|
|
468
|
+
setBulkDeleteOpen(false);
|
|
469
|
+
}
|
|
470
|
+
function addRow() {
|
|
471
|
+
const row = { ...makeEmptyRow(), id: nextId.current++ };
|
|
472
|
+
// Prepend so the new record is immediately visible at the top…
|
|
473
|
+
setRows((prev) => [row, ...prev]);
|
|
474
|
+
setPage(1);
|
|
475
|
+
setPanelReadOnly(false);
|
|
476
|
+
setActiveId(row.id);
|
|
477
|
+
setNewRowId(row.id);
|
|
478
|
+
}
|
|
479
|
+
/** Open the detail panel read-only (View). */
|
|
480
|
+
function openView(id: RowId) {
|
|
481
|
+
setPanelReadOnly(true);
|
|
482
|
+
setActiveId(id);
|
|
483
|
+
}
|
|
484
|
+
/** Open the detail panel editable (Edit). */
|
|
485
|
+
function openEdit(id: RowId) {
|
|
486
|
+
setPanelReadOnly(false);
|
|
487
|
+
setActiveId(id);
|
|
488
|
+
}
|
|
489
|
+
/** Commit the form's buffered draft back into the table. */
|
|
490
|
+
function saveForm(updated: T) {
|
|
491
|
+
setRows((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
|
492
|
+
// Flash the saved row so the change is unmistakable.
|
|
493
|
+
setFlashId(updated.id);
|
|
494
|
+
window.setTimeout(() => {
|
|
495
|
+
setFlashId((current) => (current === updated.id ? null : current));
|
|
496
|
+
}, 1600);
|
|
497
|
+
setNewRowId(null);
|
|
498
|
+
setActiveId(null);
|
|
499
|
+
}
|
|
500
|
+
/** Discard the form; drop the row entirely if it was never saved. */
|
|
501
|
+
function cancelForm() {
|
|
502
|
+
if (activeId != null && activeId === newRowId) {
|
|
503
|
+
setRows((prev) => prev.filter((r) => r.id !== activeId));
|
|
504
|
+
}
|
|
505
|
+
setNewRowId(null);
|
|
506
|
+
setActiveId(null);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const importRef = React.useRef<HTMLInputElement>(null);
|
|
510
|
+
const ioColumns: IoColumn[] = fields.map((f) => ({
|
|
511
|
+
key: f.key,
|
|
512
|
+
label: f.label,
|
|
513
|
+
}));
|
|
514
|
+
/** Export the currently filtered/sorted rows in the chosen format. */
|
|
515
|
+
function exportData(format: "csv" | "excel" | "json" | "pdf") {
|
|
516
|
+
const data = processed as Record<string, unknown>[];
|
|
517
|
+
const base = title.toLowerCase().replace(/\s+/g, "-") || "export";
|
|
518
|
+
if (format === "csv")
|
|
519
|
+
downloadFile(`${base}.csv`, rowsToCSV(ioColumns, data), "text/csv;charset=utf-8");
|
|
520
|
+
else if (format === "json")
|
|
521
|
+
downloadFile(`${base}.json`, JSON.stringify(data, null, 2), "application/json");
|
|
522
|
+
else if (format === "excel")
|
|
523
|
+
downloadFile(`${base}.xls`, rowsToTableHTML(ioColumns, data), "application/vnd.ms-excel");
|
|
524
|
+
else printTable(title, rowsToTableHTML(ioColumns, data));
|
|
525
|
+
}
|
|
526
|
+
/** Parse an imported CSV/JSON file and prepend the rows to the table. */
|
|
527
|
+
async function onImportFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
528
|
+
const file = e.target.files?.[0];
|
|
529
|
+
e.target.value = "";
|
|
530
|
+
if (!file) return;
|
|
531
|
+
const text = await file.text();
|
|
532
|
+
let records: Record<string, unknown>[] = [];
|
|
533
|
+
try {
|
|
534
|
+
if (file.name.toLowerCase().endsWith(".json")) {
|
|
535
|
+
const parsed: unknown = JSON.parse(text);
|
|
536
|
+
records = Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [];
|
|
537
|
+
} else {
|
|
538
|
+
records = parseCSV(text);
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const byKey = new Map(fields.map((f) => [f.key.toLowerCase(), f.key]));
|
|
544
|
+
const byLabel = new Map(fields.map((f) => [f.label.toLowerCase(), f.key]));
|
|
545
|
+
const imported = records.map((rec) => {
|
|
546
|
+
const row = { ...makeEmptyRow(), id: nextId.current++ } as Record<
|
|
547
|
+
string,
|
|
548
|
+
unknown
|
|
549
|
+
>;
|
|
550
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
551
|
+
const key = byKey.get(k.toLowerCase()) ?? byLabel.get(k.toLowerCase());
|
|
552
|
+
if (key) row[key] = v;
|
|
553
|
+
}
|
|
554
|
+
return row as T;
|
|
555
|
+
});
|
|
556
|
+
if (imported.length) {
|
|
557
|
+
setRows((prev) => [...imported, ...prev]);
|
|
558
|
+
setPage(1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function deleteRow(id: RowId) {
|
|
562
|
+
setRows((prev) => prev.filter((row) => row.id !== id));
|
|
563
|
+
setSelected((prev) => {
|
|
564
|
+
if (!prev.has(id)) return prev;
|
|
565
|
+
const next = new Set(prev);
|
|
566
|
+
next.delete(id);
|
|
567
|
+
return next;
|
|
568
|
+
});
|
|
569
|
+
if (activeId === id) setActiveId(null);
|
|
570
|
+
}
|
|
571
|
+
function duplicateRow(id: RowId) {
|
|
572
|
+
const copyId = nextId.current++;
|
|
573
|
+
setRows((prev) => {
|
|
574
|
+
const index = prev.findIndex((row) => row.id === id);
|
|
575
|
+
if (index < 0) return prev;
|
|
576
|
+
const original = prev[index];
|
|
577
|
+
if (!original) return prev;
|
|
578
|
+
const next = [...prev];
|
|
579
|
+
next.splice(index + 1, 0, { ...original, id: copyId } as T);
|
|
580
|
+
return next;
|
|
581
|
+
});
|
|
582
|
+
setActiveId(copyId);
|
|
583
|
+
}
|
|
584
|
+
function reorder(sourceId: RowId, targetId: RowId) {
|
|
585
|
+
if (sourceId === targetId) return;
|
|
586
|
+
// Manual ordering only makes sense without an active sort.
|
|
587
|
+
setSort(null);
|
|
588
|
+
setRows((prev) => {
|
|
589
|
+
const from = prev.findIndex((row) => row.id === sourceId);
|
|
590
|
+
const to = prev.findIndex((row) => row.id === targetId);
|
|
591
|
+
if (from < 0 || to < 0) return prev;
|
|
592
|
+
const next = [...prev];
|
|
593
|
+
const [moved] = next.splice(from, 1);
|
|
594
|
+
if (!moved) return prev;
|
|
595
|
+
next.splice(to, 0, moved);
|
|
596
|
+
return next;
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async function copyValue(key: string, value: string) {
|
|
600
|
+
try {
|
|
601
|
+
await navigator.clipboard.writeText(value);
|
|
602
|
+
setCopiedKey(key);
|
|
603
|
+
window.setTimeout(
|
|
604
|
+
() => setCopiedKey((current) => (current === key ? null : current)),
|
|
605
|
+
1200,
|
|
606
|
+
);
|
|
607
|
+
} catch {
|
|
608
|
+
// Clipboard unavailable (insecure context / denied) — no-op.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const allSelected =
|
|
613
|
+
processed.length > 0 && selected.size === processed.length;
|
|
614
|
+
// Choice fields (with `options`) power the "Set …" bulk actions.
|
|
615
|
+
const bulkFields = fields.filter((f) => f.options && f.options.length > 0);
|
|
616
|
+
// Per-column alignment (auto: numbers + short codes center).
|
|
617
|
+
const columnAligns = React.useMemo(
|
|
618
|
+
() => computeColumnAligns(fields, initialData),
|
|
619
|
+
[fields, initialData],
|
|
620
|
+
);
|
|
621
|
+
const alignOf = (key: string): ColAlign => columnAligns[key] ?? "left";
|
|
622
|
+
|
|
623
|
+
function renderCellValue(row: T, field: RecordField<T>) {
|
|
624
|
+
const isEditing = editing?.id === row.id && editing.key === field.key;
|
|
625
|
+
if (field.render) {
|
|
626
|
+
return (
|
|
627
|
+
<div className={cn("px-3 py-1.5", ALIGN_TEXT[alignOf(field.key)])}>
|
|
628
|
+
{field.render(row)}
|
|
629
|
+
</div>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
if (isEditing) {
|
|
633
|
+
return (
|
|
634
|
+
<input
|
|
635
|
+
ref={inputRef}
|
|
636
|
+
value={draft}
|
|
637
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
638
|
+
onBlur={commit}
|
|
639
|
+
onKeyDown={(e) => {
|
|
640
|
+
if (e.key === "Enter") commit();
|
|
641
|
+
if (e.key === "Escape") setEditing(null);
|
|
642
|
+
}}
|
|
643
|
+
aria-label={`Edit ${field.label}`}
|
|
644
|
+
className={cn(
|
|
645
|
+
"h-8 w-full bg-background px-3 outline-none ring-2 ring-inset ring-ring",
|
|
646
|
+
ALIGN_TEXT[alignOf(field.key)],
|
|
647
|
+
)}
|
|
648
|
+
/>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const value = String(row[field.key] ?? "");
|
|
652
|
+
const cellKey = `${row.id}:${field.key}`;
|
|
653
|
+
const hoverActions =
|
|
654
|
+
(field.editable || (field.copyable && value)) ? (
|
|
655
|
+
<span className="absolute inset-y-0 right-1 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/cell:opacity-100 focus-within:opacity-100">
|
|
656
|
+
{field.copyable && value && (
|
|
657
|
+
<button
|
|
658
|
+
type="button"
|
|
659
|
+
onClick={(e) => {
|
|
660
|
+
e.stopPropagation();
|
|
661
|
+
void copyValue(cellKey, value);
|
|
662
|
+
}}
|
|
663
|
+
aria-label={`Copy ${field.label}`}
|
|
664
|
+
title={`Copy ${field.label}`}
|
|
665
|
+
className="grid size-6 place-items-center rounded-sm bg-background text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
|
666
|
+
>
|
|
667
|
+
{copiedKey === cellKey ? (
|
|
668
|
+
<Check className="size-3.5 text-emerald-600" />
|
|
669
|
+
) : (
|
|
670
|
+
<Copy className="size-3.5" />
|
|
671
|
+
)}
|
|
672
|
+
</button>
|
|
673
|
+
)}
|
|
674
|
+
{field.editable && (
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
onClick={(e) => {
|
|
678
|
+
e.stopPropagation();
|
|
679
|
+
startEdit(row, field.key);
|
|
680
|
+
}}
|
|
681
|
+
aria-label={`Edit ${field.label}`}
|
|
682
|
+
title={`Edit ${field.label}`}
|
|
683
|
+
className="grid size-6 place-items-center rounded-sm bg-background text-muted-foreground shadow-sm ring-1 ring-border hover:text-foreground"
|
|
684
|
+
>
|
|
685
|
+
<Pencil className="size-3.5" />
|
|
686
|
+
</button>
|
|
687
|
+
)}
|
|
688
|
+
</span>
|
|
689
|
+
) : null;
|
|
690
|
+
|
|
691
|
+
if (field.editable) {
|
|
692
|
+
return (
|
|
693
|
+
<div className="group/cell relative flex h-8 w-full items-center">
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
onClick={() => startEdit(row, field.key)}
|
|
697
|
+
className={cn(
|
|
698
|
+
"flex h-8 w-full items-center overflow-hidden px-3 text-left hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
|
699
|
+
ALIGN_BOX[alignOf(field.key)],
|
|
700
|
+
)}
|
|
701
|
+
>
|
|
702
|
+
<span className="truncate">
|
|
703
|
+
{value || <span className="text-muted-foreground">—</span>}
|
|
704
|
+
</span>
|
|
705
|
+
</button>
|
|
706
|
+
{hoverActions}
|
|
707
|
+
</div>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return (
|
|
711
|
+
<div
|
|
712
|
+
className={cn(
|
|
713
|
+
"group/cell relative flex h-8 items-center px-3",
|
|
714
|
+
ALIGN_BOX[alignOf(field.key)],
|
|
715
|
+
)}
|
|
716
|
+
>
|
|
717
|
+
<span className="truncate">{value}</span>
|
|
718
|
+
{hoverActions}
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<div className="flex h-full flex-col">
|
|
725
|
+
{/* Header — title/icon now live in the global top bar; this row holds the
|
|
726
|
+
per-record actions (add / import / export). */}
|
|
727
|
+
<div className="flex h-12 items-center justify-between border-b border-border px-4">
|
|
728
|
+
<div className="flex items-center gap-2">{titleLeading}</div>
|
|
729
|
+
<div className="flex items-center gap-1.5">
|
|
730
|
+
<Button
|
|
731
|
+
variant="link"
|
|
732
|
+
size="sm"
|
|
733
|
+
onClick={addRow}
|
|
734
|
+
className="px-1 no-underline hover:no-underline"
|
|
735
|
+
>
|
|
736
|
+
<Plus className="size-4 text-emerald-500" />
|
|
737
|
+
<span>{singular}</span>
|
|
738
|
+
</Button>
|
|
739
|
+
|
|
740
|
+
<input
|
|
741
|
+
ref={importRef}
|
|
742
|
+
type="file"
|
|
743
|
+
accept=".csv,.json"
|
|
744
|
+
onChange={onImportFile}
|
|
745
|
+
className="hidden"
|
|
746
|
+
aria-hidden="true"
|
|
747
|
+
/>
|
|
748
|
+
<Dropdown
|
|
749
|
+
label="Import"
|
|
750
|
+
icon={<Upload className="size-3.5 text-sky-500" />}
|
|
751
|
+
align="end"
|
|
752
|
+
>
|
|
753
|
+
<DropdownLabel>Import from</DropdownLabel>
|
|
754
|
+
<DropdownItem onSelect={() => importRef.current?.click()}>
|
|
755
|
+
<span className="flex items-center gap-2">
|
|
756
|
+
<FileText className="size-3.5" /> CSV
|
|
757
|
+
</span>
|
|
758
|
+
</DropdownItem>
|
|
759
|
+
<DropdownItem onSelect={() => importRef.current?.click()}>
|
|
760
|
+
<span className="flex items-center gap-2">
|
|
761
|
+
<Code className="size-3.5" /> JSON
|
|
762
|
+
</span>
|
|
763
|
+
</DropdownItem>
|
|
764
|
+
<DropdownItem onSelect={() => importRef.current?.click()}>
|
|
765
|
+
<span className="flex items-center gap-2">
|
|
766
|
+
<SheetIcon className="size-3.5" /> Excel
|
|
767
|
+
</span>
|
|
768
|
+
</DropdownItem>
|
|
769
|
+
</Dropdown>
|
|
770
|
+
|
|
771
|
+
<Dropdown
|
|
772
|
+
label="Export"
|
|
773
|
+
icon={<Download className="size-3.5 text-violet-500" />}
|
|
774
|
+
align="end"
|
|
775
|
+
>
|
|
776
|
+
<DropdownLabel>Export as</DropdownLabel>
|
|
777
|
+
<DropdownItem onSelect={() => exportData("csv")}>
|
|
778
|
+
<span className="flex items-center gap-2">
|
|
779
|
+
<FileText className="size-3.5" /> CSV
|
|
780
|
+
</span>
|
|
781
|
+
</DropdownItem>
|
|
782
|
+
<DropdownItem onSelect={() => exportData("excel")}>
|
|
783
|
+
<span className="flex items-center gap-2">
|
|
784
|
+
<SheetIcon className="size-3.5" /> Excel
|
|
785
|
+
</span>
|
|
786
|
+
</DropdownItem>
|
|
787
|
+
<DropdownItem onSelect={() => exportData("json")}>
|
|
788
|
+
<span className="flex items-center gap-2">
|
|
789
|
+
<Code className="size-3.5" /> JSON
|
|
790
|
+
</span>
|
|
791
|
+
</DropdownItem>
|
|
792
|
+
<DropdownItem onSelect={() => exportData("pdf")}>
|
|
793
|
+
<span className="flex items-center gap-2">
|
|
794
|
+
<Reader className="size-3.5" /> PDF
|
|
795
|
+
</span>
|
|
796
|
+
</DropdownItem>
|
|
797
|
+
</Dropdown>
|
|
798
|
+
|
|
799
|
+
<Dropdown
|
|
800
|
+
label=""
|
|
801
|
+
ariaLabel="More actions"
|
|
802
|
+
icon={<MoreHorizontal className="size-4 text-slate-500" />}
|
|
803
|
+
align="end"
|
|
804
|
+
>
|
|
805
|
+
<DropdownItem onSelect={() => setSelected(new Set())}>
|
|
806
|
+
Clear selection
|
|
807
|
+
</DropdownItem>
|
|
808
|
+
<DropdownItem onSelect={() => setHidden(new Set())}>
|
|
809
|
+
Show all columns
|
|
810
|
+
</DropdownItem>
|
|
811
|
+
</Dropdown>
|
|
812
|
+
</div>
|
|
813
|
+
</div>
|
|
814
|
+
|
|
815
|
+
{/* Content — padded, bordered card (matches the settings-page layout) */}
|
|
816
|
+
<div className="min-h-0 flex-1 overflow-hidden p-4">
|
|
817
|
+
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
|
818
|
+
{/* Sub-toolbar */}
|
|
819
|
+
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-1.5">
|
|
820
|
+
<div className="flex items-center gap-2">
|
|
821
|
+
<ListFilter className="size-4 text-muted-foreground" />
|
|
822
|
+
{selected.size > 0 ? (
|
|
823
|
+
<span className="flex items-center gap-2">
|
|
824
|
+
<span className="font-medium">{selected.size} selected</span>
|
|
825
|
+
<button
|
|
826
|
+
type="button"
|
|
827
|
+
onClick={() => setSelected(new Set())}
|
|
828
|
+
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
829
|
+
>
|
|
830
|
+
Clear
|
|
831
|
+
</button>
|
|
832
|
+
</span>
|
|
833
|
+
) : (
|
|
834
|
+
<span className="font-medium">All {title}</span>
|
|
835
|
+
)}
|
|
836
|
+
</div>
|
|
837
|
+
<div className="flex items-center gap-0.5">
|
|
838
|
+
{/* Bulk actions — mirror the Options dropdown; shown only with a selection. */}
|
|
839
|
+
{selected.size > 0 && (
|
|
840
|
+
<Dropdown
|
|
841
|
+
label="Actions"
|
|
842
|
+
icon={<MoreHorizontal className="size-3.5 text-violet-500" />}
|
|
843
|
+
>
|
|
844
|
+
<DropdownLabel>{selected.size} selected</DropdownLabel>
|
|
845
|
+
{bulkFields.map((f) => (
|
|
846
|
+
<React.Fragment key={f.key}>
|
|
847
|
+
<DropdownLabel>Set {f.label}</DropdownLabel>
|
|
848
|
+
{f.options?.map((o) => (
|
|
849
|
+
<DropdownItem
|
|
850
|
+
key={o.value}
|
|
851
|
+
onSelect={() => bulkSetField(f.key, o.value)}
|
|
852
|
+
>
|
|
853
|
+
{o.label}
|
|
854
|
+
</DropdownItem>
|
|
855
|
+
))}
|
|
856
|
+
</React.Fragment>
|
|
857
|
+
))}
|
|
858
|
+
<DropdownItem onSelect={() => setBulkDeleteOpen(true)}>
|
|
859
|
+
<span className="flex items-center gap-2 text-destructive">
|
|
860
|
+
<Trash2 className="size-3.5" /> Delete {selected.size} selected
|
|
861
|
+
</span>
|
|
862
|
+
</DropdownItem>
|
|
863
|
+
</Dropdown>
|
|
864
|
+
)}
|
|
865
|
+
<Dropdown label="Filter" icon={<ListFilter className="size-3.5 text-amber-500" />}>
|
|
866
|
+
<DropdownLabel>Filter by keyword</DropdownLabel>
|
|
867
|
+
<div className="p-3">
|
|
868
|
+
<div className="relative">
|
|
869
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
870
|
+
<Input
|
|
871
|
+
value={filter}
|
|
872
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
873
|
+
placeholder="Contains…"
|
|
874
|
+
aria-label="Filter"
|
|
875
|
+
className="h-8 pl-9"
|
|
876
|
+
/>
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
</Dropdown>
|
|
880
|
+
|
|
881
|
+
<Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
|
|
882
|
+
<DropdownLabel>Sort by</DropdownLabel>
|
|
883
|
+
{tableFields.map((f) => (
|
|
884
|
+
<DropdownItem
|
|
885
|
+
key={f.key}
|
|
886
|
+
onSelect={() => toggleSort(f.key)}
|
|
887
|
+
icon={
|
|
888
|
+
sort?.key === f.key ? (
|
|
889
|
+
sort.dir === "asc" ? (
|
|
890
|
+
<ArrowUp className="size-3.5" />
|
|
891
|
+
) : (
|
|
892
|
+
<ArrowDown className="size-3.5" />
|
|
893
|
+
)
|
|
894
|
+
) : undefined
|
|
895
|
+
}
|
|
896
|
+
>
|
|
897
|
+
{f.label}
|
|
898
|
+
</DropdownItem>
|
|
899
|
+
))}
|
|
900
|
+
{sort && (
|
|
901
|
+
<DropdownItem onSelect={() => setSort(null)}>
|
|
902
|
+
Clear sort
|
|
903
|
+
</DropdownItem>
|
|
904
|
+
)}
|
|
905
|
+
</Dropdown>
|
|
906
|
+
|
|
907
|
+
<Dropdown
|
|
908
|
+
label="Options"
|
|
909
|
+
icon={<SlidersHorizontal className="size-3.5 text-fuchsia-500" />}
|
|
910
|
+
align="end"
|
|
911
|
+
>
|
|
912
|
+
<DropdownLabel>Visible columns</DropdownLabel>
|
|
913
|
+
{tableFields.map((f) => (
|
|
914
|
+
<DropdownItem
|
|
915
|
+
key={f.key}
|
|
916
|
+
checked={!hidden.has(f.key)}
|
|
917
|
+
onSelect={() => toggleHidden(f.key)}
|
|
918
|
+
>
|
|
919
|
+
{f.label}
|
|
920
|
+
</DropdownItem>
|
|
921
|
+
))}
|
|
922
|
+
</Dropdown>
|
|
923
|
+
|
|
924
|
+
{/* Pagination */}
|
|
925
|
+
<div className="ml-1 flex items-center gap-1 border-l border-border pl-2 text-muted-foreground">
|
|
926
|
+
<Dropdown
|
|
927
|
+
label={`${pageSize} / page`}
|
|
928
|
+
icon={<Rows3 className="size-3.5 text-teal-500" />}
|
|
929
|
+
align="end"
|
|
930
|
+
>
|
|
931
|
+
<DropdownLabel>Rows per page</DropdownLabel>
|
|
932
|
+
{PAGE_SIZES.map((n) => (
|
|
933
|
+
<DropdownItem
|
|
934
|
+
key={n}
|
|
935
|
+
checked={pageSize === n}
|
|
936
|
+
onSelect={() => setPageSize(n)}
|
|
937
|
+
>
|
|
938
|
+
{n} per page
|
|
939
|
+
</DropdownItem>
|
|
940
|
+
))}
|
|
941
|
+
</Dropdown>
|
|
942
|
+
<span className="whitespace-nowrap px-1 tabular-nums">
|
|
943
|
+
{rangeStart}–{rangeEnd} of {processed.length}
|
|
944
|
+
</span>
|
|
945
|
+
<button
|
|
946
|
+
type="button"
|
|
947
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
948
|
+
disabled={safePage <= 1}
|
|
949
|
+
aria-label="Previous page"
|
|
950
|
+
className="grid size-7 place-items-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
951
|
+
>
|
|
952
|
+
<ChevronLeft className="size-4" />
|
|
953
|
+
</button>
|
|
954
|
+
<button
|
|
955
|
+
type="button"
|
|
956
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
957
|
+
disabled={safePage >= totalPages}
|
|
958
|
+
aria-label="Next page"
|
|
959
|
+
className="grid size-7 place-items-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
|
960
|
+
>
|
|
961
|
+
<ChevronRight className="size-4" />
|
|
962
|
+
</button>
|
|
963
|
+
</div>
|
|
964
|
+
</div>
|
|
965
|
+
</div>
|
|
966
|
+
|
|
967
|
+
{/* Table */}
|
|
968
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
969
|
+
<Table
|
|
970
|
+
style={{ minWidth: totalWidth, tableLayout: "auto" }}
|
|
971
|
+
className="w-full"
|
|
972
|
+
>
|
|
973
|
+
<TableHeader className="sticky top-0 z-20 bg-background [&_th]:sticky [&_th]:top-0 [&_th]:z-20 [&_th]:bg-background">
|
|
974
|
+
<TableRow className="hover:bg-transparent">
|
|
975
|
+
<TableHead style={{ width: CHECKBOX_W }} className="p-0">
|
|
976
|
+
<div className="flex h-8 items-center pl-4">
|
|
977
|
+
<Checkbox
|
|
978
|
+
checked={allSelected}
|
|
979
|
+
onChange={toggleSelectAll}
|
|
980
|
+
aria-label="Select all"
|
|
981
|
+
/>
|
|
982
|
+
</div>
|
|
983
|
+
</TableHead>
|
|
984
|
+
<TableHead className="relative w-max" style={{ width: colWidths[NAME_COL] }}>
|
|
985
|
+
<span className="flex h-8 items-center gap-1.5 whitespace-nowrap">
|
|
986
|
+
{TitleIcon ? (
|
|
987
|
+
<TitleIcon className="size-3.5 shrink-0 text-foreground" />
|
|
988
|
+
) : (
|
|
989
|
+
<DEFAULT_FIELD_ICON className="size-3.5 shrink-0 text-foreground" />
|
|
990
|
+
)}
|
|
991
|
+
Name
|
|
992
|
+
</span>
|
|
993
|
+
{resizeHandle(NAME_COL, "Name")}
|
|
994
|
+
</TableHead>
|
|
995
|
+
{visibleFields.map((f) => {
|
|
996
|
+
const HeadIcon = f.icon ?? DEFAULT_FIELD_ICON;
|
|
997
|
+
return (
|
|
998
|
+
<TableHead
|
|
999
|
+
key={f.key}
|
|
1000
|
+
className="relative w-max"
|
|
1001
|
+
style={{ width: colWidths[f.key] }}
|
|
1002
|
+
>
|
|
1003
|
+
<button
|
|
1004
|
+
type="button"
|
|
1005
|
+
onClick={() => toggleSort(f.key)}
|
|
1006
|
+
className={cn(
|
|
1007
|
+
"flex h-8 w-full items-center gap-1.5 whitespace-nowrap hover:text-foreground",
|
|
1008
|
+
ALIGN_BOX[alignOf(f.key)],
|
|
1009
|
+
)}
|
|
1010
|
+
>
|
|
1011
|
+
<HeadIcon className="size-3.5 shrink-0 text-foreground" />
|
|
1012
|
+
<span className="flex items-center gap-1 whitespace-nowrap">
|
|
1013
|
+
{f.label}
|
|
1014
|
+
{f.required && <RequiredMark />}
|
|
1015
|
+
</span>
|
|
1016
|
+
{sort?.key === f.key &&
|
|
1017
|
+
(sort.dir === "asc" ? (
|
|
1018
|
+
<ArrowUp className="size-3 shrink-0" />
|
|
1019
|
+
) : (
|
|
1020
|
+
<ArrowDown className="size-3 shrink-0" />
|
|
1021
|
+
))}
|
|
1022
|
+
</button>
|
|
1023
|
+
{resizeHandle(f.key, f.label)}
|
|
1024
|
+
</TableHead>
|
|
1025
|
+
);
|
|
1026
|
+
})}
|
|
1027
|
+
<TableHead
|
|
1028
|
+
style={{ width: ACTIONS_W }}
|
|
1029
|
+
className="border-r-0 text-right"
|
|
1030
|
+
>
|
|
1031
|
+
<span className="flex h-8 items-center justify-end whitespace-nowrap pr-2">
|
|
1032
|
+
Actions
|
|
1033
|
+
</span>
|
|
1034
|
+
</TableHead>
|
|
1035
|
+
{/* Filler so row borders reach the right edge of the page. */}
|
|
1036
|
+
<TableHead aria-hidden="true" />
|
|
1037
|
+
</TableRow>
|
|
1038
|
+
</TableHeader>
|
|
1039
|
+
<TableBody>
|
|
1040
|
+
{processed.length ? (
|
|
1041
|
+
paged.map((row) => {
|
|
1042
|
+
const primary = getPrimary(row);
|
|
1043
|
+
return (
|
|
1044
|
+
<TableRow
|
|
1045
|
+
key={row.id}
|
|
1046
|
+
data-active={row.id === activeId}
|
|
1047
|
+
data-flash={row.id === flashId}
|
|
1048
|
+
data-dragover={row.id === dragOverId && dragId !== row.id}
|
|
1049
|
+
onContextMenu={(e) => {
|
|
1050
|
+
e.preventDefault();
|
|
1051
|
+
setMenu({ id: row.id, x: e.clientX, y: e.clientY });
|
|
1052
|
+
}}
|
|
1053
|
+
onDragOver={(e) => {
|
|
1054
|
+
if (dragId === null) return;
|
|
1055
|
+
e.preventDefault();
|
|
1056
|
+
e.dataTransfer.dropEffect = "move";
|
|
1057
|
+
setDragOverId(row.id);
|
|
1058
|
+
}}
|
|
1059
|
+
onDrop={(e) => {
|
|
1060
|
+
if (dragId === null) return;
|
|
1061
|
+
e.preventDefault();
|
|
1062
|
+
reorder(dragId, row.id);
|
|
1063
|
+
setDragId(null);
|
|
1064
|
+
setDragOverId(null);
|
|
1065
|
+
}}
|
|
1066
|
+
className="group data-[active=true]:bg-accent/60 data-[dragover=true]:border-t-2 data-[dragover=true]:border-t-primary data-[flash=true]:bg-primary/10"
|
|
1067
|
+
>
|
|
1068
|
+
<TableCell className="p-0">
|
|
1069
|
+
<div className="relative flex h-8 items-center pl-4">
|
|
1070
|
+
{/* Drag grip — overlaid in the left gutter on hover,
|
|
1071
|
+
so it never shifts the checkbox's alignment. */}
|
|
1072
|
+
<div
|
|
1073
|
+
draggable
|
|
1074
|
+
onDragStart={(e) => {
|
|
1075
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1076
|
+
e.dataTransfer.setData("text/plain", String(row.id));
|
|
1077
|
+
setDragId(row.id);
|
|
1078
|
+
}}
|
|
1079
|
+
onDragEnd={() => {
|
|
1080
|
+
setDragId(null);
|
|
1081
|
+
setDragOverId(null);
|
|
1082
|
+
}}
|
|
1083
|
+
aria-label={`Drag ${primary.title || singular} to reorder`}
|
|
1084
|
+
title="Drag to reorder"
|
|
1085
|
+
className="absolute left-0 top-1/2 flex h-6 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-muted-foreground/50 opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100 active:cursor-grabbing"
|
|
1086
|
+
>
|
|
1087
|
+
<GripVertical className="size-3.5" />
|
|
1088
|
+
</div>
|
|
1089
|
+
<Checkbox
|
|
1090
|
+
checked={selected.has(row.id)}
|
|
1091
|
+
onChange={() => toggleSelect(row.id)}
|
|
1092
|
+
aria-label={`Select ${primary.title}`}
|
|
1093
|
+
/>
|
|
1094
|
+
</div>
|
|
1095
|
+
</TableCell>
|
|
1096
|
+
<TableCell
|
|
1097
|
+
className="p-0"
|
|
1098
|
+
style={{ maxWidth: colWidths[NAME_COL] ?? NAME_DEFAULT_W }}
|
|
1099
|
+
>
|
|
1100
|
+
<button
|
|
1101
|
+
type="button"
|
|
1102
|
+
onClick={() => openView(row.id)}
|
|
1103
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-muted/60"
|
|
1104
|
+
>
|
|
1105
|
+
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-muted font-medium text-muted-foreground">
|
|
1106
|
+
{primary.initials}
|
|
1107
|
+
</span>
|
|
1108
|
+
<span className="truncate">
|
|
1109
|
+
{primary.title || "—"}
|
|
1110
|
+
</span>
|
|
1111
|
+
</button>
|
|
1112
|
+
</TableCell>
|
|
1113
|
+
{visibleFields.map((f) => (
|
|
1114
|
+
<TableCell
|
|
1115
|
+
key={f.key}
|
|
1116
|
+
className="p-0"
|
|
1117
|
+
style={{ maxWidth: colWidths[f.key] ?? fieldDefaultWidth(f) }}
|
|
1118
|
+
>
|
|
1119
|
+
{renderCellValue(row, f)}
|
|
1120
|
+
</TableCell>
|
|
1121
|
+
))}
|
|
1122
|
+
<TableCell
|
|
1123
|
+
className="border-r-0 p-0"
|
|
1124
|
+
style={{ width: ACTIONS_W }}
|
|
1125
|
+
>
|
|
1126
|
+
<div className="flex items-center justify-end gap-0.5 pr-2">
|
|
1127
|
+
<button
|
|
1128
|
+
type="button"
|
|
1129
|
+
onClick={() => openView(row.id)}
|
|
1130
|
+
aria-label={`View ${primary.title || singular}`}
|
|
1131
|
+
title="View"
|
|
1132
|
+
className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-muted"
|
|
1133
|
+
>
|
|
1134
|
+
<Eye className="size-4 text-blue-500" />
|
|
1135
|
+
</button>
|
|
1136
|
+
<button
|
|
1137
|
+
type="button"
|
|
1138
|
+
onClick={() => openEdit(row.id)}
|
|
1139
|
+
aria-label={`Edit ${primary.title || singular}`}
|
|
1140
|
+
title="Edit"
|
|
1141
|
+
className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-muted"
|
|
1142
|
+
>
|
|
1143
|
+
<Pencil className="size-4 text-amber-500" />
|
|
1144
|
+
</button>
|
|
1145
|
+
<button
|
|
1146
|
+
type="button"
|
|
1147
|
+
onClick={() => setConfirmDeleteId(row.id)}
|
|
1148
|
+
aria-label={`Delete ${primary.title || singular}`}
|
|
1149
|
+
title="Delete"
|
|
1150
|
+
className="grid size-7 cursor-pointer place-items-center rounded-sm hover:bg-destructive/10"
|
|
1151
|
+
>
|
|
1152
|
+
<Trash2 className="size-4 text-red-500" />
|
|
1153
|
+
</button>
|
|
1154
|
+
</div>
|
|
1155
|
+
</TableCell>
|
|
1156
|
+
<TableCell aria-hidden="true" />
|
|
1157
|
+
</TableRow>
|
|
1158
|
+
);
|
|
1159
|
+
})
|
|
1160
|
+
) : (
|
|
1161
|
+
<TableRow className="hover:bg-transparent">
|
|
1162
|
+
<TableCell
|
|
1163
|
+
colSpan={visibleFields.length + 4}
|
|
1164
|
+
className="h-32 text-center text-muted-foreground"
|
|
1165
|
+
>
|
|
1166
|
+
{filter ? `No results for “${filter}”.` : "No records yet."}
|
|
1167
|
+
</TableCell>
|
|
1168
|
+
</TableRow>
|
|
1169
|
+
)}
|
|
1170
|
+
</TableBody>
|
|
1171
|
+
</Table>
|
|
1172
|
+
</div>
|
|
1173
|
+
</div>
|
|
1174
|
+
</div>
|
|
1175
|
+
|
|
1176
|
+
{/* Record detail panel */}
|
|
1177
|
+
{activeRow && (
|
|
1178
|
+
<RecordDetailPanel
|
|
1179
|
+
fields={fields}
|
|
1180
|
+
row={activeRow}
|
|
1181
|
+
singular={singular}
|
|
1182
|
+
icon={TitleIcon}
|
|
1183
|
+
getPrimary={getPrimary}
|
|
1184
|
+
readOnly={panelReadOnly}
|
|
1185
|
+
onEdit={() => setPanelReadOnly(false)}
|
|
1186
|
+
onSave={saveForm}
|
|
1187
|
+
onCancel={cancelForm}
|
|
1188
|
+
/>
|
|
1189
|
+
)}
|
|
1190
|
+
|
|
1191
|
+
{menu && (
|
|
1192
|
+
<div
|
|
1193
|
+
role="menu"
|
|
1194
|
+
aria-label="Record actions"
|
|
1195
|
+
tabIndex={-1}
|
|
1196
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
1197
|
+
style={{
|
|
1198
|
+
top: Math.min(menu.y, window.innerHeight - 140),
|
|
1199
|
+
left: Math.min(menu.x, window.innerWidth - 200),
|
|
1200
|
+
}}
|
|
1201
|
+
className="fixed z-50 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
|
1202
|
+
>
|
|
1203
|
+
<button
|
|
1204
|
+
type="button"
|
|
1205
|
+
role="menuitem"
|
|
1206
|
+
onClick={() => {
|
|
1207
|
+
setActiveId(menu.id);
|
|
1208
|
+
setMenu(null);
|
|
1209
|
+
}}
|
|
1210
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
1211
|
+
>
|
|
1212
|
+
<ArrowUpRight className="size-3.5" />
|
|
1213
|
+
Open record
|
|
1214
|
+
</button>
|
|
1215
|
+
<button
|
|
1216
|
+
type="button"
|
|
1217
|
+
role="menuitem"
|
|
1218
|
+
onClick={() => {
|
|
1219
|
+
duplicateRow(menu.id);
|
|
1220
|
+
setMenu(null);
|
|
1221
|
+
}}
|
|
1222
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
|
|
1223
|
+
>
|
|
1224
|
+
<CopyPlus className="size-3.5" />
|
|
1225
|
+
Duplicate
|
|
1226
|
+
</button>
|
|
1227
|
+
<div className="my-1 h-px bg-border" />
|
|
1228
|
+
<button
|
|
1229
|
+
type="button"
|
|
1230
|
+
role="menuitem"
|
|
1231
|
+
onClick={() => {
|
|
1232
|
+
setConfirmDeleteId(menu.id);
|
|
1233
|
+
setMenu(null);
|
|
1234
|
+
}}
|
|
1235
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-destructive hover:bg-destructive/10"
|
|
1236
|
+
>
|
|
1237
|
+
<Trash2 className="size-3.5" />
|
|
1238
|
+
Delete
|
|
1239
|
+
</button>
|
|
1240
|
+
</div>
|
|
1241
|
+
)}
|
|
1242
|
+
|
|
1243
|
+
<ConfirmDialog
|
|
1244
|
+
open={confirmDeleteId != null}
|
|
1245
|
+
title={`Delete ${singular.toLowerCase()}?`}
|
|
1246
|
+
description={
|
|
1247
|
+
<>
|
|
1248
|
+
This permanently removes{" "}
|
|
1249
|
+
<span className="font-medium text-foreground">
|
|
1250
|
+
{deleteTarget ? getPrimary(deleteTarget).title || "this record" : "this record"}
|
|
1251
|
+
</span>
|
|
1252
|
+
. This can’t be undone.
|
|
1253
|
+
</>
|
|
1254
|
+
}
|
|
1255
|
+
destructive
|
|
1256
|
+
confirmLabel="Delete"
|
|
1257
|
+
onConfirm={() => {
|
|
1258
|
+
if (confirmDeleteId != null) deleteRow(confirmDeleteId);
|
|
1259
|
+
setConfirmDeleteId(null);
|
|
1260
|
+
}}
|
|
1261
|
+
onCancel={() => setConfirmDeleteId(null)}
|
|
1262
|
+
/>
|
|
1263
|
+
|
|
1264
|
+
<ConfirmDialog
|
|
1265
|
+
open={bulkDeleteOpen}
|
|
1266
|
+
title={`Delete ${selected.size} ${
|
|
1267
|
+
selected.size === 1 ? singular.toLowerCase() : `${title.toLowerCase()}`
|
|
1268
|
+
}?`}
|
|
1269
|
+
description={
|
|
1270
|
+
<>
|
|
1271
|
+
This permanently removes the{" "}
|
|
1272
|
+
<span className="font-medium text-foreground">
|
|
1273
|
+
{selected.size} selected
|
|
1274
|
+
</span>{" "}
|
|
1275
|
+
record{selected.size === 1 ? "" : "s"}. This can’t be undone.
|
|
1276
|
+
</>
|
|
1277
|
+
}
|
|
1278
|
+
destructive
|
|
1279
|
+
confirmLabel="Delete"
|
|
1280
|
+
onConfirm={bulkDelete}
|
|
1281
|
+
onCancel={() => setBulkDeleteOpen(false)}
|
|
1282
|
+
/>
|
|
1283
|
+
</div>
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
interface DetailPanelProps<T extends { id: RowId }> {
|
|
1288
|
+
fields: RecordField<T>[];
|
|
1289
|
+
/** Initial values; the panel edits a local buffered copy until Save. */
|
|
1290
|
+
row: T;
|
|
1291
|
+
singular: string;
|
|
1292
|
+
icon?: IconType;
|
|
1293
|
+
getPrimary: (row: T) => { title: string; initials: string; subtitle?: string };
|
|
1294
|
+
/** Read-only (View) vs editable (Edit / Add). */
|
|
1295
|
+
readOnly?: boolean;
|
|
1296
|
+
/** Switch a read-only panel into edit mode. */
|
|
1297
|
+
onEdit?: () => void;
|
|
1298
|
+
/** Commit the buffered draft to the table. */
|
|
1299
|
+
onSave: (row: T) => void;
|
|
1300
|
+
/** Discard the draft (and drop the row if it was never saved). */
|
|
1301
|
+
onCancel: () => void;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function RecordDetailPanel<T extends { id: RowId }>({
|
|
1305
|
+
fields,
|
|
1306
|
+
row,
|
|
1307
|
+
singular,
|
|
1308
|
+
icon: TitleIcon,
|
|
1309
|
+
getPrimary,
|
|
1310
|
+
readOnly = false,
|
|
1311
|
+
onEdit,
|
|
1312
|
+
onSave,
|
|
1313
|
+
onCancel,
|
|
1314
|
+
}: DetailPanelProps<T>) {
|
|
1315
|
+
const [draft, setDraft] = React.useState<T>(row);
|
|
1316
|
+
// Reset the buffered form when a different record is opened.
|
|
1317
|
+
React.useEffect(() => {
|
|
1318
|
+
setDraft(row);
|
|
1319
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1320
|
+
}, [row.id]);
|
|
1321
|
+
|
|
1322
|
+
const primary = getPrimary(draft);
|
|
1323
|
+
const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
|
|
1324
|
+
|
|
1325
|
+
// Required-field validation: keys with an error get a red border on Save.
|
|
1326
|
+
const [errors, setErrors] = React.useState<Set<string>>(new Set());
|
|
1327
|
+
React.useEffect(() => {
|
|
1328
|
+
setErrors(new Set());
|
|
1329
|
+
}, [row.id]);
|
|
1330
|
+
|
|
1331
|
+
const setField = (key: keyof T, value: string) => {
|
|
1332
|
+
setDraft((d) => ({ ...d, [key]: value }));
|
|
1333
|
+
setErrors((prev) => {
|
|
1334
|
+
if (!prev.has(key as string)) return prev;
|
|
1335
|
+
const next = new Set(prev);
|
|
1336
|
+
next.delete(key as string);
|
|
1337
|
+
return next;
|
|
1338
|
+
});
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// Play the exit animation, then run the actual close/save when it ends.
|
|
1342
|
+
const [closing, setClosing] = React.useState(false);
|
|
1343
|
+
const pending = React.useRef<(() => void) | null>(null);
|
|
1344
|
+
const requestClose = (action: () => void) => {
|
|
1345
|
+
pending.current = action;
|
|
1346
|
+
setClosing(true);
|
|
1347
|
+
};
|
|
1348
|
+
const handleSave = () => {
|
|
1349
|
+
const missing = fields.filter(
|
|
1350
|
+
(f) =>
|
|
1351
|
+
f.required &&
|
|
1352
|
+
f.editable &&
|
|
1353
|
+
!f.render &&
|
|
1354
|
+
String(draft[f.key as keyof T] ?? "").trim() === "",
|
|
1355
|
+
);
|
|
1356
|
+
if (missing.length > 0) {
|
|
1357
|
+
setErrors(new Set(missing.map((f) => f.key)));
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
requestClose(() => onSave(draft));
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
return (
|
|
1364
|
+
<>
|
|
1365
|
+
{/* Dimmed backdrop — click to close. */}
|
|
1366
|
+
<div
|
|
1367
|
+
className={cn(
|
|
1368
|
+
"fixed inset-0 z-[55] bg-foreground/25",
|
|
1369
|
+
closing ? "vui-overlay-out" : "vui-overlay-in",
|
|
1370
|
+
)}
|
|
1371
|
+
onClick={() => requestClose(onCancel)}
|
|
1372
|
+
aria-hidden="true"
|
|
1373
|
+
/>
|
|
1374
|
+
<aside
|
|
1375
|
+
aria-label={`${singular} form`}
|
|
1376
|
+
className={cn(
|
|
1377
|
+
"fixed inset-y-0 right-0 z-[60] flex w-full flex-col border-l border-border bg-background shadow-xl sm:w-[380px] sm:max-w-[90vw]",
|
|
1378
|
+
closing ? "vui-panel-out" : "vui-panel-in",
|
|
1379
|
+
)}
|
|
1380
|
+
onAnimationEnd={(e) => {
|
|
1381
|
+
if (e.target === e.currentTarget && closing && pending.current) {
|
|
1382
|
+
const run = pending.current;
|
|
1383
|
+
pending.current = null;
|
|
1384
|
+
run();
|
|
1385
|
+
}
|
|
1386
|
+
}}
|
|
1387
|
+
>
|
|
1388
|
+
{/* Header — icon + title (placeholder when new); matches the page header. */}
|
|
1389
|
+
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
|
|
1390
|
+
<span className="flex size-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
|
1391
|
+
<HeaderIcon className="size-3.5" />
|
|
1392
|
+
</span>
|
|
1393
|
+
<span
|
|
1394
|
+
className={cn(
|
|
1395
|
+
"truncate font-semibold",
|
|
1396
|
+
!primary.title && "text-muted-foreground",
|
|
1397
|
+
)}
|
|
1398
|
+
>
|
|
1399
|
+
{primary.title || `New ${singular}`}
|
|
1400
|
+
</span>
|
|
1401
|
+
<Button
|
|
1402
|
+
variant="ghost"
|
|
1403
|
+
size="icon"
|
|
1404
|
+
onClick={() => requestClose(onCancel)}
|
|
1405
|
+
aria-label="Close"
|
|
1406
|
+
className="ml-auto"
|
|
1407
|
+
>
|
|
1408
|
+
<X className="size-4" />
|
|
1409
|
+
</Button>
|
|
1410
|
+
</div>
|
|
1411
|
+
|
|
1412
|
+
{/* Body — one bordered section per field group. */}
|
|
1413
|
+
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
|
1414
|
+
{GROUP_ORDER.map((group) => {
|
|
1415
|
+
const groupFields = fields.filter(
|
|
1416
|
+
(f) => (f.group ?? "General") === group,
|
|
1417
|
+
);
|
|
1418
|
+
if (groupFields.length === 0) return null;
|
|
1419
|
+
return (
|
|
1420
|
+
<section
|
|
1421
|
+
key={group}
|
|
1422
|
+
className="overflow-hidden rounded-lg border border-border"
|
|
1423
|
+
>
|
|
1424
|
+
<h3 className="border-b border-border bg-muted/40 px-3 py-2 font-medium text-muted-foreground">
|
|
1425
|
+
{group}
|
|
1426
|
+
</h3>
|
|
1427
|
+
<dl className="divide-y divide-border">
|
|
1428
|
+
{groupFields.map((f) => (
|
|
1429
|
+
<div
|
|
1430
|
+
key={f.key}
|
|
1431
|
+
className="flex items-start gap-3 px-3 py-3 leading-relaxed"
|
|
1432
|
+
>
|
|
1433
|
+
<dt className="flex w-28 shrink-0 items-center gap-1.5 pt-1.5 text-muted-foreground">
|
|
1434
|
+
{f.icon && <f.icon className="size-3.5" />}
|
|
1435
|
+
{f.label}
|
|
1436
|
+
{f.required && <RequiredMark />}
|
|
1437
|
+
</dt>
|
|
1438
|
+
<dd className="min-w-0 flex-1">
|
|
1439
|
+
{f.render ? (
|
|
1440
|
+
<div className="pt-0.5">{f.render(draft)}</div>
|
|
1441
|
+
) : !readOnly && f.editable ? (
|
|
1442
|
+
<textarea
|
|
1443
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1444
|
+
onChange={(e) =>
|
|
1445
|
+
setField(f.key as keyof T, e.target.value)
|
|
1446
|
+
}
|
|
1447
|
+
aria-label={f.label}
|
|
1448
|
+
aria-invalid={errors.has(f.key) || undefined}
|
|
1449
|
+
placeholder={`Add ${f.label.toLowerCase()}`}
|
|
1450
|
+
rows={1}
|
|
1451
|
+
// field-sizing grows the box to fit long/wrapped text
|
|
1452
|
+
className={cn(
|
|
1453
|
+
"w-full resize-none rounded-sm border bg-background px-2 py-1.5 outline-none [field-sizing:content] placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-inset",
|
|
1454
|
+
errors.has(f.key)
|
|
1455
|
+
? "border-destructive focus-visible:ring-destructive"
|
|
1456
|
+
: "border-input focus-visible:ring-ring",
|
|
1457
|
+
)}
|
|
1458
|
+
/>
|
|
1459
|
+
) : (
|
|
1460
|
+
<span className="block whitespace-pre-wrap break-words px-2 pt-1.5">
|
|
1461
|
+
{String(draft[f.key as keyof T] ?? "") || (
|
|
1462
|
+
<span className="text-muted-foreground">—</span>
|
|
1463
|
+
)}
|
|
1464
|
+
</span>
|
|
1465
|
+
)}
|
|
1466
|
+
</dd>
|
|
1467
|
+
</div>
|
|
1468
|
+
))}
|
|
1469
|
+
</dl>
|
|
1470
|
+
</section>
|
|
1471
|
+
);
|
|
1472
|
+
})}
|
|
1473
|
+
</div>
|
|
1474
|
+
|
|
1475
|
+
{/* Footer — View shows Close/Edit; Edit shows Cancel/Save. */}
|
|
1476
|
+
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
|
|
1477
|
+
{readOnly ? (
|
|
1478
|
+
<>
|
|
1479
|
+
<Button onClick={() => requestClose(onCancel)}>
|
|
1480
|
+
<X className="size-4" />
|
|
1481
|
+
Close
|
|
1482
|
+
</Button>
|
|
1483
|
+
{onEdit && (
|
|
1484
|
+
<Button variant="primary" onClick={onEdit}>
|
|
1485
|
+
<Pencil className="size-4" />
|
|
1486
|
+
Edit
|
|
1487
|
+
</Button>
|
|
1488
|
+
)}
|
|
1489
|
+
</>
|
|
1490
|
+
) : (
|
|
1491
|
+
<>
|
|
1492
|
+
<Button onClick={() => requestClose(onCancel)}>
|
|
1493
|
+
<X className="size-4" />
|
|
1494
|
+
Cancel
|
|
1495
|
+
</Button>
|
|
1496
|
+
<Button variant="primary" onClick={handleSave}>
|
|
1497
|
+
<Check className="size-4" />
|
|
1498
|
+
Save
|
|
1499
|
+
</Button>
|
|
1500
|
+
</>
|
|
1501
|
+
)}
|
|
1502
|
+
</div>
|
|
1503
|
+
</aside>
|
|
1504
|
+
</>
|
|
1505
|
+
);
|
|
1506
|
+
}
|