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:
2026-03-26 12:45:42 -05:00
co-authored by Claude Opus 4.6
parent 1ae4c1be2c
commit 6ca598b45c
12 changed files with 975 additions and 0 deletions
@@ -0,0 +1,82 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { fosterSubmissions, 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 submissions = await db
.select({
id: fosterSubmissions.id,
type: fosterSubmissions.type,
content: fosterSubmissions.content,
photoUrl: fosterSubmissions.photoUrl,
status: fosterSubmissions.status,
createdAt: fosterSubmissions.createdAt,
petName: pets.name,
petId: pets.id,
submittedByName: users.name,
submittedByEmail: users.email
})
.from(fosterSubmissions)
.leftJoin(pets, eq(fosterSubmissions.petId, pets.id))
.leftJoin(users, eq(fosterSubmissions.userId, users.id))
.orderBy(desc(fosterSubmissions.createdAt))
.limit(100);
const pending = submissions.filter(s => s.status === 'pending');
const reviewed = submissions.filter(s => s.status !== 'pending');
return { pending, reviewed };
};
export const actions: Actions = {
approve: 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(fosterSubmissions).set({
status: 'approved',
reviewedBy: locals.user!.id,
reviewedAt: new Date()
}).where(eq(fosterSubmissions.id, id));
await logAudit({
userId: locals.user!.id,
action: 'approve_foster_submission',
entity: 'foster_submission',
entityId: id
});
return { success: true };
},
reject: 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(fosterSubmissions).set({
status: 'rejected',
reviewedBy: locals.user!.id,
reviewedAt: new Date()
}).where(eq(fosterSubmissions.id, id));
await logAudit({
userId: locals.user!.id,
action: 'reject_foster_submission',
entity: 'foster_submission',
entityId: id
});
return { success: true };
}
};
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let submitting = $state<string | null>(null);
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
</script>
<svelte:head><title>Moderation — Admin</title></svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Foster Submission Moderation</h1>
{#if data.pending.length > 0}
<p class="text-sm text-amber-600 mt-1">{data.pending.length} pending review</p>
{/if}
</div>
{#if data.pending.length === 0 && data.reviewed.length === 0}
<div class="text-center py-12 text-gray-400">
<p class="text-sm font-medium">No submissions yet.</p>
</div>
{/if}
{#if data.pending.length > 0}
<h2 class="text-lg font-semibold text-gray-900 mb-3">Pending Review</h2>
<div class="space-y-4 mb-8">
{#each data.pending as sub}
<div class="bg-white rounded-xl shadow-sm border border-amber-200 p-5">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">{sub.type}</span>
<span class="text-sm font-medium text-gray-900">{sub.petName ?? 'Unknown Pet'}</span>
<span class="text-xs text-gray-400">by {sub.submittedByName ?? sub.submittedByEmail ?? 'Unknown'}</span>
</div>
{#if sub.content}
<p class="text-sm text-gray-700 whitespace-pre-wrap">{sub.content}</p>
{/if}
{#if sub.photoUrl}
<img src={sub.photoUrl} alt="Submission" class="mt-3 rounded-lg max-h-48 object-cover" />
{/if}
<p class="text-xs text-gray-400 mt-2">{formatDate(sub.createdAt)}</p>
</div>
<div class="flex flex-col gap-2 flex-shrink-0">
<form method="POST" action="?/approve" use:enhance={() => {
submitting = `approve-${sub.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={sub.id} />
<button type="submit" disabled={submitting === `approve-${sub.id}`} class="w-full px-4 py-2 text-xs font-medium rounded-lg bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 cursor-pointer">Approve</button>
</form>
<form method="POST" action="?/reject" use:enhance={() => {
submitting = `reject-${sub.id}`;
return async ({ update }) => { submitting = null; await update(); };
}}>
<input type="hidden" name="id" value={sub.id} />
<button type="submit" disabled={submitting === `reject-${sub.id}`} class="w-full px-4 py-2 text-xs font-medium rounded-lg border border-red-300 text-red-600 hover:bg-red-50 disabled:opacity-50 cursor-pointer">Reject</button>
</form>
</div>
</div>
</div>
{/each}
</div>
{/if}
{#if data.reviewed.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">Reviewed ({data.reviewed.length})</summary>
<div class="mt-4 space-y-3">
{#each data.reviewed as sub}
<div class="flex items-center justify-between text-sm border-b border-gray-soft pb-3 last:border-0">
<div>
<span class="font-medium text-gray-900">{sub.petName ?? '—'}</span>
<span class="text-gray-400 mx-2">·</span>
<span class="text-gray-600">{sub.type}</span>
<span class="text-gray-400 mx-2">·</span>
<span class="text-xs text-gray-400">{sub.submittedByName ?? '—'}</span>
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {sub.status === 'approved' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
{sub.status}
</span>
</div>
{/each}
</div>
</details>
{/if}
</div>