profiles: add address management, user edit, fix form redirect
- Add type (Mailing/Billing/Shipping) and isDefault fields to Address schema with migration. Unique default enforced server-side. - Rewrite profiles page with inline address creation form and editable user name/email within the profile modal. - Add upsertAddress and updateUser form actions. - Fix form redirect by adding use:enhance to AdminForm. - Disable Update button in AdminForm until a field is changed (prevents accidental submissions).
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
<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',
|
||||
onRowClick,
|
||||
onNew
|
||||
}: {
|
||||
records: Record<string, unknown>[];
|
||||
listFields: string[];
|
||||
searchFields?: string[];
|
||||
fieldTypes?: Record<string, PrismaScalarType>;
|
||||
singular?: string;
|
||||
onRowClick?: (record: Record<string, unknown>) => void;
|
||||
onNew?: () => 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
|
||||
);
|
||||
|
||||
function toggleSort(field: string) {
|
||||
if (sortCol === field) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortCol = field;
|
||||
sortAsc = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
{#if searchFields.length > 0}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search {singular}s..."
|
||||
bind:value={search}
|
||||
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
{#if onNew}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onNew}
|
||||
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
New {singular} +
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each listFields as field (field)}
|
||||
<th
|
||||
scope="col"
|
||||
onclick={() => toggleSort(field)}
|
||||
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
{fieldNameToLabel(field)}
|
||||
{#if sortCol === field}
|
||||
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
|
||||
<tr
|
||||
onclick={() => onRowClick?.(record)}
|
||||
class="cursor-pointer border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{#each listFields as field (field)}
|
||||
<td class="px-4 py-2 text-sm">
|
||||
{formatCellValue(resolveFieldValue(record, field), field)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td
|
||||
colspan={listFields.length}
|
||||
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No {singular}s found.
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user