Commit 1: Create individual form components per route, remove generic AdminForm dependency
- Add per-route form components: UserRoleForm, UserForm, ProclamationForm (with category cascade), CategoryForm, FreqForm, LevelForm - Update all admin +page.svelte files to use AdminTable + AdminModal + their specific form component instead of AdminPage - Update profiles page to inline fields instead of using AdminForm - Add custom Proclamation server action with category fallback logic (categoryId falls back to parentCategoryId when no sub-category) - Remove cascade/were/fieldLabels logic from generic crud.ts - Remove outdated cascade/where tests from crud.spec.ts - Remove fieldLabels from AdminModelConfig type
This commit is contained in:
@@ -6,38 +6,44 @@ import { getModelConfig } from './registry';
|
|||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
|
|
||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => {
|
||||||
prisma: {
|
const mkdel = () => ({
|
||||||
userRole: {
|
findMany: vi.fn(),
|
||||||
findMany: vi.fn(),
|
create: vi.fn(),
|
||||||
create: vi.fn(),
|
update: vi.fn(),
|
||||||
update: vi.fn(),
|
delete: vi.fn(),
|
||||||
delete: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findFirst: vi.fn()
|
||||||
findFirst: vi.fn()
|
});
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
userRole: mkdel(),
|
||||||
|
proclamation: mkdel(),
|
||||||
|
proclamationCategory: mkdel(),
|
||||||
|
solfeggioFrequency: mkdel(),
|
||||||
|
subscriptionLevel: mkdel()
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { loadRecords, upsertRecord, deleteRecord } from './crud';
|
import { loadRecords, upsertRecord, deleteRecord } from './crud';
|
||||||
|
|
||||||
const findMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
|
const userRoleFindMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
|
||||||
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
|
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
|
||||||
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
|
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
|
||||||
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
|
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
|
||||||
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
|
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
|
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const config = getModelConfig('user-roles')!;
|
const config = getModelConfig('user-roles')!;
|
||||||
|
|
||||||
describe('crud loadRecords', () => {
|
describe('crud loadRecords', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
findMany.mockReset();
|
userRoleFindMany.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns records', async () => {
|
it('returns records', async () => {
|
||||||
findMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
|
userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const result = await loadRecords(locals, config);
|
const result = await loadRecords(locals, config);
|
||||||
@@ -46,7 +52,7 @@ describe('crud loadRecords', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty records array', async () => {
|
it('returns empty records array', async () => {
|
||||||
findMany.mockResolvedValue([]);
|
userRoleFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const result = await loadRecords(locals, config);
|
const result = await loadRecords(locals, config);
|
||||||
@@ -127,6 +133,64 @@ describe('crud deleteRecord', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('P2002 unique constraint handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
create.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error with field names when meta.target is populated', async () => {
|
||||||
|
const p2002 = new Error('Unique constraint failed');
|
||||||
|
(p2002 as any).code = 'P2002';
|
||||||
|
(p2002 as any).meta = { target: ['name'], modelName: 'UserRole' };
|
||||||
|
create.mockRejectedValue(p2002);
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('name', 'Admin');
|
||||||
|
const locals = createLocals(mockUser() as any);
|
||||||
|
const result = await upsertRecord(fd, config, locals);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'A record with this name already exists.',
|
||||||
|
fieldErrors: { name: 'Already taken' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns generic message when meta.target is empty', async () => {
|
||||||
|
const p2002 = new Error('Unique constraint failed');
|
||||||
|
(p2002 as any).code = 'P2002';
|
||||||
|
(p2002 as any).meta = { target: [], modelName: 'UserRole' };
|
||||||
|
create.mockRejectedValue(p2002);
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('name', 'Admin');
|
||||||
|
const locals = createLocals(mockUser() as any);
|
||||||
|
const result = await upsertRecord(fd, config, locals);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'A database constraint violation occurred. The record could not be created.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns generic message when meta.target is missing', async () => {
|
||||||
|
const p2002 = new Error('Unique constraint failed');
|
||||||
|
(p2002 as any).code = 'P2002';
|
||||||
|
(p2002 as any).meta = { modelName: 'UserRole' };
|
||||||
|
create.mockRejectedValue(p2002);
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set('name', 'Admin');
|
||||||
|
const locals = createLocals(mockUser() as any);
|
||||||
|
const result = await upsertRecord(fd, config, locals);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'A database constraint violation occurred. The record could not be created.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sveltekit-superforms import compatibility', () => {
|
describe('sveltekit-superforms import compatibility', () => {
|
||||||
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
|
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
|
||||||
expect(superValidate).toBeDefined();
|
expect(superValidate).toBeDefined();
|
||||||
@@ -151,17 +215,11 @@ describe('prisma client version consistency', () => {
|
|||||||
it('generated client references existing query compiler files', async () => {
|
it('generated client references existing query compiler files', async () => {
|
||||||
const fs = await import('fs');
|
const fs = await import('fs');
|
||||||
const path = await import('path');
|
const path = await import('path');
|
||||||
const runtimeDir = path.resolve(
|
const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
|
||||||
'node_modules/@prisma/client/runtime'
|
|
||||||
);
|
|
||||||
|
|
||||||
const generatedFile = path.resolve(
|
const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
|
||||||
'src/lib/generated/prisma/internal/class.ts'
|
|
||||||
);
|
|
||||||
const content = fs.readFileSync(generatedFile, 'utf-8');
|
const content = fs.readFileSync(generatedFile, 'utf-8');
|
||||||
const compilerMatch = content.match(
|
const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
|
||||||
/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/
|
|
||||||
);
|
|
||||||
expect(compilerMatch).not.toBeNull();
|
expect(compilerMatch).not.toBeNull();
|
||||||
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
|
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
|
||||||
expect(fs.existsSync(compilerPath)).toBe(true);
|
expect(fs.existsSync(compilerPath)).toBe(true);
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
|||||||
});
|
});
|
||||||
|
|
||||||
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
|
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
|
||||||
// For cascade child fields: maps field -> { childValue -> parentValue }
|
|
||||||
const cascadeOptionParentMap: Record<string, Record<string | number, string | number>> = {};
|
|
||||||
|
|
||||||
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
|
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
|
||||||
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
|
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
|
||||||
@@ -61,13 +59,6 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
|||||||
[relConfig.valueField]: true,
|
[relConfig.valueField]: true,
|
||||||
[relConfig.labelField]: true
|
[relConfig.labelField]: true
|
||||||
};
|
};
|
||||||
// Check if this field is a cascade child (needs parentId)
|
|
||||||
const cascadeEntry = Object.entries(config.cascadeFields ?? {}).find(
|
|
||||||
([, c]) => c.parentField === field
|
|
||||||
);
|
|
||||||
if (cascadeEntry) {
|
|
||||||
selectFields['parentId'] = true;
|
|
||||||
}
|
|
||||||
const options = await relDelegate.findMany({
|
const options = await relDelegate.findMany({
|
||||||
select: selectFields,
|
select: selectFields,
|
||||||
orderBy: { [relConfig.labelField]: 'asc' }
|
orderBy: { [relConfig.labelField]: 'asc' }
|
||||||
@@ -77,27 +68,11 @@ export async function loadRecords(locals: App.Locals, config: AdminModelConfig)
|
|||||||
value: opt[relConfig.valueField] as string | number
|
value: opt[relConfig.valueField] as string | number
|
||||||
}));
|
}));
|
||||||
relationOptionsData[field] = mapped;
|
relationOptionsData[field] = mapped;
|
||||||
|
|
||||||
// Build parent map if cascade child
|
|
||||||
if (cascadeEntry) {
|
|
||||||
const childField = cascadeEntry[0];
|
|
||||||
const parentMap: Record<string | number, string | number> = {};
|
|
||||||
const rows = options as Record<string, unknown>[];
|
|
||||||
for (const row of rows) {
|
|
||||||
const childVal = row[relConfig.valueField] as string | number;
|
|
||||||
const parentVal = row['parentId'] as number | null;
|
|
||||||
if (parentVal != null) {
|
|
||||||
parentMap[childVal] = parentVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cascadeOptionParentMap[childField] = parentMap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records: records as Record<string, unknown>[],
|
records: records as Record<string, unknown>[],
|
||||||
relationOptionsData,
|
relationOptionsData
|
||||||
cascadeOptionParentMap
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +81,11 @@ export async function upsertRecord(
|
|||||||
config: AdminModelConfig,
|
config: AdminModelConfig,
|
||||||
locals: App.Locals
|
locals: App.Locals
|
||||||
) {
|
) {
|
||||||
|
const formData =
|
||||||
|
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
||||||
|
|
||||||
const schema = modelSchemas[config.modelName];
|
const schema = modelSchemas[config.modelName];
|
||||||
const form = schema ? await superValidate(requestOrFormData, zod4(schema)) : null;
|
const form = schema ? await superValidate(formData, zod4(schema)) : null;
|
||||||
|
|
||||||
if (form && !form.valid) {
|
if (form && !form.valid) {
|
||||||
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||||
@@ -116,8 +94,6 @@ export async function upsertRecord(
|
|||||||
return { ...form, success: false, error, fieldErrors };
|
return { ...form, success: false, error, fieldErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData =
|
|
||||||
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
|
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
const data =
|
const data =
|
||||||
form?.data ??
|
form?.data ??
|
||||||
@@ -163,8 +139,20 @@ export async function upsertRecord(
|
|||||||
await delegate.create({ data });
|
await delegate.create({ data });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||||
const meta = (e as { meta?: { target?: string[] } }).meta;
|
const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta;
|
||||||
const fields = meta?.target ?? [];
|
const fields = meta?.target ?? [];
|
||||||
|
console.error(
|
||||||
|
`[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`,
|
||||||
|
data,
|
||||||
|
`meta.target=`,
|
||||||
|
meta?.target
|
||||||
|
);
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'A database constraint violation occurred. The record could not be created.'
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `A record with this ${fields.join(', ')} already exists.`,
|
error: `A record with this ${fields.join(', ')} already exists.`,
|
||||||
|
|||||||
@@ -102,12 +102,7 @@ const models: AdminModelConfig[] = [
|
|||||||
category: { include: { parent: true } },
|
category: { include: { parent: true } },
|
||||||
subscriptionLevel: true
|
subscriptionLevel: true
|
||||||
},
|
},
|
||||||
listFields: [
|
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
|
||||||
'name',
|
|
||||||
'category.name',
|
|
||||||
'solfeggioFrequency.name',
|
|
||||||
'subscriptionLevel.name'
|
|
||||||
],
|
|
||||||
searchFields: ['name', 'content'],
|
searchFields: ['name', 'content'],
|
||||||
formFields: [
|
formFields: [
|
||||||
'name',
|
'name',
|
||||||
|
|||||||
@@ -1,8 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import CategoryForm from './CategoryForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('proclamation-categories')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="proclamation-categories" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<CategoryForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
relationOptionsData={data.relationOptionsData}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let name = $state(String(record.name ?? ''));
|
||||||
|
let parentId = $state(String(record.parentId ?? ''));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-name"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-parentId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Parent Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-parentId"
|
||||||
|
name="parentId"
|
||||||
|
bind:value={parentId}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- No Parent --</option>
|
||||||
|
{#each relationOptionsData.parentId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.parentId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -1,16 +1,73 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
|
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
|
import * as Schemas from '$lib/svelte-admin/schemas';
|
||||||
|
|
||||||
const config = getModelConfig('proclamations')!;
|
const config = getModelConfig('proclamations')!;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
return loadRecords(locals, config);
|
const base = await loadRecords(locals, config);
|
||||||
|
|
||||||
|
const categories = await prisma.proclamationCategory.findMany({
|
||||||
|
select: { id: true, name: true, parentId: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...base, categories };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
upsert: async ({ request, locals }) => {
|
upsert: async ({ request, locals }) => {
|
||||||
return upsertRecord(request, config, locals);
|
if (!locals.user) {
|
||||||
|
return { success: false, error: 'Unauthorized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const form = await superValidate(formData, zod4(Schemas.Proclamation));
|
||||||
|
if (!form.valid) {
|
||||||
|
const { error, fieldErrors } = superFormErrorsToFieldErrors(
|
||||||
|
form.errors as Record<string, unknown>
|
||||||
|
);
|
||||||
|
return { success: false, error, fieldErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawId = formData.get('id')?.toString();
|
||||||
|
const { parentCategoryId, ...rest } = form.data;
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
...rest,
|
||||||
|
categoryId: rest.categoryId ?? parentCategoryId
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rawId) {
|
||||||
|
const existing = await prisma.proclamation.findUnique({
|
||||||
|
where: { id: rawId }
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
return { success: false, error: 'Record not found' };
|
||||||
|
}
|
||||||
|
await prisma.proclamation.update({
|
||||||
|
where: { id: rawId },
|
||||||
|
data: data as any
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.proclamation.create({ data: data as any });
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'A record with this name already exists.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
},
|
},
|
||||||
delete: async ({ request, locals }) => {
|
delete: async ({ request, locals }) => {
|
||||||
return deleteRecord(request, config, locals);
|
return deleteRecord(request, config, locals);
|
||||||
|
|||||||
@@ -1,8 +1,92 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import ProclamationForm from './ProclamationForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('proclamations')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="proclamations" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<ProclamationForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
relationOptionsData={data.relationOptionsData}
|
||||||
|
categories={data.categories}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import TipTap from '$lib/components/tiptap/TipTap.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
|
||||||
|
categories = [] as { id: number; name: string; parentId: number | null }[],
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
|
||||||
|
categories?: { id: number; name: string; parentId: number | null }[];
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = $state(String(record.name ?? ''));
|
||||||
|
let solfeggioFrequencyId = $state(String(record.solfeggioFrequencyId ?? ''));
|
||||||
|
let subscriptionLevelId = $state(String(record.subscriptionLevelId ?? ''));
|
||||||
|
|
||||||
|
let parentCategoryId = $state(
|
||||||
|
String(
|
||||||
|
record.parentCategoryId ??
|
||||||
|
(record.category as Record<string, unknown> | undefined)?.parentId ??
|
||||||
|
''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let categoryId = $state(String(record.categoryId ?? ''));
|
||||||
|
|
||||||
|
let parentOptions = $derived(categories.filter((c) => c.parentId === null));
|
||||||
|
|
||||||
|
let childOptions = $derived(
|
||||||
|
parentCategoryId ? categories.filter((c) => c.parentId === Number(parentCategoryId)) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleParentChange(e: Event) {
|
||||||
|
const target = e.target as HTMLSelectElement;
|
||||||
|
parentCategoryId = target.value;
|
||||||
|
categoryId = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-name"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-solfeggioFrequencyId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Frequency
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-solfeggioFrequencyId"
|
||||||
|
name="solfeggioFrequencyId"
|
||||||
|
bind:value={solfeggioFrequencyId}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Frequency --</option>
|
||||||
|
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.solfeggioFrequencyId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-parentCategoryId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-parentCategoryId"
|
||||||
|
name="parentCategoryId"
|
||||||
|
value={parentCategoryId}
|
||||||
|
onchange={handleParentChange}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Category --</option>
|
||||||
|
{#each parentOptions as cat (String(cat.id))}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.parentCategoryId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentCategoryId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-categoryId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Sub-Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-categoryId"
|
||||||
|
name="categoryId"
|
||||||
|
bind:value={categoryId}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Sub-Category --</option>
|
||||||
|
{#each childOptions as cat (String(cat.id))}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.categoryId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.categoryId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-subscriptionLevelId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Subscription Level
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-subscriptionLevelId"
|
||||||
|
name="subscriptionLevelId"
|
||||||
|
bind:value={subscriptionLevelId}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Level --</option>
|
||||||
|
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.subscriptionLevelId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-content"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</label>
|
||||||
|
<div id="field-content">
|
||||||
|
<TipTap
|
||||||
|
initialContent={typeof record.content === 'string' ? record.content : ''}
|
||||||
|
name="content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if fieldErrors.content}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -3,14 +3,13 @@
|
|||||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import AdminForm from '$lib/components/admin/AdminForm.svelte';
|
|
||||||
import Toast from '$lib/components/admin/Toast.svelte';
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
let config = $derived(getModelConfig('profiles')!);
|
const config = getModelConfig('profiles')!;
|
||||||
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
@@ -135,18 +134,110 @@
|
|||||||
{canDelete}
|
{canDelete}
|
||||||
recordId={editingRecord?.id as string | undefined}
|
recordId={editingRecord?.id as string | undefined}
|
||||||
>
|
>
|
||||||
<AdminForm
|
<form method="POST" action="?/upsert" use:enhance>
|
||||||
fields={config.formFields}
|
{#if editingRecord?.id}
|
||||||
fieldTypes={config.fieldTypes}
|
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||||
relationOptions={config.relationOptions}
|
{/if}
|
||||||
relationOptionsData={data.relationOptionsData}
|
{#if formError}
|
||||||
record={editingRecord ?? {}}
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
readonlyFields={config.readonlyFields ?? []}
|
{formError}
|
||||||
action="?/upsert"
|
</div>
|
||||||
submitLabel={editingRecord ? 'Update' : 'Create'}
|
{/if}
|
||||||
{fieldErrors}
|
<div class="flex flex-col gap-4">
|
||||||
{formError}
|
<div>
|
||||||
/>
|
<label
|
||||||
|
for="field-userId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
User
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-userId"
|
||||||
|
name="userId"
|
||||||
|
value={String(editingRecord?.userId ?? '')}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select User --</option>
|
||||||
|
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.userId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-firstName"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={String(editingRecord?.firstName ?? '')}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.firstName}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-lastName"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={String(editingRecord?.lastName ?? '')}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.lastName}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-phoneNumber"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-phoneNumber"
|
||||||
|
name="phoneNumber"
|
||||||
|
value={String(editingRecord?.phoneNumber ?? '')}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.phoneNumber}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{editingRecord ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
{#if editingRecord}
|
{#if editingRecord}
|
||||||
{@const user = editingRecord.user as Record<string, unknown> | undefined}
|
{@const user = editingRecord.user as Record<string, unknown> | undefined}
|
||||||
|
|||||||
@@ -1,8 +1,90 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import FreqForm from './FreqForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('solfeggio-frequencies')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="solfeggio-frequencies" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<FreqForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let name = $state(String(record.name ?? ''));
|
||||||
|
let value = $state(String(record.value ?? ''));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-name"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-value"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="field-value"
|
||||||
|
name="value"
|
||||||
|
bind:value
|
||||||
|
step="1"
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.value
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.value}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.value}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -1,8 +1,90 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import LevelForm from './LevelForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('subscription-levels')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="subscription-levels" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<LevelForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let name = $state(String(record.name ?? ''));
|
||||||
|
let description = $state(String(record.description ?? ''));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-name"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-description"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-description"
|
||||||
|
name="description"
|
||||||
|
bind:value={description}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.description
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.description}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -1,8 +1,90 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import UserRoleForm from './UserRoleForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('user-roles')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="user-roles" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<UserRoleForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let name = $state(String(record.name ?? ''));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-name"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="field-name"
|
||||||
|
name="name"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if fieldErrors.name}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -1,8 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte';
|
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||||
|
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||||
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
|
import Toast from '$lib/components/admin/Toast.svelte';
|
||||||
|
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||||
|
import UserForm from './UserForm.svelte';
|
||||||
|
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
|
|
||||||
|
const config = getModelConfig('users')!;
|
||||||
|
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||||
|
|
||||||
|
let modalOpen = $state(false);
|
||||||
|
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||||
|
let toastMessage = $state('');
|
||||||
|
let toastType = $state<'success' | 'error'>('success');
|
||||||
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
|
let formError = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form && 'success' in (form as any)) {
|
||||||
|
const result = form as any;
|
||||||
|
if (result.success) {
|
||||||
|
toastMessage = 'Saved successfully';
|
||||||
|
toastType = 'success';
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
formError = result.error ?? '';
|
||||||
|
fieldErrors = result.fieldErrors ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
editingRecord = record;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
modalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen = false;
|
||||||
|
editingRecord = null;
|
||||||
|
formError = '';
|
||||||
|
fieldErrors = {};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage modelSlug="users" {data} {form} adminRoleId={data.adminUser?.roleId ?? 4} />
|
<div class="p-6">
|
||||||
|
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||||
|
<AdminTable
|
||||||
|
records={data.records}
|
||||||
|
listFields={config.listFields}
|
||||||
|
searchFields={config.searchFields}
|
||||||
|
fieldTypes={config.fieldTypes}
|
||||||
|
singular={config.displayNameSingular}
|
||||||
|
onRowClick={openEdit}
|
||||||
|
onNew={config.listOnly ? undefined : openCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminModal
|
||||||
|
title={editingRecord
|
||||||
|
? `Edit ${config.displayNameSingular}`
|
||||||
|
: `Create ${config.displayNameSingular}`}
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
{canDelete}
|
||||||
|
recordId={editingRecord?.id as string | undefined}
|
||||||
|
>
|
||||||
|
<UserForm
|
||||||
|
record={editingRecord ?? {}}
|
||||||
|
relationOptionsData={data.relationOptionsData}
|
||||||
|
{fieldErrors}
|
||||||
|
{formError}
|
||||||
|
submitLabel={editingRecord ? 'Update' : 'Create'}
|
||||||
|
/>
|
||||||
|
</AdminModal>
|
||||||
|
|
||||||
|
{#if toastMessage}
|
||||||
|
<Toast message={toastMessage} type={toastType} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
formError = '',
|
||||||
|
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
|
||||||
|
action = '?/upsert',
|
||||||
|
submitLabel = 'Save'
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
formError?: string;
|
||||||
|
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
|
||||||
|
action?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let roleId = $state(String(record.roleId ?? ''));
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
if (record.id) dirty = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form {action} method="POST" use:enhance oninput={markDirty}>
|
||||||
|
{#if record?.id}
|
||||||
|
<input type="hidden" name="id" value={String(record.id)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if formError}
|
||||||
|
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="field-roleId"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="field-roleId"
|
||||||
|
name="roleId"
|
||||||
|
bind:value={roleId}
|
||||||
|
class="w-full border px-3 py-1.5 text-sm {fieldErrors.roleId
|
||||||
|
? 'border-red-400'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Role --</option>
|
||||||
|
{#each relationOptionsData.roleId ?? [] as opt (String(opt.value))}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if fieldErrors.roleId}
|
||||||
|
<p class="mt-1 text-xs text-red-600">{fieldErrors.roleId}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!!record?.id && !dirty}
|
||||||
|
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
Reference in New Issue
Block a user