Add self-referential parent/child relation on AffirmationCategory for one-level hierarchy. Update AffirmationForm with two-level category and sub-category dropdowns, server actions with find-or-create hierarchy logic, AffirmationList with Parent
157 lines
4.4 KiB
Svelte
157 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
|
|
|
|
type Props = {
|
|
records: Record<string, unknown>[];
|
|
listFields: string[];
|
|
searchFields: string[];
|
|
modelSlug: string;
|
|
listOnly?: boolean;
|
|
};
|
|
|
|
let { records, listFields, searchFields, modelSlug, listOnly = false }: Props = $props();
|
|
|
|
let search = $state('');
|
|
let defaultSortKey = $derived(listFields[0] ?? '');
|
|
let sortKey = $state('');
|
|
let sortDirection = $state<'asc' | 'desc'>('asc');
|
|
let effectiveSortKey = $derived(sortKey || defaultSortKey);
|
|
|
|
function formatCell(value: unknown): string {
|
|
if (value == null) return '—';
|
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
if (value instanceof Date) return value.toLocaleString();
|
|
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
if (typeof value === 'number' && !Number.isInteger(value)) return value.toFixed(2);
|
|
return String(value);
|
|
}
|
|
|
|
const filteredAndSorted = $derived(() => {
|
|
let result = records;
|
|
|
|
if (search) {
|
|
const searchLower = search.toLowerCase();
|
|
result = result.filter((r) =>
|
|
searchFields.some((field) => {
|
|
const val = resolveFieldValue(r, field);
|
|
return val != null && String(val).toLowerCase().includes(searchLower);
|
|
})
|
|
);
|
|
}
|
|
|
|
result = [...result].sort((a, b) => {
|
|
const aVal = resolveFieldValue(a, effectiveSortKey);
|
|
const bVal = resolveFieldValue(b, effectiveSortKey);
|
|
const aStr = aVal == null ? '' : String(aVal).toLowerCase();
|
|
const bStr = bVal == null ? '' : String(bVal).toLowerCase();
|
|
const aNum = Number(aVal);
|
|
const bNum = Number(bVal);
|
|
|
|
if (!isNaN(aNum) && !isNaN(bNum)) {
|
|
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
|
|
}
|
|
if (aStr < bStr) return sortDirection === 'asc' ? -1 : 1;
|
|
if (aStr > bStr) return sortDirection === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
function toggleSort(key: string) {
|
|
if (effectiveSortKey === key) {
|
|
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
sortKey = key;
|
|
sortDirection = 'asc';
|
|
}
|
|
}
|
|
|
|
function getSortIndicator(key: string) {
|
|
if (effectiveSortKey !== key) return '↕';
|
|
return sortDirection === 'asc' ? '↑' : '↓';
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
bind:value={search}
|
|
class="rounded-lg border border-gray-300 px-3 py-2 text-sm transition-shadow outline-none focus:border-blue-500 focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
|
|
{#if search}
|
|
<button
|
|
type="button"
|
|
onclick={() => (search = '')}
|
|
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
|
>
|
|
Clear
|
|
</button>
|
|
{/if}
|
|
|
|
<span class="ml-auto text-sm text-gray-500">
|
|
{filteredAndSorted().length} of {records.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full border-collapse text-left text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-200 bg-gray-50">
|
|
{#each listFields as field}
|
|
<th class="px-4 py-3 font-medium text-gray-700">
|
|
<button
|
|
type="button"
|
|
class="flex items-center gap-1 hover:text-gray-900"
|
|
onclick={() => toggleSort(field)}
|
|
>
|
|
{fieldNameToLabel(field.includes('.') ? field.split('.')[0] : field)}
|
|
<span class="text-xs text-gray-400">{getSortIndicator(field)}</span>
|
|
</button>
|
|
</th>
|
|
{/each}
|
|
{#if !listOnly}
|
|
<th class="px-4 py-3 font-medium text-gray-700"></th>
|
|
{/if}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filteredAndSorted() as record}
|
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
|
{#each listFields as field}
|
|
<td class="px-4 py-3 text-gray-600">
|
|
{formatCell(resolveFieldValue(record, field))}
|
|
</td>
|
|
{/each}
|
|
{#if !listOnly}
|
|
<td class="px-4 py-3 text-right">
|
|
<a
|
|
href="/admin/{modelSlug}/{record.id}"
|
|
class="text-blue-600 hover:text-blue-800 hover:underline"
|
|
>
|
|
Edit
|
|
</a>
|
|
</td>
|
|
{/if}
|
|
</tr>
|
|
{:else}
|
|
<tr>
|
|
<td colspan={listFields.length + (listOnly ? 0 : 1)} class="px-4 py-8 text-center text-gray-500">
|
|
{#if search}
|
|
No records match your search
|
|
{:else}
|
|
No records yet
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|