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,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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user