Files
portal/src/lib/components/admin/AdminTable.svelte
T
NeilClack 1d11de8cec Refactor admin to shared generic CRUD; restructure proclamation categories/groups and APIs
- Replace per-model admin forms with the shared generic AdminTable/AdminModal CRUD
- Add ProclamationGroup model and admin + API v2 routes (replaces subscription levels)
- Drop subscription levels and flatten proclamation categories
- Update v2 serializers and seed data accordingly
2026-08-27 12:03:40 -05:00

216 lines
5.3 KiB
Svelte

<script lang="ts">
import type { PrismaScalarType } from '$lib/svelte-admin/types';
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
let {
records = [],
listFields = [],
searchFields = [],
fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record',
draggable = false,
copyableFields = [] as string[],
onRowClick,
onNew,
onReorder
}: {
records: Record<string, unknown>[];
listFields: string[];
searchFields?: string[];
fieldTypes?: Record<string, PrismaScalarType>;
singular?: string;
draggable?: boolean;
copyableFields?: string[];
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
let sortCol = $state<string | null>(null);
let sortAsc = $state(true);
let filtered = $derived(
search
? records.filter((r) =>
searchFields.some((f) => {
const v = resolveFieldValue(r, f);
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
})
)
: records
);
let sorted = $derived(
sortCol
? [...filtered].sort((a, b) => {
const va = resolveFieldValue(a, sortCol!);
const vb = resolveFieldValue(b, sortCol!);
if (va == null) return 1;
if (vb == null) return -1;
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortAsc ? cmp : -cmp;
})
: filtered
);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let rows = $derived(dragOverride ?? sorted);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
function toggleSort(field: string) {
if (sortCol === field) {
sortAsc = !sortAsc;
} else {
sortCol = field;
sortAsc = true;
}
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = sorted;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
dragIndex = null;
hoverIndex = null;
}
function copyValue(v: unknown) {
navigator.clipboard.writeText(String(v ?? ''));
}
function formatCellValue(v: unknown, field: string): string {
if (v == null) return '';
const ft = fieldTypes[field];
if (ft === 'DateTime') {
if (v instanceof Date) return v.toLocaleString();
return new Date(v as string).toLocaleString();
}
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
if (ft === 'Float') {
const n = Number(v);
return Number.isNaN(n) ? String(v) : n.toFixed(2);
}
return String(v);
}
</script>
<div>
<div>
{#if searchFields.length > 0}
<input type="text" placeholder="Search {singular}s..." bind:value={search} />
{:else}
<div></div>
{/if}
{#if onNew}
<button type="button" onclick={onNew}>
New {singular} +
</button>
{/if}
</div>
<div>
<table>
<thead>
<tr>
{#if draggable}
<th scope="col"></th>
{/if}
{#each listFields as field (field)}
<th scope="col" onclick={() => toggleSort(field)}>
{fieldNameToLabel(field)}
{#if sortCol === field}
<span>{sortAsc ? '\u25B2' : '\u25BC'}</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
<tr
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
>
{#if draggable}
<td onclick={(e) => e.stopPropagation()}>
<span>⠿</span>
</td>
{/if}
{#each listFields as field (field)}
<td>
<span>
{formatCellValue(resolveFieldValue(record, field), field)}
{#if copyableFields.includes(field)}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(resolveFieldValue(record, field));
}}
title="Copy to clipboard"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
{/if}
</span>
</td>
{/each}
</tr>
{:else}
<tr>
<td colspan={listFields.length + (draggable ? 1 : 0)}>
No {singular}s found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>