@svadmin/ui 0.57.0 → 0.59.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.
Files changed (40) hide show
  1. package/dist/components/AICommandBar.svelte +34 -10
  2. package/dist/components/AdminApp.svelte +16 -5
  3. package/dist/components/AutoForm.svelte +4 -4
  4. package/dist/components/AutoTable.svelte +884 -217
  5. package/dist/components/AutoTable.svelte.d.ts +1 -1
  6. package/dist/components/CommandPalette.svelte +40 -17
  7. package/dist/components/ConfirmDialog.svelte +4 -3
  8. package/dist/components/ConfirmDialog.svelte.d.ts +1 -0
  9. package/dist/components/CopilotPanel.svelte +3 -3
  10. package/dist/components/DraggableHeader.svelte +15 -36
  11. package/dist/components/DraggableHeader.svelte.d.ts +13 -4
  12. package/dist/components/EditPage.svelte +4 -4
  13. package/dist/components/EmptyState.svelte +4 -2
  14. package/dist/components/Layout.svelte +8 -1
  15. package/dist/components/ListPage.svelte +4 -12
  16. package/dist/components/ListPage.svelte.d.ts +1 -1
  17. package/dist/components/RecordDetailDrawer.svelte +121 -0
  18. package/dist/components/RecordDetailDrawer.svelte.d.ts +9 -0
  19. package/dist/components/ResourceAccessGuard.svelte +68 -0
  20. package/dist/components/ResourceAccessGuard.svelte.d.ts +12 -0
  21. package/dist/components/ResourceOperationsPage.svelte +8 -3
  22. package/dist/components/ShowPage.svelte +5 -6
  23. package/dist/components/StatsCard.svelte +5 -3
  24. package/dist/components/TaskQueueDrawer.svelte +10 -1
  25. package/dist/components/TenantSwitcher.svelte +1 -0
  26. package/dist/components/Toast.svelte +35 -1
  27. package/dist/components/UndoableNotification.svelte +12 -6
  28. package/dist/components/UndoableNotification.svelte.d.ts +2 -0
  29. package/dist/components/buttons/CloneButton.svelte +9 -2
  30. package/dist/components/buttons/CreateButton.svelte +9 -2
  31. package/dist/components/buttons/DeleteButton.svelte +9 -2
  32. package/dist/components/buttons/EditButton.svelte +9 -2
  33. package/dist/components/buttons/ListButton.svelte +1 -1
  34. package/dist/components/buttons/ShowButton.svelte +9 -2
  35. package/dist/components/saved-list-views.d.ts +35 -0
  36. package/dist/components/saved-list-views.js +168 -0
  37. package/dist/components/ui/input/input.svelte +2 -2
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.js +2 -0
  40. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy, untrack } from 'svelte';
3
+ import { SvelteMap } from 'svelte/reactivity';
3
4
  import { cn } from '../utils.js';
4
5
  import {
5
6
  createTable,
@@ -16,25 +17,24 @@
16
17
  type ColumnVisibilityState,
17
18
  type ExpandedState,
18
19
  } from '@tanstack/svelte-table';
19
- import type { Column, Header, TableFeatures } from '@tanstack/table-core';
20
+ import type { Column, Header, Row, TableFeatures } from '@tanstack/table-core';
20
21
  import { createAtom, useSelector } from '@tanstack/svelte-store';
21
22
  import {
22
23
  column_getCanSort,
23
24
  column_getIsSorted,
24
25
  column_toggleSorting,
25
- column_toggleVisibility,
26
26
  header_getSize,
27
+ table_getHeaderGroups,
27
28
  table_getRowModel,
28
29
  table_getAllLeafColumns,
29
30
  table_getIsAllRowsSelected,
30
31
  table_toggleAllRowsSelected,
31
- row_getIsSelected,
32
32
  row_toggleSelected,
33
33
  row_getVisibleCells,
34
34
  cell_getValue,
35
35
  } from '@tanstack/table-core/static-functions';
36
36
 
37
- import { captureAdminContext, useList, useDelete, useDeleteMany, getResource, notifyWithProvider } from '@svadmin/core';
37
+ import { captureAdminContext, DeleteManyPartialError, getAdminOptions, useList, useDelete, useDeleteMany, getResource, useNavigation, useParsed } from '@svadmin/core';
38
38
  import type {
39
39
  BaseRecord,
40
40
  FieldDefinition,
@@ -46,6 +46,24 @@
46
46
  import { useCan } from '@svadmin/core';
47
47
  import { readURLState, writeURLState } from '@svadmin/core';
48
48
  import { useTranslation } from '@svadmin/core/i18n';
49
+ import {
50
+ activeSavedListViewStorageKey,
51
+ canMigrateLegacyListPreferences,
52
+ cloneSavedListViewState,
53
+ columnOrderStorageKey,
54
+ columnVisibilityStorageKey,
55
+ legacyActiveSavedListViewStorageKey,
56
+ legacyColumnOrderStorageKey,
57
+ legacyColumnVisibilityStorageKey,
58
+ legacySavedListViewsStorageKey,
59
+ listPreferenceScopeId,
60
+ readSavedListViews,
61
+ savedListViewsStorageKey,
62
+ serializeSavedListViews,
63
+ type ListPreferenceScope,
64
+ type SavedListView,
65
+ type SavedListViewState,
66
+ } from './saved-list-views.js';
49
67
 
50
68
  import { fade } from 'svelte/transition';
51
69
  import { Button } from './ui/button/index.js';
@@ -58,15 +76,19 @@
58
76
  import * as DropdownMenu from './ui/dropdown-menu/index.js';
59
77
  import * as PaginationUI from './ui/pagination/index.js';
60
78
  import * as ContextMenu from './ui/context-menu/index.js';
79
+ import * as Select from './ui/select/index.js';
61
80
  import {
62
81
  Plus, Pencil, Trash2,
63
82
  Search, Download, ChevronDown, ChevronUp, SlidersHorizontal, Filter as FilterIcon,
64
- Eye, Copy, RefreshCw, Rows, X
83
+ Eye, Copy, RefreshCw, Rows, X, Bookmark, Check
65
84
  } from '@lucide/svelte';
66
85
  import ConfirmDialog from './ConfirmDialog.svelte';
86
+ import RecordDetailDrawer from './RecordDetailDrawer.svelte';
87
+ import CanAccess from './CanAccess.svelte';
67
88
  import TooltipButton from './TooltipButton.svelte';
68
89
  import InlineEdit from './InlineEdit.svelte';
69
90
  import DraggableHeader from './DraggableHeader.svelte';
91
+ import DataState from './content/DataState.svelte';
70
92
  import type { Snippet } from 'svelte';
71
93
 
72
94
  const i18n = useTranslation();
@@ -98,7 +120,7 @@
98
120
  /** Externally controlled sorters */
99
121
  sorters?: Sort[];
100
122
  /** Custom batch actions to render when rows are selected */
101
- batchActions?: Snippet<[{ selectedIds: string[] }]>;
123
+ batchActions?: Snippet<[{ selectedIds: (string | number)[] }]>;
102
124
  }
103
125
 
104
126
  let {
@@ -124,30 +146,203 @@
124
146
  let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
125
147
  const currentDensity = $derived(densityOverride ?? density);
126
148
  const adminContext = captureAdminContext();
149
+ const adminOptions = getAdminOptions();
150
+ const mutationMode = $derived(adminOptions.mutationMode ?? 'pessimistic');
151
+ const parsed = useParsed();
152
+ const navigation = useNavigation();
127
153
 
128
154
  const resource = $derived(getResource(resourceName));
129
155
  const primaryKey = $derived(resource.primaryKey ?? 'id');
156
+ const listPreferenceScope = $derived.by(() => {
157
+ const matcher = adminContext.queryKeyMatcher(resourceName);
158
+ return {
159
+ resourceName,
160
+ providerName: matcher.provider ?? 'default',
161
+ tenantIdentity: matcher.tenant,
162
+ };
163
+ });
164
+
165
+ function readLocalPreference(key: string): string | null {
166
+ if (typeof window === 'undefined') return null;
167
+ try {
168
+ return localStorage.getItem(key);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function readScopedPreference(scope: ListPreferenceScope, scopedKey: string, legacyKey: string): string | null {
175
+ const scoped = readLocalPreference(scopedKey);
176
+ if (scoped !== null || !canMigrateLegacyListPreferences(scope)) return scoped;
177
+ const legacy = readLocalPreference(legacyKey);
178
+ if (legacy === null || typeof window === 'undefined') return null;
179
+ try {
180
+ localStorage.setItem(scopedKey, legacy);
181
+ localStorage.removeItem(legacyKey);
182
+ } catch { /* keep the legacy value readable when migration cannot persist */ }
183
+ return legacy;
184
+ }
130
185
 
131
186
  // ─── URL state + server-side state ────────────────────────────
132
187
  const urlState = readURLState(adminContext);
133
188
 
189
+ const savedViewColumnIds = new Set([
190
+ ...untrack(() => resource.fields.map((field) => field.key)),
191
+ '_select', '_expand', '_actions',
192
+ ]);
193
+ const storedSavedViews = untrack(() => readSavedListViews(
194
+ readScopedPreference(
195
+ listPreferenceScope,
196
+ savedListViewsStorageKey(listPreferenceScope),
197
+ legacySavedListViewsStorageKey(resourceName),
198
+ ),
199
+ savedViewColumnIds,
200
+ ));
201
+ let savedViews = $state<SavedListView[]>(storedSavedViews);
202
+ const storedActiveSavedViewId = untrack(() => {
203
+ const candidate = readScopedPreference(
204
+ listPreferenceScope,
205
+ activeSavedListViewStorageKey(listPreferenceScope),
206
+ legacyActiveSavedListViewStorageKey(resourceName),
207
+ );
208
+ return storedSavedViews.some((view) => view.id === candidate) ? candidate ?? undefined : undefined;
209
+ });
210
+ let savedViewName = $state('');
211
+ let savedViewsOpen = $state(false);
212
+
213
+ const hasExplicitURLState = untrack(() => (
214
+ urlState.page !== undefined
215
+ || urlState.pageSize !== undefined
216
+ || urlState.sortField !== undefined
217
+ || urlState.search !== undefined
218
+ || urlState.filters !== undefined
219
+ ));
220
+ let activeSavedViewId = $state<string | undefined>(hasExplicitURLState ? undefined : storedActiveSavedViewId);
221
+ const activeSavedViewName = $derived(savedViews.find((view) => view.id === activeSavedViewId)?.name);
222
+ const initialSavedView = untrack(() => (
223
+ !hasExplicitURLState && storedActiveSavedViewId
224
+ ? storedSavedViews.find((view) => view.id === storedActiveSavedViewId)
225
+ : undefined
226
+ ));
227
+ const initialViewState = initialSavedView?.state;
228
+
229
+ // The table can stay mounted while the surrounding tenant/provider changes.
230
+ // Keep persistence effects paused until the new scope has been loaded.
231
+ let loadedPreferenceScopeId = $state(untrack(() => listPreferenceScopeId(listPreferenceScope)));
232
+
233
+ function readColumnVisibilityPreference(scope: ListPreferenceScope): ColumnVisibilityState {
234
+ const stored = readScopedPreference(
235
+ scope,
236
+ columnVisibilityStorageKey(scope),
237
+ legacyColumnVisibilityStorageKey(resourceName),
238
+ );
239
+ if (stored) {
240
+ try {
241
+ const parsed: unknown = JSON.parse(stored);
242
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
243
+ const visibility: ColumnVisibilityState = {};
244
+ for (const [columnId, visible] of Object.entries(parsed as Record<string, unknown>)) {
245
+ if (savedViewColumnIds.has(columnId) && typeof visible === 'boolean') {
246
+ visibility[columnId] = visible;
247
+ }
248
+ }
249
+ return visibility;
250
+ }
251
+ } catch { /* fall through to resource defaults */ }
252
+ }
253
+
254
+ const visibility: ColumnVisibilityState = {};
255
+ for (const field of resource.fields) {
256
+ if (field.showInList === false) visibility[field.key] = false;
257
+ }
258
+ return visibility;
259
+ }
260
+
261
+ function readColumnOrderPreference(scope: ListPreferenceScope): string[] {
262
+ const stored = readScopedPreference(
263
+ scope,
264
+ columnOrderStorageKey(scope),
265
+ legacyColumnOrderStorageKey(resourceName),
266
+ );
267
+ if (!stored) return [];
268
+ try {
269
+ const parsed: unknown = JSON.parse(stored);
270
+ return Array.isArray(parsed)
271
+ ? [...new Set(parsed.filter((columnId): columnId is string => (
272
+ typeof columnId === 'string' && savedViewColumnIds.has(columnId)
273
+ )))]
274
+ : [];
275
+ } catch {
276
+ return [];
277
+ }
278
+ }
279
+
280
+ function readScopedSavedViewPreferences(scope: ListPreferenceScope): {
281
+ savedViews: SavedListView[];
282
+ activeSavedViewId?: string;
283
+ activeSavedView?: SavedListView;
284
+ columnVisibility: ColumnVisibilityState;
285
+ columnOrder: string[];
286
+ } {
287
+ const scopedSavedViews = readSavedListViews(
288
+ readScopedPreference(
289
+ scope,
290
+ savedListViewsStorageKey(scope),
291
+ legacySavedListViewsStorageKey(resourceName),
292
+ ),
293
+ savedViewColumnIds,
294
+ );
295
+ const candidate = readScopedPreference(
296
+ scope,
297
+ activeSavedListViewStorageKey(scope),
298
+ legacyActiveSavedListViewStorageKey(resourceName),
299
+ );
300
+ const activeSavedView = scopedSavedViews.find((view) => view.id === candidate);
301
+ return {
302
+ savedViews: scopedSavedViews,
303
+ activeSavedViewId: activeSavedView?.id,
304
+ activeSavedView,
305
+ columnVisibility: readColumnVisibilityPreference(scope),
306
+ columnOrder: readColumnOrderPreference(scope),
307
+ };
308
+ }
309
+
134
310
  // Snapshot resource values for initial state (untrack to avoid reactive tracking)
135
- const storedPageSize = typeof window !== 'undefined' ? parseInt(localStorage.getItem('svadmin-default-page-size') ?? '', 10) : NaN;
311
+ const storedPageSize = parseInt(readLocalPreference('svadmin-default-page-size') ?? '', 10);
136
312
  const initPageSize = untrack(() => resource.pageSize ?? (isNaN(storedPageSize) ? 10 : storedPageSize));
137
313
  const initDefaultSort = untrack(() => resource.defaultSort);
138
314
 
139
315
  let pagination = $state<PaginationState>(untrack(() => externalPagination ?? {
140
- current: urlState.page ?? 1,
141
- pageSize: urlState.pageSize ?? initPageSize,
316
+ current: urlState.page ?? initialViewState?.pagination.current ?? 1,
317
+ pageSize: urlState.pageSize ?? initialViewState?.pagination.pageSize ?? initPageSize,
142
318
  }));
143
319
  let sorters = $state<Sort[]>(untrack(() =>
144
320
  externalSorters ??
145
321
  (urlState.sortField
146
322
  ? [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }]
147
- : initDefaultSort ? [initDefaultSort] : []))
323
+ : initialViewState?.sorters ?? (initDefaultSort ? [initDefaultSort] : [])))
148
324
  );
149
- let filters = $state<Filter[]>([]);
150
- let searchText = $state(urlState.search ?? '');
325
+ const initialURLFilters = untrack(() => urlState.filters ?? initialViewState?.filters ?? []);
326
+ const editableFilterKeys = new Set(untrack(() => resource.fields.filter((field) => field.filterable).map((field) => field.key)));
327
+ const editableFilterCounts: Record<string, number> = {};
328
+ for (const filter of initialURLFilters) {
329
+ if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
330
+ editableFilterCounts[filter.field] = (editableFilterCounts[filter.field] ?? 0) + 1;
331
+ }
332
+ }
333
+ const initialFilterValues: Record<string, string> = {};
334
+ for (const filter of initialURLFilters) {
335
+ if (
336
+ 'field' in filter
337
+ && filter.operator === 'contains'
338
+ && typeof filter.value === 'string'
339
+ && editableFilterCounts[filter.field] === 1
340
+ ) {
341
+ initialFilterValues[filter.field] = filter.value;
342
+ }
343
+ }
344
+ let filters = $state<Filter[]>(initialURLFilters);
345
+ let searchText = $state(urlState.search ?? initialViewState?.search ?? '');
151
346
  let appliedSearchText = $state(untrack(() => searchText));
152
347
  let searchDebounceTimer: ReturnType<typeof setTimeout> | undefined;
153
348
 
@@ -156,6 +351,7 @@
156
351
  if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
157
352
  searchDebounceTimer = setTimeout(() => {
158
353
  appliedSearchText = searchText;
354
+ markSavedViewDirty();
159
355
  pagination = { ...pagination, current: 1 };
160
356
  searchDebounceTimer = undefined;
161
357
  }, 300);
@@ -178,24 +374,23 @@
178
374
  }
179
375
  });
180
376
 
181
- // URL sync
182
- $effect(() => {
183
- writeURLState({
184
- page: pagination.current,
185
- pageSize: pagination.pageSize,
186
- sortField: sorters[0]?.field,
187
- sortOrder: sorters[0]?.order,
188
- search: appliedSearchText || undefined,
189
- }, adminContext);
190
- });
191
-
192
377
  // ─── Build active filters with search ─────────────────────────
193
378
  const searchableFields = $derived(resource.fields.filter(f => f.searchable));
194
379
  const filterableFields = $derived(resource.fields.filter(f => f.filterable));
195
- let filterValues = $state<Record<string, string>>({});
196
- const activeFilterCount = $derived(Object.values(filterValues).filter(v => v.trim()).length);
380
+ let filterValues = $state<Record<string, string>>(initialFilterValues);
381
+ const locationFilters = $derived<Filter[]>(filters);
382
+ const activeFilterCount = $derived(locationFilters.length);
383
+ const activeFilterItems = $derived.by(() => {
384
+ const labels = new Map(resource.fields.map((field) => [field.key, field.label]));
385
+ return locationFilters.map((filter, index) => {
386
+ if ('field' in filter) {
387
+ return { index, label: `${labels.get(filter.field) ?? filter.field}: ${String(filter.value)}` };
388
+ }
389
+ return { index, label: filter.operator };
390
+ });
391
+ });
197
392
  const activeFilters = $derived.by(() => {
198
- const result: Filter[] = [...filters];
393
+ const result: Filter[] = [...locationFilters];
199
394
  if (appliedSearchText.trim() && searchableFields.length > 0) {
200
395
  if (searchableFields.length === 1) {
201
396
  result.push({ field: searchableFields[0].key, operator: 'contains', value: appliedSearchText });
@@ -211,17 +406,61 @@
211
406
  result.push(searchFilter);
212
407
  }
213
408
  }
214
- for (const [key, value] of Object.entries(filterValues)) {
215
- if (value.trim()) {
216
- result.push({ field: key, operator: 'contains', value: value.trim() });
217
- }
218
- }
219
409
  return result;
220
410
  });
221
411
 
222
- function removeFilterKey(key: string): void {
223
- const { [key]: _removed, ...rest } = filterValues;
224
- filterValues = rest;
412
+ // URL sync
413
+ $effect(() => {
414
+ writeURLState({
415
+ page: pagination.current,
416
+ pageSize: pagination.pageSize,
417
+ sortField: sorters[0]?.field,
418
+ sortOrder: sorters[0]?.order,
419
+ search: appliedSearchText || undefined,
420
+ filters: locationFilters,
421
+ }, adminContext);
422
+ });
423
+
424
+ function clearFilters(): void {
425
+ markSavedViewDirty();
426
+ filters = [];
427
+ filterValues = {};
428
+ pagination = { ...pagination, current: 1 };
429
+ }
430
+
431
+ function setFilterValue(field: string, value: string): void {
432
+ markSavedViewDirty();
433
+ const trimmedValue = value.trim();
434
+ const nextFilter = { field, operator: 'contains' as const, value: trimmedValue };
435
+ let replaced = false;
436
+ filters = filters.flatMap((filter) => {
437
+ if (!('field' in filter) || filter.field !== field || filter.operator !== 'contains') return [filter];
438
+ if (!trimmedValue || replaced) return [];
439
+ replaced = true;
440
+ return [nextFilter];
441
+ });
442
+ if (trimmedValue && !replaced) filters = [...filters, nextFilter];
443
+ filterValues[field] = value;
444
+ pagination = { ...pagination, current: 1 };
445
+ }
446
+
447
+ function removeActiveFilter(index: number): void {
448
+ markSavedViewDirty();
449
+ const filter = locationFilters[index];
450
+ if (!filter) return;
451
+ const nextFilters = filters.filter((_, filterIndex) => filterIndex !== index);
452
+ filters = nextFilters;
453
+ if ('field' in filter && filter.operator === 'contains') {
454
+ const remaining = nextFilters.filter((candidate) => (
455
+ 'field' in candidate
456
+ && candidate.field === filter.field
457
+ && candidate.operator === 'contains'
458
+ && typeof candidate.value === 'string'
459
+ ));
460
+ filterValues[filter.field] = remaining.length === 1 && 'field' in remaining[0]
461
+ ? String(remaining[0].value)
462
+ : '';
463
+ }
225
464
  pagination = { ...pagination, current: 1 };
226
465
  }
227
466
 
@@ -260,12 +499,6 @@
260
499
  })));
261
500
  const queryFilters = $derived<Filter[]>(activeFilters.map(cloneFilter));
262
501
 
263
- $effect(() => {
264
- if (Object.values(filterValues).some(v => v.trim())) {
265
- pagination = { ...pagination, current: 1 };
266
- }
267
- });
268
-
269
502
  // ─── Data fetching ────────────────────────────────────────────
270
503
  const listResult = useList({
271
504
  get resource() { return resourceName; },
@@ -276,47 +509,48 @@
276
509
  const query = listResult;
277
510
  const deleteResult = useDelete({ get resource() { return resourceName; } });
278
511
  const deleteMutation = deleteResult.mutation;
279
- const deleteManyResult = useDeleteMany({ get resource() { return resourceName; } });
512
+ let batchOperationCount = $state(0);
513
+ const deleteManyResult = useDeleteMany({
514
+ get resource() { return resourceName; },
515
+ successNotification: () => ({
516
+ message: i18n.t('common.batchDeleteSuccess', { count: batchOperationCount }),
517
+ }),
518
+ errorNotification: (error) => ({
519
+ type: 'error',
520
+ message: error instanceof DeleteManyPartialError
521
+ ? i18n.t('common.batchDeletePartialFail', { failed: error.failedIds.length, total: batchOperationCount })
522
+ : i18n.t('common.batchDeleteFailed', { count: batchOperationCount }),
523
+ }),
524
+ });
280
525
  const deleteManyMutation = deleteManyResult.mutation;
281
526
 
282
527
  // ─── Permissions ──────────────────────────────────────────────
283
528
  const acEnabled = $derived(!!adminContext.accessControlProvider);
284
529
  const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
285
- const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
286
- const canShowPerm = useCan(() => ({ resource: resourceName, action: 'show', queryOptions: { enabled: acEnabled } }));
287
- const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
288
530
  const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
289
- const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
290
- const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
291
- const canShow = $derived(canShowPerm.allowed && resource.canShow !== false);
292
- const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
531
+ const canCreate = $derived(resource.canCreate !== false && (!acEnabled || canCreatePerm.allowed));
532
+ const canEdit = $derived(resource.canEdit !== false);
533
+ const canShow = $derived(resource.canShow !== false);
534
+ const canDelete = $derived(resource.canDelete !== false);
293
535
  const canExport = $derived(canExportPerm.allowed);
294
536
 
295
537
  // ─── TanStack Table state ────────────────────────────────────
296
538
  const initialSorting = untrack<SortingState>(() =>
297
539
  sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
298
540
  );
299
- const initialColumnVisibility = (() => {
300
- // Try to restore from localStorage first
301
- const storageKey = `svadmin-columns-${resourceName}`;
302
- if (typeof window !== 'undefined') {
303
- try {
304
- const stored = localStorage.getItem(storageKey);
305
- if (stored) return JSON.parse(stored);
306
- } catch { /* ignore */ }
307
- }
308
- // Fall back to resource field definitions
309
- const vis: ColumnVisibilityState = {};
310
- for (const f of resource.fields) {
311
- if (f.showInList === false) vis[f.key] = false;
312
- }
313
- return vis;
314
- })();
541
+ const initialColumnVisibility = untrack(() => (
542
+ initialViewState?.columnVisibility ?? readColumnVisibilityPreference(listPreferenceScope)
543
+ ));
315
544
  const sortingAtom = createAtom(initialSorting);
316
545
  const columnVisibilityAtom = createAtom(initialColumnVisibility);
317
546
  const rowSelectionAtom = createAtom({} as RowSelectionState);
318
547
  const expandedAtom = createAtom({} as ExpandedState);
319
- const columnOrderAtom = createAtom([] as string[]);
548
+ const initialColumnOrder = untrack(() => (
549
+ initialViewState?.columnOrder?.length
550
+ ? initialViewState.columnOrder
551
+ : readColumnOrderPreference(listPreferenceScope)
552
+ ));
553
+ const columnOrderAtom = createAtom(initialColumnOrder);
320
554
  const features = tableFeatures({
321
555
  columnOrderingFeature,
322
556
  columnSizingFeature,
@@ -331,12 +565,36 @@
331
565
  const tableRowSelection = useSelector(rowSelectionAtom);
332
566
  const tableExpanded = useSelector(expandedAtom);
333
567
  const tableColumnOrder = useSelector(columnOrderAtom);
568
+ const selectedIdValueByKey = new SvelteMap<string, string | number>();
569
+
570
+ function preferenceScopeIsLoaded(): boolean {
571
+ return loadedPreferenceScopeId === listPreferenceScopeId(listPreferenceScope);
572
+ }
334
573
 
335
574
  function rowIsExpanded(rowId: string): boolean {
336
575
  const expanded = tableExpanded.current;
337
576
  return expanded === true || expanded[rowId] === true;
338
577
  }
339
578
 
579
+ function rowIsSelected(rowId: string): boolean {
580
+ return tableRowSelection.current[rowId] === true;
581
+ }
582
+
583
+ function rowIdValue(row: Row<TableFeatures, BaseRecord>): string | number {
584
+ const value = row.original[primaryKey];
585
+ return typeof value === 'string' || typeof value === 'number' ? value : row.id;
586
+ }
587
+
588
+ function toggleRowSelection(row: Row<TableFeatures, BaseRecord>): void {
589
+ const wasSelected = rowIsSelected(row.id);
590
+ if (!wasSelected) {
591
+ selectedIdValueByKey.set(row.id, rowIdValue(row));
592
+ } else if (wasSelected) {
593
+ selectedIdValueByKey.delete(row.id);
594
+ }
595
+ row_toggleSelected(row);
596
+ }
597
+
340
598
  $effect(() => {
341
599
  const nextSorters: Sort[] = tableSorting.current.map((sorter) => ({
342
600
  field: sorter.id,
@@ -350,7 +608,8 @@
350
608
 
351
609
  // Persist column visibility to localStorage
352
610
  $effect(() => {
353
- const storageKey = `svadmin-columns-${resourceName}`;
611
+ if (!preferenceScopeIsLoaded()) return;
612
+ const storageKey = columnVisibilityStorageKey(listPreferenceScope);
354
613
  if (typeof window !== 'undefined') {
355
614
  try {
356
615
  localStorage.setItem(storageKey, JSON.stringify(tableColumnVisibility.current));
@@ -358,6 +617,176 @@
358
617
  }
359
618
  });
360
619
 
620
+ $effect(() => {
621
+ if (!preferenceScopeIsLoaded()) return;
622
+ const ids = tableColumnOrder.current;
623
+ persistColumnOrder(ids);
624
+ });
625
+
626
+ function persistSavedViews(): void {
627
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
628
+ try {
629
+ localStorage.setItem(savedListViewsStorageKey(listPreferenceScope), serializeSavedListViews(savedViews));
630
+ } catch { /* ignore quota errors */ }
631
+ }
632
+
633
+ function persistActiveSavedView(): void {
634
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
635
+ try {
636
+ const key = activeSavedListViewStorageKey(listPreferenceScope);
637
+ if (activeSavedViewId) localStorage.setItem(key, activeSavedViewId);
638
+ else localStorage.removeItem(key);
639
+ } catch { /* ignore quota errors */ }
640
+ }
641
+
642
+ function markSavedViewDirty(): void {
643
+ if (!activeSavedViewId) return;
644
+ activeSavedViewId = undefined;
645
+ persistActiveSavedView();
646
+ }
647
+
648
+ function getCurrentSavedViewState(): SavedListViewState {
649
+ return {
650
+ search: appliedSearchText,
651
+ filters: locationFilters.map(cloneFilter),
652
+ sorters: sorters.map((sorter) => ({ ...sorter })),
653
+ pagination: {
654
+ current: pagination.current ?? 1,
655
+ pageSize: pagination.pageSize ?? initPageSize,
656
+ },
657
+ columnVisibility: { ...tableColumnVisibility.current },
658
+ columnOrder: [...tableColumnOrder.current],
659
+ };
660
+ }
661
+
662
+ function applySavedView(view: SavedListView): void {
663
+ if (searchDebounceTimer) {
664
+ clearTimeout(searchDebounceTimer);
665
+ searchDebounceTimer = undefined;
666
+ }
667
+ const state = cloneSavedListViewState(view.state);
668
+ activeSavedViewId = view.id;
669
+ persistActiveSavedView();
670
+ searchText = state.search;
671
+ appliedSearchText = state.search;
672
+ filters = state.filters;
673
+ const restoredFilterCounts: Record<string, number> = {};
674
+ for (const filter of state.filters) {
675
+ if ('field' in filter && filter.operator === 'contains' && typeof filter.value === 'string' && editableFilterKeys.has(filter.field)) {
676
+ restoredFilterCounts[filter.field] = (restoredFilterCounts[filter.field] ?? 0) + 1;
677
+ }
678
+ }
679
+ filterValues = {};
680
+ for (const filter of state.filters) {
681
+ if (
682
+ 'field' in filter
683
+ && filter.operator === 'contains'
684
+ && typeof filter.value === 'string'
685
+ && restoredFilterCounts[filter.field] === 1
686
+ ) {
687
+ filterValues[filter.field] = filter.value;
688
+ }
689
+ }
690
+ pagination = { ...pagination, ...state.pagination };
691
+ sorters = state.sorters;
692
+ sortingAtom.set(state.sorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
693
+ columnVisibilityAtom.set(state.columnVisibility);
694
+ columnOrderAtom.set(state.columnOrder);
695
+ persistColumnOrder(state.columnOrder);
696
+ rowSelectionAtom.set({});
697
+ }
698
+
699
+ function resetToDefaultListState(): void {
700
+ if (searchDebounceTimer) {
701
+ clearTimeout(searchDebounceTimer);
702
+ searchDebounceTimer = undefined;
703
+ }
704
+ searchText = '';
705
+ appliedSearchText = '';
706
+ filters = [];
707
+ filterValues = {};
708
+ if (!externalPagination) {
709
+ pagination = { current: 1, pageSize: initPageSize };
710
+ }
711
+ if (!externalSorters) {
712
+ const defaultSorters = initDefaultSort ? [{ ...initDefaultSort }] : [];
713
+ sorters = defaultSorters;
714
+ sortingAtom.set(defaultSorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
715
+ }
716
+ }
717
+
718
+ $effect(() => {
719
+ const scope = listPreferenceScope;
720
+ const scopeId = listPreferenceScopeId(scope);
721
+ if (scopeId === loadedPreferenceScopeId) return;
722
+
723
+ const preferences = readScopedSavedViewPreferences(scope);
724
+ savedViews = preferences.savedViews;
725
+ activeSavedViewId = preferences.activeSavedViewId;
726
+
727
+ if (!hasExplicitURLState && preferences.activeSavedView) {
728
+ applySavedView(preferences.activeSavedView);
729
+ } else {
730
+ if (!hasExplicitURLState) resetToDefaultListState();
731
+ columnVisibilityAtom.set(preferences.columnVisibility);
732
+ columnOrderAtom.set(preferences.columnOrder);
733
+ rowSelectionAtom.set({});
734
+ }
735
+
736
+ loadedPreferenceScopeId = scopeId;
737
+ });
738
+
739
+ function saveCurrentView(): void {
740
+ const name = savedViewName.trim().slice(0, 60);
741
+ if (!name) return;
742
+ if (searchDebounceTimer) {
743
+ clearTimeout(searchDebounceTimer);
744
+ searchDebounceTimer = undefined;
745
+ appliedSearchText = searchText;
746
+ }
747
+ const existing = savedViews.find((view) => view.name.toLocaleLowerCase() === name.toLocaleLowerCase());
748
+ const view: SavedListView = {
749
+ id: existing?.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
750
+ name,
751
+ state: getCurrentSavedViewState(),
752
+ };
753
+ savedViews = existing
754
+ ? savedViews.map((candidate) => candidate.id === existing.id ? view : candidate)
755
+ : [view, ...savedViews].slice(0, 25);
756
+ activeSavedViewId = view.id;
757
+ savedViewName = '';
758
+ persistSavedViews();
759
+ persistActiveSavedView();
760
+ }
761
+
762
+ function deleteSavedView(id: string): void {
763
+ savedViews = savedViews.filter((view) => view.id !== id);
764
+ if (activeSavedViewId === id) activeSavedViewId = undefined;
765
+ persistSavedViews();
766
+ persistActiveSavedView();
767
+ }
768
+
769
+ function setColumnVisibility(columnId: string, visible: boolean): void {
770
+ markSavedViewDirty();
771
+ columnVisibilityAtom.set({ ...tableColumnVisibility.current, [columnId]: visible });
772
+ }
773
+
774
+ function persistColumnOrder(ids: string[]): void {
775
+ if (typeof window === 'undefined' || !preferenceScopeIsLoaded()) return;
776
+ try { localStorage.setItem(columnOrderStorageKey(listPreferenceScope), JSON.stringify(ids)); } catch { /* ignore */ }
777
+ }
778
+
779
+ function setColumnOrder(newOrder: Array<{ id: string }>): void {
780
+ markSavedViewDirty();
781
+ const ids = newOrder.map((column) => column.id);
782
+ columnOrderAtom.set(ids);
783
+ }
784
+
785
+ function toggleColumnSort(column: Column<TableFeatures, BaseRecord, unknown>): void {
786
+ markSavedViewDirty();
787
+ column_toggleSorting(column);
788
+ }
789
+
361
790
  // Sync external sorters ? local sorting state (controlled mode only)
362
791
  $effect(() => {
363
792
  if (!externalSorters) return;
@@ -431,9 +860,14 @@
431
860
  get columns() { return orderedColumns; },
432
861
  manualSorting: true,
433
862
  getRowId: (row: BaseRecord) => String(row[primaryKey]),
863
+ state: {
864
+ get sorting() { return tableSorting.current; },
865
+ get columnVisibility() { return tableColumnVisibility.current; },
866
+ },
867
+ onSortingChange: (updater) => {
868
+ sortingAtom.set(typeof updater === 'function' ? updater(tableSorting.current) : updater);
869
+ },
434
870
  atoms: {
435
- sorting: sortingAtom,
436
- columnVisibility: columnVisibilityAtom,
437
871
  rowSelection: rowSelectionAtom,
438
872
  expanded: expandedAtom,
439
873
  },
@@ -444,14 +878,38 @@
444
878
  },
445
879
  );
446
880
 
447
- $effect.pre(() => {
448
- const nextColumns = orderedColumns;
449
- untrack(() => {
450
- tbl.setOptions({ ...tbl.options, columns: nextColumns });
451
- });
452
- });
881
+ function toggleAllRowsSelection(): void {
882
+ const willSelect = !table_getIsAllRowsSelected(tbl);
883
+ for (const row of tableView.rows) {
884
+ if (willSelect) {
885
+ selectedIdValueByKey.set(row.id, rowIdValue(row));
886
+ } else if (!willSelect) {
887
+ selectedIdValueByKey.delete(row.id);
888
+ }
889
+ }
890
+ table_toggleAllRowsSelected(tbl);
891
+ }
453
892
 
454
- const selectedCount = $derived(Object.keys(tableRowSelection.current).length);
893
+ const selectedIds = $derived(
894
+ Object.keys(tableRowSelection.current).map((key) => selectedIdValueByKey.get(key) ?? key)
895
+ );
896
+ const selectedCount = $derived(selectedIds.length);
897
+ const batchDeletePerm = useCan(() => ({
898
+ resource: resourceName,
899
+ action: 'delete',
900
+ params: { ids: selectedIds },
901
+ queryOptions: { enabled: acEnabled && canDelete && selectedIds.length > 0 },
902
+ }));
903
+ const canBatchDelete = $derived(
904
+ canDelete && (!acEnabled || (!batchDeletePerm.isLoading && batchDeletePerm.allowed))
905
+ );
906
+
907
+ function clearSelection(): void {
908
+ if (!deleteManyMutation.isPending) {
909
+ selectedIdValueByKey.clear();
910
+ rowSelectionAtom.set({});
911
+ }
912
+ }
455
913
 
456
914
  function isColumnVisible(columnId: string): boolean {
457
915
  return tableColumnVisibility.current[columnId] !== false;
@@ -460,11 +918,13 @@
460
918
  const tableView = $derived.by(() => {
461
919
  void tableSorting.current;
462
920
  void tableColumnVisibility.current;
921
+ void orderedColumns;
463
922
  void tableRowSelection.current;
464
923
  void tableExpanded.current;
465
924
  void tableColumnOrder.current;
925
+ const headerGroups = table_getHeaderGroups(tbl);
466
926
  return {
467
- headerGroups: tbl.getHeaderGroups(),
927
+ headerGroups,
468
928
  rows: table_getRowModel(tbl).rows,
469
929
  };
470
930
  });
@@ -512,38 +972,79 @@
512
972
  let confirmOpen = $state(false);
513
973
  let confirmMessage = $state('');
514
974
  let confirmAction = $state<() => void>(() => {});
975
+ let confirmPending = $state(false);
976
+ let detailOpenedInHistory = $state(false);
977
+ const detailRecordId = $derived(parsed.params.detail);
978
+ const detailOpen = $derived(detailRecordId != null);
515
979
 
516
980
  function confirmDelete(id: string | number) {
517
981
  confirmMessage = i18n.t('common.deleteConfirm');
518
982
  confirmAction = async () => {
519
- await deleteMutation.mutateAsync({ id, resource: resourceName });
520
- confirmOpen = false;
983
+ confirmPending = true;
984
+ if (mutationMode !== 'pessimistic') confirmOpen = false;
985
+ try {
986
+ await deleteMutation.mutateAsync({
987
+ id,
988
+ resource: resourceName,
989
+ onCancel: () => { confirmOpen = false; },
990
+ });
991
+ confirmOpen = false;
992
+ } catch {
993
+ // useDelete owns error feedback and restores optimistic cache state.
994
+ } finally {
995
+ confirmPending = false;
996
+ }
521
997
  };
522
998
  confirmOpen = true;
523
999
  }
524
1000
 
525
1001
  function confirmBatchDelete() {
526
- const ids = Object.keys(tableRowSelection.current);
1002
+ const ids = [...selectedIds];
1003
+ if (ids.length === 0 || deleteManyMutation.isPending) return;
1004
+ batchOperationCount = ids.length;
527
1005
  confirmMessage = i18n.t('common.batchDeleteConfirm', { count: ids.length });
528
1006
  confirmAction = async () => {
1007
+ confirmPending = true;
1008
+ if (mutationMode !== 'pessimistic') confirmOpen = false;
529
1009
  try {
530
- await deleteManyMutation.mutateAsync({ ids, resource: resourceName });
531
- notifyWithProvider(
532
- { type: 'success', message: i18n.t('common.batchDeleteSuccess', { count: ids.length }) },
533
- adminContext.notificationProvider,
534
- );
535
- } catch {
536
- notifyWithProvider(
537
- { type: 'error', message: i18n.t('common.batchDeletePartialFail', { failed: 1, total: ids.length }) },
538
- adminContext.notificationProvider,
539
- );
1010
+ await deleteManyMutation.mutateAsync({
1011
+ ids,
1012
+ resource: resourceName,
1013
+ onCancel: () => { confirmOpen = false; },
1014
+ });
1015
+ selectedIdValueByKey.clear();
1016
+ rowSelectionAtom.set({});
1017
+ confirmOpen = false;
1018
+ } catch (error) {
1019
+ if (error instanceof DeleteManyPartialError) {
1020
+ const failedSelection: RowSelectionState = {};
1021
+ for (const id of error.failedIds) failedSelection[String(id)] = true;
1022
+ for (const id of error.succeededIds) selectedIdValueByKey.delete(String(id));
1023
+ rowSelectionAtom.set(failedSelection);
1024
+ }
1025
+ // useDeleteMany owns the error notification; keep failed selection for retry.
1026
+ } finally {
1027
+ confirmPending = false;
540
1028
  }
541
- rowSelectionAtom.set({});
542
- confirmOpen = false;
543
1029
  };
544
1030
  confirmOpen = true;
545
1031
  }
546
1032
 
1033
+ function openDetail(id: string | number): void {
1034
+ detailOpenedInHistory = true;
1035
+ writeURLState({ detailId: String(id) }, adminContext, 'push');
1036
+ }
1037
+
1038
+ function closeDetail(): void {
1039
+ if (detailOpenedInHistory && adminContext.routerProvider) {
1040
+ detailOpenedInHistory = false;
1041
+ adminContext.routerProvider.back();
1042
+ return;
1043
+ }
1044
+ detailOpenedInHistory = false;
1045
+ writeURLState({ detailId: undefined }, adminContext);
1046
+ }
1047
+
547
1048
  // ─── CSV Export ───────────────────────────────────────────────
548
1049
  function exportCSV() {
549
1050
  const data = query.data?.data ?? [];
@@ -567,6 +1068,7 @@
567
1068
  }
568
1069
 
569
1070
  function goToPage(page: number) {
1071
+ markSavedViewDirty();
570
1072
  pagination = { ...pagination, current: page };
571
1073
  }
572
1074
  </script>
@@ -579,15 +1081,15 @@
579
1081
  <div class="flex flex-wrap items-center gap-2">
580
1082
  {#if canExport}
581
1083
  <Button variant="outline" size="sm" onclick={exportCSV}>
582
- <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.export')}
1084
+ <Download class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.export")}
583
1085
  </Button>
584
1086
  {/if}
585
1087
  {#if headerActions}
586
1088
  {@render headerActions()}
587
1089
  {/if}
588
1090
  {#if canCreate}
589
- <Button onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
590
- <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t('common.create')}
1091
+ <Button onclick={() => navigation.create(resourceName)}>
1092
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
591
1093
  </Button>
592
1094
  {/if}
593
1095
  </div>
@@ -596,31 +1098,40 @@
596
1098
 
597
1099
  <!-- Selection Banner (Enterprise Batch Actions) -->
598
1100
  {#if selectedCount > 0}
599
- <div class="flex flex-wrap items-center justify-between gap-3 px-3.5 py-2 bg-primary/10 border border-primary/20 rounded-lg text-sm text-foreground animate-in fade-in duration-200">
600
- <div class="flex items-center gap-2">
601
- <span class="font-medium text-xs sm:text-sm">{i18n.t('common.selectedItems', { count: selectedCount })}</span>
1101
+ <div
1102
+ class="flex flex-col gap-3 border border-primary/20 bg-primary/10 px-3.5 py-2 rounded-lg text-sm text-foreground animate-in fade-in duration-200 sm:flex-row sm:items-center sm:justify-between"
1103
+ aria-label={i18n.t("common.selectedCount", { count: selectedCount })}
1104
+ data-svadmin-batch-toolbar
1105
+ >
1106
+ <div class="flex min-w-0 items-center gap-2 text-sm">
1107
+ <span class="font-medium text-foreground">{i18n.t("common.selectedCount", { count: selectedCount })}</span>
1108
+ {#if deleteManyMutation.isPending}
1109
+ <span class="text-muted-foreground" role="status">{i18n.t("common.processing")}</span>
1110
+ {/if}
602
1111
  </div>
603
- <div class="flex items-center gap-2">
604
- {#if canDelete}
1112
+ <div class="flex flex-wrap items-center gap-2">
1113
+ {#if batchActions}
1114
+ {@render batchActions({ selectedIds })}
1115
+ {/if}
1116
+ {#if canBatchDelete}
605
1117
  <Button
606
1118
  variant="destructive"
607
1119
  size="sm"
608
1120
  class="h-8 text-xs whitespace-nowrap"
1121
+ disabled={deleteManyMutation.isPending}
609
1122
  onclick={confirmBatchDelete}
610
1123
  >
611
- <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t('common.batchDelete', { count: selectedCount })}
1124
+ <Trash2 class="h-3.5 w-3.5" data-icon="inline-start" /> {i18n.t("common.batchDelete", { count: selectedCount })}
612
1125
  </Button>
613
1126
  {/if}
614
- {#if batchActions}
615
- {@render batchActions({ selectedIds: Object.keys(tableRowSelection.current) })}
616
- {/if}
617
1127
  <Button
618
1128
  variant="ghost"
619
1129
  size="sm"
620
1130
  class="h-8 text-xs text-muted-foreground hover:text-foreground"
621
- onclick={() => rowSelectionAtom.set({})}
1131
+ disabled={deleteManyMutation.isPending}
1132
+ onclick={clearSelection}
622
1133
  >
623
- {i18n.t('common.clearSelection')}
1134
+ {i18n.t("common.clearSelection")}
624
1135
  </Button>
625
1136
  </div>
626
1137
  </div>
@@ -637,7 +1148,7 @@
637
1148
  type="text"
638
1149
  value={searchText}
639
1150
  oninput={scheduleSearch}
640
- placeholder={i18n.t('common.search')}
1151
+ placeholder={i18n.t("common.search")}
641
1152
  class="pl-9 h-9 text-sm"
642
1153
  />
643
1154
  </div>
@@ -649,7 +1160,7 @@
649
1160
  {#snippet child({ props })}
650
1161
  <Button variant="outline" size="sm" class="h-9 px-3" {...props}>
651
1162
  <FilterIcon class="h-4 w-4" data-icon="inline-start" />
652
- {i18n.t('common.filter')}
1163
+ {i18n.t("common.filter")}
653
1164
  {#if activeFilterCount > 0}
654
1165
  <Badge variant="secondary" class="ml-1 h-5 min-w-5 px-1">{activeFilterCount}</Badge>
655
1166
  {/if}
@@ -658,28 +1169,28 @@
658
1169
  </Popover.Trigger>
659
1170
  <Popover.Content class="w-80">
660
1171
  <div class="space-y-3">
661
- <h4 class="font-medium text-sm">{i18n.t('common.filter')}</h4>
1172
+ <h4 class="font-medium text-sm">{i18n.t("common.filter")}</h4>
662
1173
  {#each filterableFields as field, _i (_i)}
663
1174
  <div class="space-y-1">
664
1175
  <label class="text-xs text-muted-foreground" for="filter-{field.key}">{field.label}</label>
665
- {#if field.type === 'select' && field.options}
666
- <select
1176
+ {#if field.type === "select" && field.options}
1177
+ <Select.Root
667
1178
  id="filter-{field.key}"
668
- class="h-9 text-sm w-full font-normal flex w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
669
- value={filterValues[field.key] ?? ''}
670
- onchange={(e) => filterValues[field.key] = (e.currentTarget as HTMLSelectElement).value}
1179
+ class="h-9 text-sm"
1180
+ value={filterValues[field.key] ?? ""}
1181
+ onchange={(e) => setFilterValue(field.key, (e.currentTarget as HTMLSelectElement).value)}
671
1182
  >
672
- <option value="">{i18n.t('common.all')}</option>
1183
+ <option value="">{i18n.t("common.all")}</option>
673
1184
  {#each field.options as opt, _i (_i)}
674
1185
  <option value={opt.value}>{opt.label}</option>
675
1186
  {/each}
676
- </select>
1187
+ </Select.Root>
677
1188
  {:else}
678
1189
  <Input
679
1190
  id="filter-{field.key}"
680
1191
  type="text"
681
- value={filterValues[field.key] ?? ''}
682
- oninput={(e) => filterValues[field.key] = (e.currentTarget as HTMLInputElement).value}
1192
+ value={filterValues[field.key] ?? ""}
1193
+ oninput={(e) => setFilterValue(field.key, e.currentTarget.value)}
683
1194
  placeholder={field.label}
684
1195
  class="h-9 text-sm"
685
1196
  />
@@ -688,13 +1199,10 @@
688
1199
  {/each}
689
1200
  <div class="flex gap-2 pt-2">
690
1201
  <Button size="sm" class="flex-1" onclick={() => { pagination = { ...pagination, current: 1 }; }}>
691
- {i18n.t('common.confirm')}
1202
+ {i18n.t("common.confirm")}
692
1203
  </Button>
693
- <Button variant="outline" size="sm" onclick={() => {
694
- filterValues = {};
695
- pagination = { ...pagination, current: 1 };
696
- }}>
697
- {i18n.t('common.reset')}
1204
+ <Button variant="outline" size="sm" onclick={clearFilters}>
1205
+ {i18n.t("common.reset")}
698
1206
  </Button>
699
1207
  </div>
700
1208
  </div>
@@ -703,10 +1211,10 @@
703
1211
  {/if}
704
1212
  </div>
705
1213
 
706
- <!-- Right: Table Controls (Density, Columns, Refresh, Export if !showHeader) -->
1214
+ <!-- Right: Table Controls (Density, Columns, Saved Views, Refresh, Export if !showHeader) -->
707
1215
  <div class="flex items-center gap-1.5 shrink-0">
708
1216
  {#if !showHeader && canExport}
709
- <TooltipButton tooltip={i18n.t('common.export')} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
1217
+ <TooltipButton tooltip={i18n.t("common.export")} variant="outline" size="sm" class="h-9 px-2.5" onclick={exportCSV}>
710
1218
  <Download class="h-4 w-4" />
711
1219
  </TooltipButton>
712
1220
  {/if}
@@ -715,17 +1223,17 @@
715
1223
  <DropdownMenu.Root>
716
1224
  <DropdownMenu.Trigger>
717
1225
  {#snippet child({ props })}
718
- <TooltipButton tooltip={i18n.t('common.density')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
1226
+ <TooltipButton tooltip={i18n.t("common.density")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
719
1227
  <Rows class="h-4 w-4" />
720
1228
  </TooltipButton>
721
1229
  {/snippet}
722
1230
  </DropdownMenu.Trigger>
723
1231
  <DropdownMenu.Content align="end" class="w-36">
724
- <DropdownMenu.Item onclick={() => { densityOverride = 'comfortable'; }} class={currentDensity === 'comfortable' ? 'font-semibold text-primary' : ''}>
725
- {i18n.t('common.densityComfortable')}
1232
+ <DropdownMenu.Item onclick={() => { densityOverride = "comfortable"; }} class={currentDensity === "comfortable" ? "font-semibold text-primary" : ""}>
1233
+ {i18n.t("common.densityComfortable")}
726
1234
  </DropdownMenu.Item>
727
- <DropdownMenu.Item onclick={() => { densityOverride = 'compact'; }} class={currentDensity === 'compact' ? 'font-semibold text-primary' : ''}>
728
- {i18n.t('common.densityCompact')}
1235
+ <DropdownMenu.Item onclick={() => { densityOverride = "compact"; }} class={currentDensity === "compact" ? "font-semibold text-primary" : ""}>
1236
+ {i18n.t("common.densityCompact")}
729
1237
  </DropdownMenu.Item>
730
1238
  </DropdownMenu.Content>
731
1239
  </DropdownMenu.Root>
@@ -735,16 +1243,16 @@
735
1243
  <DropdownMenu.Root>
736
1244
  <DropdownMenu.Trigger>
737
1245
  {#snippet child({ props })}
738
- <TooltipButton tooltip={i18n.t('common.columns')} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
1246
+ <TooltipButton tooltip={i18n.t("common.columns")} variant="outline" size="sm" class="h-9 px-2.5" {...props}>
739
1247
  <SlidersHorizontal class="h-4 w-4" />
740
1248
  </TooltipButton>
741
1249
  {/snippet}
742
1250
  </DropdownMenu.Trigger>
743
1251
  <DropdownMenu.Content align="end" class="w-48">
744
- {#each table_getAllLeafColumns(tbl).filter((column: Column<TableFeatures, BaseRecord, unknown>) => !column.id.startsWith('_')) as column, _i (_i)}
1252
+ {#each table_getAllLeafColumns(tbl).filter((column) => !column.id.startsWith("_")) as column, _i (_i)}
745
1253
  <DropdownMenu.CheckboxItem
746
1254
  checked={tableColumnVisibility.current[column.id] ?? true}
747
- onCheckedChange={(v) => column_toggleVisibility(column, !!v)}
1255
+ onCheckedChange={(v) => setColumnVisibility(column.id, !!v)}
748
1256
  >
749
1257
  {visibleFields.find(f => f.key === column.id)?.label ?? column.id}
750
1258
  </DropdownMenu.CheckboxItem>
@@ -752,49 +1260,134 @@
752
1260
  </DropdownMenu.Content>
753
1261
  </DropdownMenu.Root>
754
1262
 
1263
+ <!-- Saved Views -->
1264
+ <Popover.Root bind:open={savedViewsOpen}>
1265
+ <Popover.Trigger>
1266
+ {#snippet child({ props })}
1267
+ <Button variant="outline" size="sm" class="h-9 px-2.5" {...props} aria-label={i18n.t("common.savedViews")}>
1268
+ <Bookmark class="h-4 w-4" data-icon="inline-start" /> {activeSavedViewName ?? i18n.t("common.savedViews")}
1269
+ </Button>
1270
+ {/snippet}
1271
+ </Popover.Trigger>
1272
+ <Popover.Content align="end" class="w-80">
1273
+ <div class="space-y-3">
1274
+ <div>
1275
+ <h4 class="font-medium text-sm">{i18n.t("common.savedViews")}</h4>
1276
+ <p class="mt-1 text-xs text-muted-foreground">{i18n.t("common.savedViewsHint")}</p>
1277
+ </div>
1278
+ <div class="space-y-1">
1279
+ <label class="text-xs text-muted-foreground" for="saved-list-view">{i18n.t("common.currentView")}</label>
1280
+ <Select.Root
1281
+ id="saved-list-view"
1282
+ class="h-9 w-full"
1283
+ value={activeSavedViewId ?? ""}
1284
+ onchange={(event) => {
1285
+ const id = (event.currentTarget as HTMLSelectElement).value;
1286
+ const view = savedViews.find((candidate) => candidate.id === id);
1287
+ if (view) applySavedView(view);
1288
+ else {
1289
+ activeSavedViewId = undefined;
1290
+ persistActiveSavedView();
1291
+ }
1292
+ }}
1293
+ >
1294
+ <option value="">{i18n.t("common.currentView")}</option>
1295
+ {#each savedViews as view (view.id)}
1296
+ <option value={view.id}>{view.name}</option>
1297
+ {/each}
1298
+ </Select.Root>
1299
+ </div>
1300
+ <div class="flex gap-2">
1301
+ <Input
1302
+ aria-label={i18n.t("common.viewName")}
1303
+ placeholder={i18n.t("common.viewName")}
1304
+ maxlength={60}
1305
+ bind:value={savedViewName}
1306
+ class="h-9"
1307
+ />
1308
+ <Button size="sm" class="shrink-0" disabled={!savedViewName.trim()} onclick={saveCurrentView}>
1309
+ <Check class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.saveView")}
1310
+ </Button>
1311
+ </div>
1312
+ {#if savedViews.length > 0}
1313
+ <div class="space-y-1 border-t border-border pt-2">
1314
+ {#each savedViews as view (view.id)}
1315
+ <div class="flex items-center justify-between gap-2 text-sm">
1316
+ <span class="min-w-0 truncate">{view.name}</span>
1317
+ <button
1318
+ type="button"
1319
+ class="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1320
+ aria-label="{i18n.t("common.delete")} {view.name}"
1321
+ title="{i18n.t("common.delete")} {view.name}"
1322
+ onclick={() => deleteSavedView(view.id)}
1323
+ >
1324
+ <Trash2 class="size-3.5" />
1325
+ </button>
1326
+ </div>
1327
+ {/each}
1328
+ </div>
1329
+ {/if}
1330
+ </div>
1331
+ </Popover.Content>
1332
+ </Popover.Root>
1333
+
755
1334
  {#if showRefresh}
756
1335
  <TooltipButton
757
- tooltip={i18n.t('common.refresh')}
1336
+ tooltip={i18n.t("common.refresh")}
758
1337
  variant="outline"
759
1338
  size="sm"
760
1339
  class="h-9 px-2.5"
761
1340
  onclick={() => listResult.refetch()}
762
1341
  >
763
- <RefreshCw class="h-4 w-4 {query.isFetching ? 'animate-spin' : ''}" />
1342
+ <RefreshCw class="h-4 w-4 {query.isFetching ? "animate-spin" : ""}" />
764
1343
  </TooltipButton>
765
1344
  {/if}
1345
+
1346
+ {#if !showHeader && canCreate}
1347
+ <Button onclick={() => navigation.create(resourceName)}>
1348
+ <Plus class="h-4 w-4" data-icon="inline-start" /> {i18n.t("common.create")}
1349
+ </Button>
1350
+ {/if}
766
1351
  </div>
767
1352
  </div>
768
1353
 
769
1354
  <!-- Active Filter Tags -->
770
- {#if showFilterTags && (appliedSearchText.trim() || activeFilterCount > 0)}
771
- <div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5">
1355
+ {#if showFilterTags && ((appliedSearchText || searchText).trim() || activeFilterItems.length > 0)}
1356
+ <div
1357
+ class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground pt-0.5 border-y border-border py-2"
1358
+ aria-label={i18n.t('common.filterTags')}
1359
+ data-svadmin-active-filters
1360
+ >
772
1361
  <span class="font-medium text-foreground">{i18n.t('common.filterTags')}:</span>
773
- {#if appliedSearchText.trim()}
1362
+ {#if (appliedSearchText || searchText).trim()}
774
1363
  <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
775
- <span>{i18n.t('common.search')}: "{appliedSearchText}"</span>
776
- <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${i18n.t('common.search')}`} onclick={() => { searchText = ''; appliedSearchText = ''; pagination = { ...pagination, current: 1 }; }}>
777
- <X class="h-3 w-3" />
1364
+ <span>{i18n.t('common.search')}: "{(appliedSearchText || searchText).trim()}"</span>
1365
+ <button
1366
+ type="button"
1367
+ class="hover:text-foreground cursor-pointer inline-flex size-4 items-center justify-center rounded-sm"
1368
+ aria-label="{i18n.t('common.clear')}: {i18n.t('common.search')}"
1369
+ title="{i18n.t('common.clear')}: {i18n.t('common.search')}"
1370
+ onclick={() => { searchText = ""; appliedSearchText = ""; pagination = { ...pagination, current: 1 }; }}
1371
+ >
1372
+ <X class="size-3" />
778
1373
  </button>
779
1374
  </Badge>
780
1375
  {/if}
781
- {#each Object.entries(filterValues) as [key, val] (key)}
782
- {#if val.trim()}
783
- {@const field = filterableFields.find(f => f.key === key)}
784
- <Badge variant="secondary" class="gap-1 pr-1 font-normal text-xs">
785
- <span>{field?.label ?? key}: {val}</span>
786
- <button type="button" class="hover:text-foreground cursor-pointer" aria-label={`${i18n.t('common.clear')}: ${field?.label ?? key}`} onclick={() => removeFilterKey(key)}>
787
- <X class="h-3 w-3" />
788
- </button>
789
- </Badge>
790
- {/if}
1376
+ {#each activeFilterItems as item (`${item.label}-${item.index}`)}
1377
+ <Badge variant="outline" class="max-w-full gap-1 pr-1 font-normal">
1378
+ <span class="truncate">{item.label}</span>
1379
+ <button
1380
+ type="button"
1381
+ class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
1382
+ aria-label="{i18n.t('common.clear')}: {item.label}"
1383
+ title="{i18n.t('common.clear')}: {item.label}"
1384
+ onclick={() => removeActiveFilter(item.index)}
1385
+ >
1386
+ <X class="size-3" />
1387
+ </button>
1388
+ </Badge>
791
1389
  {/each}
792
- <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={() => {
793
- searchText = '';
794
- appliedSearchText = '';
795
- filterValues = {};
796
- pagination = { ...pagination, current: 1 };
797
- }}>
1390
+ <Button variant="ghost" size="sm" class="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" onclick={clearFilters}>
798
1391
  {i18n.t('common.clearAllFilters')}
799
1392
  </Button>
800
1393
  </div>
@@ -818,9 +1411,11 @@
818
1411
  {/each}
819
1412
  </div>
820
1413
  {:else if query.error}
821
- <div class="flex h-64 items-center justify-center text-destructive text-sm">
822
- {i18n.t('common.loadFailed', { message: (query.error as Error).message })}
823
- </div>
1414
+ <DataState
1415
+ state="error"
1416
+ description={i18n.t('common.loadFailed', { message: (query.error as Error).message })}
1417
+ retry={() => listResult.refetch()}
1418
+ />
824
1419
  {:else}
825
1420
  <div in:fade={{ duration: 150 }}>
826
1421
  <!-- Desktop Table (hidden on mobile) -->
@@ -831,8 +1426,7 @@
831
1426
  {@const visibleHeaders = headerGroup.headers.filter((header: Header<TableFeatures, BaseRecord, unknown>) => isColumnVisible(header.column.id))}
832
1427
  <DraggableHeader
833
1428
  columns={visibleHeaders.map((header) => ({ id: header.column.id, header }))}
834
- resourceName={resourceName}
835
- onReorder={(newOrder) => columnOrderAtom.set(newOrder.map((column) => column.id))}
1429
+ onReorder={setColumnOrder}
836
1430
  >
837
1431
  {#snippet header(col, _index, dragProps)}
838
1432
  {@const header = col.header as typeof headerGroup.headers[0]}
@@ -842,10 +1436,21 @@
842
1436
  style={header_getSize(header) != null && header_getSize(header) !== 150 ? `width:${header_getSize(header)}px` : undefined}
843
1437
  >
844
1438
  {#if header.id === '_select'}
845
- <Checkbox
846
- checked={table_getIsAllRowsSelected(tbl)}
847
- onCheckedChange={() => table_toggleAllRowsSelected(tbl)}
848
- />
1439
+ {#if batchActions}
1440
+ <Checkbox
1441
+ aria-label={i18n.t('common.selectAll')}
1442
+ checked={table_getIsAllRowsSelected(tbl)}
1443
+ onCheckedChange={toggleAllRowsSelection}
1444
+ />
1445
+ {:else}
1446
+ <CanAccess resource={resourceName} action="delete" params={{ ids: tableView.rows.map(rowIdValue) }}>
1447
+ <Checkbox
1448
+ aria-label={i18n.t('common.selectAll')}
1449
+ checked={table_getIsAllRowsSelected(tbl)}
1450
+ onCheckedChange={toggleAllRowsSelection}
1451
+ />
1452
+ </CanAccess>
1453
+ {/if}
849
1454
  {:else if header.id === '_expand'}
850
1455
  <!-- empty -->
851
1456
  {:else if header.id === '_actions'}
@@ -855,7 +1460,7 @@
855
1460
  variant="ghost"
856
1461
  size="sm"
857
1462
  class="flex items-center gap-1 hover:text-foreground -ml-2 h-auto py-1 px-2 uppercase tracking-wide text-[0.7rem] font-semibold"
858
- onclick={() => column_toggleSorting(header.column)}
1463
+ onclick={() => toggleColumnSort(header.column)}
859
1464
  >
860
1465
  {visibleFields.find(f => f.key === header.id)?.label ?? header.id}
861
1466
  <span class="text-xs opacity-50">
@@ -874,21 +1479,32 @@
874
1479
  {/each}
875
1480
  </Table.Header>
876
1481
  <Table.Body>
877
- {#each tableView.rows as row, _i (_i)}
1482
+ {#each tableView.rows as row (row.id)}
878
1483
  {@const record = row.original}
879
1484
  {@const id = record[primaryKey] as string | number}
880
1485
  {@const visibleCells = row_getVisibleCells(row).filter((cell) => isColumnVisible(cell.column.id))}
881
1486
  <ContextMenu.Root>
882
1487
  <ContextMenu.Trigger>
883
1488
  {#snippet child({ props })}
884
- <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {row_getIsSelected(row) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
1489
+ <Table.Row {...props} class="transition-all duration-300 border-b border-border/10 {rowIsSelected(row.id) ? 'bg-primary/5' : 'hover:bg-muted/20'}">
885
1490
  {#each visibleCells as cell, _i (_i)}
886
1491
  <Table.Cell>
887
1492
  {#if cell.column.id === '_select'}
888
- <Checkbox
889
- checked={row_getIsSelected(row)}
890
- onCheckedChange={() => row_toggleSelected(row)}
891
- />
1493
+ {#if batchActions}
1494
+ <Checkbox
1495
+ aria-label={i18n.t('common.selectRow', { id })}
1496
+ checked={rowIsSelected(row.id)}
1497
+ onCheckedChange={() => toggleRowSelection(row)}
1498
+ />
1499
+ {:else}
1500
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1501
+ <Checkbox
1502
+ aria-label={i18n.t('common.selectRow', { id })}
1503
+ checked={rowIsSelected(row.id)}
1504
+ onCheckedChange={() => toggleRowSelection(row)}
1505
+ />
1506
+ </CanAccess>
1507
+ {/if}
892
1508
  {:else if cell.column.id === '_expand'}
893
1509
  <TooltipButton tooltip={rowIsExpanded(row.id) ? i18n.t('common.collapse') : i18n.t('common.expand')} variant="ghost" size="icon" class="h-7 w-7" onclick={() => toggleRowExpanded(row.id)}>
894
1510
  {#if rowIsExpanded(row.id)}
@@ -902,15 +1518,26 @@
902
1518
  {#if rowActions}
903
1519
  {@render rowActions({ record, id })}
904
1520
  {:else}
1521
+ {#if canShow}
1522
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1523
+ <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
1524
+ <Eye class="h-4 w-4" />
1525
+ </TooltipButton>
1526
+ </CanAccess>
1527
+ {/if}
905
1528
  {#if canEdit}
906
- <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)}>
907
- <Pencil class="h-4 w-4" />
908
- </TooltipButton>
1529
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1530
+ <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
1531
+ <Pencil class="h-4 w-4" />
1532
+ </TooltipButton>
1533
+ </CanAccess>
909
1534
  {/if}
910
1535
  {#if canDelete}
911
- <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
912
- <Trash2 class="h-4 w-4" />
913
- </TooltipButton>
1536
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1537
+ <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1538
+ <Trash2 class="h-4 w-4" />
1539
+ </TooltipButton>
1540
+ </CanAccess>
914
1541
  {/if}
915
1542
  {/if}
916
1543
  </div>
@@ -934,13 +1561,18 @@
934
1561
  {@const opt = field.options.find(o => o.value === cell_getValue(cell))}
935
1562
  <Badge variant="outline">{opt?.label ?? cell_getValue(cell) ?? '—'}</Badge>
936
1563
  {:else if canEdit && field && ['text', 'number', 'email', 'url'].includes(field.type)}
937
- <InlineEdit
938
- {resourceName}
939
- recordId={id}
940
- {field}
941
- value={cell_getValue(cell)}
942
- onSave={() => listResult.refetch()}
943
- />
1564
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1565
+ <InlineEdit
1566
+ {resourceName}
1567
+ recordId={id}
1568
+ {field}
1569
+ value={cell_getValue(cell)}
1570
+ onSave={() => listResult.refetch()}
1571
+ />
1572
+ {#snippet fallback()}
1573
+ <span class="block truncate" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
1574
+ {/snippet}
1575
+ </CanAccess>
944
1576
  {:else if field?.key === primaryKey}
945
1577
  <span class="block truncate font-mono text-xs" title={String(cell_getValue(cell) ?? '—')}>{cell_getValue(cell) ?? '—'}</span>
946
1578
  {:else}
@@ -954,23 +1586,29 @@
954
1586
  </ContextMenu.Trigger>
955
1587
  <ContextMenu.Content class="w-48">
956
1588
  {#if canEdit}
957
- <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)} class="gap-2">
958
- <Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
959
- </ContextMenu.Item>
1589
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1590
+ <ContextMenu.Item onclick={() => navigation.edit(resourceName, id)} class="gap-2">
1591
+ <Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
1592
+ </ContextMenu.Item>
1593
+ </CanAccess>
960
1594
  {/if}
961
1595
  {#if canShow}
962
- <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)} class="gap-2">
963
- <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
964
- </ContextMenu.Item>
1596
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1597
+ <ContextMenu.Item onclick={() => openDetail(id)} class="gap-2">
1598
+ <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
1599
+ </ContextMenu.Item>
1600
+ </CanAccess>
965
1601
  {/if}
966
1602
  <ContextMenu.Item onclick={() => navigator.clipboard?.writeText(String(id))} class="gap-2">
967
1603
  <Copy class="h-4 w-4" /> {i18n.t('common.copyId')}
968
1604
  </ContextMenu.Item>
969
1605
  {#if canDelete}
970
- <ContextMenu.Separator />
971
- <ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
972
- <Trash2 class="h-4 w-4" /> {i18n.t('common.delete')}
973
- </ContextMenu.Item>
1606
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1607
+ <ContextMenu.Separator />
1608
+ <ContextMenu.Item onclick={() => confirmDelete(id)} class="gap-2 text-destructive">
1609
+ <Trash2 class="h-4 w-4" /> {i18n.t('common.delete')}
1610
+ </ContextMenu.Item>
1611
+ </CanAccess>
974
1612
  {/if}
975
1613
  </ContextMenu.Content>
976
1614
  </ContextMenu.Root>
@@ -994,7 +1632,7 @@
994
1632
  <p class="text-sm font-medium text-muted-foreground mb-1">{i18n.t('common.noData')}</p>
995
1633
  <p class="text-xs text-muted-foreground/60 mb-4">{i18n.t('common.noDataHint')}</p>
996
1634
  {#if canCreate}
997
- <Button variant="outline" size="sm" class="gap-2" onclick={() => adminContext.navigate(`/${resourceName}/create`)}>
1635
+ <Button variant="outline" size="sm" class="gap-2" onclick={() => navigation.create(resourceName)}>
998
1636
  <Plus class="h-3.5 w-3.5" />
999
1637
  {i18n.t('common.create')}
1000
1638
  </Button>
@@ -1014,16 +1652,27 @@
1014
1652
  {@const record = row.original}
1015
1653
  {@const id = record[primaryKey] as string | number}
1016
1654
  <div
1017
- class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {row_getIsSelected(row) ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
1655
+ class="rounded-[20px] shadow-[0_4px_20px_rgb(0,0,0,0.03)] ring-1 ring-border/20 bg-card p-5 transition-all {rowIsSelected(row.id) ? 'ring-2 ring-primary/50 bg-primary/5' : ''}"
1018
1656
  >
1019
1657
  <!-- Card header: ID + select + actions -->
1020
1658
  <div class="flex items-center justify-between mb-3">
1021
1659
  <div class="flex items-center gap-2">
1022
1660
  {#if selectable && (canDelete || batchActions)}
1023
- <Checkbox
1024
- checked={row_getIsSelected(row)}
1025
- onCheckedChange={() => row_toggleSelected(row)}
1026
- />
1661
+ {#if batchActions}
1662
+ <Checkbox
1663
+ aria-label={i18n.t('common.selectRow', { id })}
1664
+ checked={rowIsSelected(row.id)}
1665
+ onCheckedChange={() => toggleRowSelection(row)}
1666
+ />
1667
+ {:else}
1668
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1669
+ <Checkbox
1670
+ aria-label={i18n.t('common.selectRow', { id })}
1671
+ checked={rowIsSelected(row.id)}
1672
+ onCheckedChange={() => toggleRowSelection(row)}
1673
+ />
1674
+ </CanAccess>
1675
+ {/if}
1027
1676
  {/if}
1028
1677
  <span class="text-xs font-mono text-muted-foreground">#{id}</span>
1029
1678
  </div>
@@ -1032,26 +1681,32 @@
1032
1681
  {@render rowActions({ record, id })}
1033
1682
  {:else}
1034
1683
  {#if canEdit}
1035
- <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/edit/${id}`)}>
1036
- <Pencil class="h-4 w-4" />
1037
- </TooltipButton>
1684
+ <CanAccess resource={resourceName} action="edit" params={{ id }}>
1685
+ <TooltipButton tooltip={i18n.t('common.edit')} variant="ghost" size="icon-sm" onclick={() => navigation.edit(resourceName, id)}>
1686
+ <Pencil class="h-4 w-4" />
1687
+ </TooltipButton>
1688
+ </CanAccess>
1038
1689
  {/if}
1039
1690
  {#if canShow}
1040
- <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)}>
1041
- <Eye class="h-4 w-4" />
1042
- </TooltipButton>
1691
+ <CanAccess resource={resourceName} action="show" params={{ id }}>
1692
+ <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => openDetail(id)}>
1693
+ <Eye class="h-4 w-4" />
1694
+ </TooltipButton>
1695
+ </CanAccess>
1043
1696
  {/if}
1044
1697
  {#if canDelete}
1045
- <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1046
- <Trash2 class="h-4 w-4" />
1047
- </TooltipButton>
1698
+ <CanAccess resource={resourceName} action="delete" params={{ id }}>
1699
+ <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
1700
+ <Trash2 class="h-4 w-4" />
1701
+ </TooltipButton>
1702
+ </CanAccess>
1048
1703
  {/if}
1049
1704
  {/if}
1050
1705
  </div>
1051
1706
  </div>
1052
1707
  <!-- Card fields -->
1053
1708
  <div class="space-y-2">
1054
- {#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field, _i (_i)}
1709
+ {#each visibleFields.filter((field) => isColumnVisible(field.key)).slice(0, 6) as field (field.key)}
1055
1710
  {@const value = record[field.key]}
1056
1711
  <div class="flex items-start justify-between gap-4">
1057
1712
  <span class="text-xs font-medium text-muted-foreground shrink-0">{field.label}</span>
@@ -1086,7 +1741,7 @@
1086
1741
  {#if emptyState}
1087
1742
  {@render emptyState()}
1088
1743
  {:else}
1089
- {i18n.t('common.noData')}
1744
+ <DataState state="empty" class="border-0 bg-transparent px-2 py-4" />
1090
1745
  {/if}
1091
1746
  </div>
1092
1747
  {/each}
@@ -1100,12 +1755,14 @@
1100
1755
  <div class="flex flex-col sm:flex-row items-center justify-between gap-3 px-1 py-2 text-sm text-muted-foreground">
1101
1756
  <div class="flex shrink-0 items-center gap-3 leading-8">
1102
1757
  <span class="shrink-0 whitespace-nowrap">{i18n.t('common.total', { total: query.data?.total ?? 0 })}</span>
1103
- <select
1104
- class="h-8 w-[70px] shrink-0 px-1 py-1 flex items-center justify-between rounded-md border border-input bg-background text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
1758
+ <Select.Root
1759
+ aria-label={i18n.t('common.perPage')}
1760
+ class="h-8 w-[78px] shrink-0"
1105
1761
  value={String(pagination.pageSize ?? 10)}
1106
1762
  onchange={(e) => {
1107
- const size = Number((e.target as HTMLSelectElement).value);
1763
+ const size = Number((e.currentTarget as HTMLSelectElement).value);
1108
1764
  if (!isNaN(size)) {
1765
+ markSavedViewDirty();
1109
1766
  pagination = { ...pagination, pageSize: size, current: 1 };
1110
1767
  }
1111
1768
  }}
@@ -1114,7 +1771,7 @@
1114
1771
  <option value="20">20</option>
1115
1772
  <option value="50">50</option>
1116
1773
  <option value="100">100</option>
1117
- </select>
1774
+ </Select.Root>
1118
1775
  </div>
1119
1776
  <PaginationUI.Root>
1120
1777
  <PaginationUI.Content>
@@ -1154,6 +1811,16 @@
1154
1811
  open={confirmOpen}
1155
1812
  message={confirmMessage}
1156
1813
  confirmText={i18n.t('common.delete')}
1814
+ confirming={confirmPending}
1157
1815
  onconfirm={confirmAction}
1158
1816
  oncancel={() => { confirmOpen = false; }}
1159
1817
  />
1818
+
1819
+ {#if detailRecordId != null}
1820
+ <RecordDetailDrawer
1821
+ resourceName={resourceName}
1822
+ open={detailOpen}
1823
+ recordId={detailRecordId}
1824
+ onClose={closeDetail}
1825
+ />
1826
+ {/if}