feat(task-37): admin Analytics & System — analytics, traffic, moderation, foster dashboard, fosters, system
Key metrics dashboard, page view traffic analysis, foster submission moderation, foster assignment management, active foster dashboard with supply requests, and system settings/org config editor with full audit log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { fosterAssignments, pets, users } from '$lib/server/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { hasPermission } from '$lib/roles';
|
||||
import type { Role } from '$lib/roles';
|
||||
import { logAudit } from '$lib/server/audit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
|
||||
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: fosterAssignments.id,
|
||||
startDate: fosterAssignments.startDate,
|
||||
endDate: fosterAssignments.endDate,
|
||||
isCurrent: fosterAssignments.isCurrent,
|
||||
notes: fosterAssignments.notes,
|
||||
createdAt: fosterAssignments.createdAt,
|
||||
petId: pets.id,
|
||||
petName: pets.name,
|
||||
petSpecies: pets.species,
|
||||
fostererName: users.name,
|
||||
fostererEmail: users.email,
|
||||
fostererId: users.id
|
||||
})
|
||||
.from(fosterAssignments)
|
||||
.leftJoin(pets, eq(fosterAssignments.petId, pets.id))
|
||||
.leftJoin(users, eq(fosterAssignments.userId, users.id))
|
||||
.orderBy(desc(fosterAssignments.isCurrent), desc(fosterAssignments.startDate));
|
||||
|
||||
// Active fosters for the form
|
||||
const fosterUsers = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.role, 'foster'))
|
||||
.orderBy(users.name);
|
||||
|
||||
const availablePets = await db
|
||||
.select({ id: pets.id, name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.status, 'available'))
|
||||
.orderBy(pets.name);
|
||||
|
||||
return { assignments, fosterUsers, availablePets };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
end: async ({ request, locals }) => {
|
||||
if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
|
||||
const fd = await request.formData();
|
||||
const id = parseInt(fd.get('id')?.toString() ?? '', 10);
|
||||
if (isNaN(id)) return fail(400, { error: 'Invalid ID' });
|
||||
|
||||
await db.update(fosterAssignments)
|
||||
.set({ isCurrent: false, endDate: new Date() })
|
||||
.where(eq(fosterAssignments.id, id));
|
||||
|
||||
await logAudit({
|
||||
userId: locals.user!.id,
|
||||
action: 'end_foster_assignment',
|
||||
entity: 'foster_assignment',
|
||||
entityId: id
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
let submitting = $state<string | null>(null);
|
||||
|
||||
const active = $derived(data.assignments.filter(a => a.isCurrent));
|
||||
const past = $derived(data.assignments.filter(a => !a.isCurrent));
|
||||
|
||||
function formatDate(d: string | Date | null): string {
|
||||
if (!d) return '—';
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Foster Assignments — Admin</title></svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Foster Assignments</h1>
|
||||
<span class="text-sm text-gray-500">{active.length} active</span>
|
||||
</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}
|
||||
|
||||
{#if active.length > 0}
|
||||
<h2 class="text-base font-semibold text-gray-700 mb-3">Active</h2>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft overflow-hidden mb-8">
|
||||
<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">Pet</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Foster</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600">Since</th>
|
||||
<th class="px-6 py-3 font-semibold text-gray-600 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each active as a}
|
||||
<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">
|
||||
<a href="/admin/pets/{a.petId}" class="hover:text-teal-brand">{a.petName ?? '—'}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-600">
|
||||
<p>{a.fostererName ?? '—'}</p>
|
||||
<p class="text-xs text-gray-400">{a.fostererEmail ?? ''}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-gray-500 text-xs">{formatDate(a.startDate)}</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<form method="POST" action="?/end" use:enhance={() => {
|
||||
submitting = `end-${a.id}`;
|
||||
return async ({ update }) => { submitting = null; await update(); };
|
||||
}}>
|
||||
<input type="hidden" name="id" value={a.id} />
|
||||
<button type="submit" disabled={submitting === `end-${a.id}`} class="text-sm text-amber-600 hover:text-amber-700 font-medium cursor-pointer disabled:opacity-50">End</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if past.length > 0}
|
||||
<details class="bg-white rounded-xl shadow-sm border border-gray-soft p-4">
|
||||
<summary class="text-sm font-medium text-gray-600 cursor-pointer">Past Assignments ({past.length})</summary>
|
||||
<div class="mt-4">
|
||||
<table class="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-soft">
|
||||
<th class="py-2 font-semibold text-gray-600">Pet</th>
|
||||
<th class="py-2 font-semibold text-gray-600">Foster</th>
|
||||
<th class="py-2 font-semibold text-gray-600">Period</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each past as a}
|
||||
<tr class="border-b border-gray-soft last:border-0">
|
||||
<td class="py-3 text-gray-900">{a.petName ?? '—'}</td>
|
||||
<td class="py-3 text-gray-600">{a.fostererName ?? '—'}</td>
|
||||
<td class="py-3 text-gray-500 text-xs">{formatDate(a.startDate)} – {formatDate(a.endDate)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if data.assignments.length === 0}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p class="text-sm font-medium">No foster assignments yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user