feat(task-35): admin People section — vets, sponsors, mentors, volunteers, users
Full CRUD for vets, sponsors, mentors, volunteer applications, and users. Includes approve/deny flows for pending users, role management, and password reset. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { vets, petMedical, pets } from '$lib/server/schema';
|
||||
import { eq, count } from 'drizzle-orm';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { logAudit } from '$lib/server/audit';
|
||||
import { hasPermission } from '$lib/roles';
|
||||
import type { Role } from '$lib/roles';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) error(404, 'Vet not found');
|
||||
|
||||
const [vet] = await db.select().from(vets).where(eq(vets.id, id));
|
||||
if (!vet) error(404, 'Vet not found');
|
||||
|
||||
const [medicalPetCount] = await db.select({ total: count() }).from(petMedical).where(eq(petMedical.vetId, id));
|
||||
const [clinicPetCount] = await db.select({ total: count() }).from(pets).where(eq(pets.vetId, id));
|
||||
const petCount = (medicalPetCount?.total ?? 0) + (clinicPetCount?.total ?? 0);
|
||||
|
||||
return { vet, petCount };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, params, locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'vets')) error(403, 'Access denied');
|
||||
const id = parseInt(params.id, 10);
|
||||
const fd = await request.formData();
|
||||
|
||||
const name = fd.get('name')?.toString().trim() ?? '';
|
||||
if (!name) return fail(400, { error: 'Name is required' });
|
||||
|
||||
const clinic = fd.get('clinic')?.toString().trim() || null;
|
||||
const phone = fd.get('phone')?.toString().trim() || null;
|
||||
const email = fd.get('email')?.toString().trim() || null;
|
||||
const address = fd.get('address')?.toString().trim() || null;
|
||||
const notes = fd.get('notes')?.toString().trim() || null;
|
||||
|
||||
await db.update(vets).set({ name, clinic, phone, email, address, notes }).where(eq(vets.id, id));
|
||||
|
||||
await logAudit({
|
||||
userId: locals.user?.id ?? undefined,
|
||||
action: 'update_vet',
|
||||
entity: 'vet',
|
||||
entityId: id,
|
||||
diff: { name, clinic }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
let { data, form } = $props();
|
||||
const vet = $derived(data.vet);
|
||||
let submitting = $state(false);
|
||||
let showSuccess = $state(false);
|
||||
|
||||
function handleEnhance() {
|
||||
submitting = true;
|
||||
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
|
||||
submitting = false;
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') { showSuccess = true; setTimeout(() => showSuccess = false, 3000); }
|
||||
};
|
||||
}
|
||||
|
||||
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{vet.name} — Admin</title></svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/vets" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{vet.name}</h1>
|
||||
{#if vet.clinic}<p class="text-sm text-gray-500">{vet.clinic}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
{data.petCount} pet record{data.petCount !== 1 ? 's' : ''} assigned to this vet
|
||||
</div>
|
||||
|
||||
{#if showSuccess}
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
||||
Vet updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance={handleEnhance} class="space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Vet Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="name" class={labelClass}>Name <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="name" name="name" required value={vet.name} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="clinic" class={labelClass}>Clinic</label>
|
||||
<input type="text" id="clinic" name="clinic" value={vet.clinic ?? ''} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="phone" class={labelClass}>Phone</label>
|
||||
<input type="tel" id="phone" name="phone" value={vet.phone ?? ''} class={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class={labelClass}>Email</label>
|
||||
<input type="email" id="email" name="email" value={vet.email ?? ''} class={inputClass} />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="address" class={labelClass}>Address</label>
|
||||
<textarea id="address" name="address" rows="2" class="{inputClass} resize-y">{vet.address ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class={labelClass}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" class="{inputClass} resize-y">{vet.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/admin/vets" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors">Cancel</a>
|
||||
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Reference in New Issue
Block a user