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,43 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { applications } from '$lib/server/schema';
|
||||
import { eq, and, count, desc } from 'drizzle-orm';
|
||||
import { hasPermission } from '$lib/roles';
|
||||
import type { Role } from '$lib/roles';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied');
|
||||
|
||||
const status = url.searchParams.get('status') ?? '';
|
||||
const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
|
||||
const offset = (page - 1) * PER_PAGE;
|
||||
|
||||
const conditions = [eq(applications.type, 'volunteer') as ReturnType<typeof eq>];
|
||||
if (status) conditions.push(eq(applications.status, status as typeof applications.status.enumValues[number]) as ReturnType<typeof eq>);
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const [totalRow] = await db.select({ total: count() }).from(applications).where(where);
|
||||
const total = totalRow?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
|
||||
|
||||
const volunteers = await db
|
||||
.select({
|
||||
id: applications.id,
|
||||
applicantName: applications.applicantName,
|
||||
email: applications.applicantEmail,
|
||||
phone: applications.applicantPhone,
|
||||
status: applications.status,
|
||||
createdAt: applications.createdAt
|
||||
})
|
||||
.from(applications)
|
||||
.where(where)
|
||||
.orderBy(desc(applications.createdAt))
|
||||
.limit(PER_PAGE)
|
||||
.offset(offset);
|
||||
|
||||
return { volunteers, total, page, totalPages, status };
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'reviewing', label: 'Reviewing' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'denied', label: 'Denied' }
|
||||
];
|
||||
|
||||
function handleFilterChange(value: string) {
|
||||
const url = new URL(page.url);
|
||||
if (value) { url.searchParams.set('status', value); } else { url.searchParams.delete('status'); }
|
||||
url.searchParams.delete('page');
|
||||
goto(url.pathname + url.search);
|
||||
}
|
||||
|
||||
function buildBaseUrl(): string {
|
||||
const url = new URL(page.url);
|
||||
return url.pathname + '?' + url.searchParams.toString();
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Volunteer Applications — Admin</title></svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Volunteer Applications</h1>
|
||||
<span class="text-sm text-gray-500">{data.total} total</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-4 mb-6">
|
||||
<select
|
||||
value={data.status}
|
||||
onchange={(e) => handleFilterChange(e.currentTarget.value)}
|
||||
class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand"
|
||||
>
|
||||
{#each statusOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if data.volunteers.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p class="text-sm font-medium">No volunteer applications found.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-soft bg-gray-50">
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Name</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Email</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Phone</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Status</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Date</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.volunteers as vol}
|
||||
<tr class="border-b border-gray-soft last:border-0 hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 font-medium text-gray-900">{vol.applicantName}</td>
|
||||
<td class="px-6 py-4 text-gray-600"><a href="mailto:{vol.email}" class="hover:text-teal-brand">{vol.email}</a></td>
|
||||
<td class="px-6 py-4 text-gray-600">{vol.phone ?? '—'}</td>
|
||||
<td class="px-6 py-4"><StatusBadge status={vol.status} /></td>
|
||||
<td class="px-6 py-4 text-gray-500 text-xs">{formatDate(vol.createdAt)}</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href="/admin/volunteers/{vol.id}" class="text-sm font-medium text-teal-brand hover:text-teal-dark">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={data.page} totalPages={data.totalPages} baseUrl={buildBaseUrl()} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { applications } from '$lib/server/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { logAudit } from '$lib/server/audit';
|
||||
import { sendApplicationStatusUpdate } from '$lib/server/email';
|
||||
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, 'volunteers')) error(403, 'Access denied');
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) error(404, 'Application not found');
|
||||
|
||||
const [app] = await db.select().from(applications).where(eq(applications.id, id));
|
||||
if (!app || app.type !== 'volunteer') error(404, 'Volunteer application not found');
|
||||
|
||||
return { application: app };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateStatus: async ({ request, params, locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'volunteers')) error(403, 'Access denied');
|
||||
const id = parseInt(params.id, 10);
|
||||
const fd = await request.formData();
|
||||
const status = fd.get('status')?.toString();
|
||||
|
||||
if (!status || !['pending', 'reviewing', 'approved', 'denied', 'withdrawn'].includes(status)) {
|
||||
return fail(400, { error: 'Invalid status' });
|
||||
}
|
||||
|
||||
await db.update(applications).set({ status: status as typeof applications.status.enumValues[number] }).where(eq(applications.id, id));
|
||||
|
||||
await logAudit({
|
||||
userId: locals.user?.id ?? undefined,
|
||||
action: 'update_volunteer_status',
|
||||
entity: 'application',
|
||||
entityId: id,
|
||||
diff: { status }
|
||||
});
|
||||
|
||||
try {
|
||||
const [app] = await db.select({ applicantName: applications.applicantName, email: applications.applicantEmail }).from(applications).where(eq(applications.id, id));
|
||||
if (app) {
|
||||
await sendApplicationStatusUpdate(
|
||||
process.env.ORG_NAME ?? 'Rescue',
|
||||
app.email,
|
||||
app.applicantName,
|
||||
'volunteer',
|
||||
status,
|
||||
process.env.SITE_URL ?? ''
|
||||
);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const app = $derived(data.application);
|
||||
|
||||
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); }
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatFieldLabel(key: string): string {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).trim();
|
||||
}
|
||||
|
||||
const formDataEntries = $derived.by(() => {
|
||||
let fd = app.formData;
|
||||
if (!fd) return [];
|
||||
if (typeof fd === 'string') { try { fd = JSON.parse(fd); } catch { return []; } }
|
||||
if (typeof fd !== 'object' || fd === null) return [];
|
||||
return Object.entries(fd as Record<string, unknown>).filter(([, v]) => v !== null && v !== undefined && v !== '');
|
||||
});
|
||||
|
||||
const statusOptions = ['pending', 'reviewing', 'approved', 'denied', 'withdrawn'];
|
||||
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>Volunteer #{app.id} — Admin</title></svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/volunteers" 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 class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{app.applicantName}</h1>
|
||||
<StatusBadge status={app.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 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">Applicant Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Email</p>
|
||||
<p class="text-sm mt-1"><a href="mailto:{app.applicantEmail}" class="text-teal-brand hover:text-teal-dark">{app.applicantEmail}</a></p>
|
||||
</div>
|
||||
{#if app.applicantPhone}
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Phone</p>
|
||||
<p class="text-sm mt-1"><a href="tel:{app.applicantPhone}" class="text-teal-brand hover:text-teal-dark">{app.applicantPhone}</a></p>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Submitted</p>
|
||||
<p class="text-sm text-gray-600 mt-1">{formatDate(app.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formDataEntries.length > 0}
|
||||
<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">Application Details</h2>
|
||||
<dl class="space-y-4">
|
||||
{#each formDataEntries as [key, value]}
|
||||
<div class="border-b border-gray-soft pb-3 last:border-0 last:pb-0">
|
||||
<dt class="text-xs text-gray-400 uppercase tracking-wide">{formatFieldLabel(key)}</dt>
|
||||
<dd class="text-sm text-gray-900 mt-1 whitespace-pre-wrap">
|
||||
{#if typeof value === 'boolean'}{value ? 'Yes' : 'No'}
|
||||
{:else if Array.isArray(value)}{value.join(', ')}
|
||||
{:else}{String(value)}{/if}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">Update Status</h2>
|
||||
{#if showSuccess}
|
||||
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg 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>
|
||||
Status updated.
|
||||
</div>
|
||||
{/if}
|
||||
<form method="POST" action="?/updateStatus" use:enhance={handleEnhance}>
|
||||
<div class="mb-3">
|
||||
<label for="status" class={labelClass}>Status</label>
|
||||
<select id="status" name="status" class={inputClass}>
|
||||
{#each statusOptions as s}
|
||||
<option value={s} selected={app.status === s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="w-full px-4 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 Status'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user