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,47 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pets, applications, donations, fosterAssignments, users, pageViews } from '$lib/server/schema';
import { eq, gte, count, sum, and } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'dashboard')) error(403, 'Access denied');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [totalPetsRow] = await db.select({ total: count() }).from(pets);
const [availablePetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'available'));
const [fosterPetsRow] = await db.select({ total: count() }).from(pets).where(eq(pets.status, 'foster'));
const [adoptedThisMonthRow] = await db.select({ total: count() }).from(pets).where(
and(eq(pets.status, 'adopted'), gte(pets.adoptedDate, thirtyDaysAgo))
);
const [pendingAppsRow] = await db.select({ total: count() }).from(applications).where(eq(applications.status, 'pending'));
const [newAppsRow] = await db.select({ total: count() }).from(applications).where(gte(applications.createdAt, thirtyDaysAgo));
const [donationsRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, thirtyDaysAgo));
const [totalUsersRow] = await db.select({ total: count() }).from(users).where(eq(users.active, true));
const [activeFostersRow] = await db.select({ total: count() }).from(fosterAssignments).where(eq(fosterAssignments.isCurrent, true));
const [pageViewsRow] = await db.select({ total: count() }).from(pageViews).where(
and(gte(pageViews.createdAt, thirtyDaysAgo), eq(pageViews.isBot, false))
);
return {
stats: {
totalPets: totalPetsRow?.total ?? 0,
availablePets: availablePetsRow?.total ?? 0,
fosterPets: fosterPetsRow?.total ?? 0,
adoptedThisMonth: adoptedThisMonthRow?.total ?? 0,
pendingApplications: pendingAppsRow?.total ?? 0,
newApplications: newAppsRow?.total ?? 0,
donationsThisMonth: (donationsRow?.total ?? 0) as number,
activeUsers: totalUsersRow?.total ?? 0,
activeFosters: activeFostersRow?.total ?? 0,
pageViewsThisMonth: pageViewsRow?.total ?? 0
}
};
};
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
let { data } = $props();
const s = $derived(data.stats);
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
</script>
<svelte:head><title>Analytics — Admin</title></svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Analytics</h1>
<p class="text-sm text-gray-500 mt-1">Last 30 days unless otherwise noted</p>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Total Pets</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{s.totalPets}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Available</p>
<p class="text-3xl font-bold text-teal-brand mt-1">{s.availablePets}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">In Foster</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{s.fosterPets}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Adopted (30d)</p>
<p class="text-3xl font-bold text-green-600 mt-1">{s.adoptedThisMonth}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Active Fosters</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{s.activeFosters}</p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Pending Applications</p>
<p class="text-3xl font-bold {s.pendingApplications > 0 ? 'text-amber-600' : 'text-gray-900'} mt-1">{s.pendingApplications}</p>
<p class="text-xs text-gray-400 mt-1">{s.newApplications} new this month</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Donations (30d)</p>
<p class="text-3xl font-bold text-green-600 mt-1">{fmt.format(s.donationsThisMonth / 100)}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Active Users</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{s.activeUsers}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Page Views (30d)</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{s.pageViewsThisMonth.toLocaleString()}</p>
</div>
</div>
</div>
@@ -0,0 +1,57 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { fosterAssignments, supplyRequests, fosterSubmissions, pets, users } from '$lib/server/schema';
import { eq, desc, count } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role as Role, 'pets')) error(403, 'Access denied');
// Active foster assignments
const activeFosters = await db
.select({
id: fosterAssignments.id,
startDate: fosterAssignments.startDate,
petId: pets.id,
petName: pets.name,
petSpecies: pets.species,
petBreed: pets.breed,
fostererName: users.name,
fostererEmail: users.email
})
.from(fosterAssignments)
.leftJoin(pets, eq(fosterAssignments.petId, pets.id))
.leftJoin(users, eq(fosterAssignments.userId, users.id))
.where(eq(fosterAssignments.isCurrent, true))
.orderBy(desc(fosterAssignments.startDate));
// Pending supply requests
const pendingSupplyRequests = await db
.select({
id: supplyRequests.id,
items: supplyRequests.items,
urgency: supplyRequests.urgency,
notes: supplyRequests.notes,
createdAt: supplyRequests.createdAt,
petName: pets.name,
requesterName: users.name
})
.from(supplyRequests)
.leftJoin(pets, eq(supplyRequests.petId, pets.id))
.leftJoin(users, eq(supplyRequests.userId, users.id))
.where(eq(supplyRequests.status, 'pending'))
.orderBy(desc(supplyRequests.createdAt));
const [pendingSubmissionsRow] = await db
.select({ total: count() })
.from(fosterSubmissions)
.where(eq(fosterSubmissions.status, 'pending'));
return {
activeFosters,
pendingSupplyRequests,
pendingSubmissionsCount: pendingSubmissionsRow?.total ?? 0
};
};
@@ -0,0 +1,87 @@
<script lang="ts">
let { data } = $props();
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
const urgencyColors: Record<string, string> = {
high: 'bg-red-100 text-red-800',
medium: 'bg-amber-100 text-amber-800',
low: 'bg-gray-100 text-gray-600'
};
</script>
<svelte:head><title>Foster Dashboard — Admin</title></svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">Foster Dashboard</h1>
</div>
<!-- Summary stats -->
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Active Fosters</p>
<p class="text-3xl font-bold text-teal-brand mt-1">{data.activeFosters.length}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Supply Requests</p>
<p class="text-3xl font-bold {data.pendingSupplyRequests.length > 0 ? 'text-amber-600' : 'text-gray-900'} mt-1">{data.pendingSupplyRequests.length}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Pending Posts</p>
<p class="text-3xl font-bold {data.pendingSubmissionsCount > 0 ? 'text-amber-600' : 'text-gray-900'} mt-1">{data.pendingSubmissionsCount}</p>
<a href="/admin/moderation" class="text-xs text-teal-brand hover:text-teal-dark mt-1 block">Review</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Active fosters -->
<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">Active Foster Assignments</h2>
{#if data.activeFosters.length === 0}
<p class="text-sm text-gray-400">No active fosters.</p>
{:else}
<div class="space-y-3">
{#each data.activeFosters as foster}
<div class="flex items-center justify-between border-b border-gray-soft pb-3 last:border-0">
<div>
<p class="text-sm font-medium text-gray-900">
<a href="/admin/pets/{foster.petId}" class="hover:text-teal-brand">{foster.petName ?? '—'}</a>
</p>
<p class="text-xs text-gray-500">{foster.fostererName ?? foster.fostererEmail ?? '—'}</p>
</div>
<span class="text-xs text-gray-400">{formatDate(foster.startDate)}</span>
</div>
{/each}
</div>
{/if}
</div>
<!-- Pending supply requests -->
<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">Pending Supply Requests</h2>
{#if data.pendingSupplyRequests.length === 0}
<p class="text-sm text-gray-400">No pending requests.</p>
{:else}
<div class="space-y-3">
{#each data.pendingSupplyRequests as req}
<div class="border-b border-gray-soft pb-3 last:border-0">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-gray-900">{req.requesterName ?? '—'}</p>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {urgencyColors[req.urgency] ?? 'bg-gray-100 text-gray-600'}">
{req.urgency}
</span>
</div>
<p class="text-xs text-gray-600">{req.items}</p>
{#if req.petName}
<p class="text-xs text-gray-400 mt-1">For: {req.petName}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
+69
View File
@@ -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 };
}
};
+98
View File
@@ -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>
@@ -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>
+92
View File
@@ -0,0 +1,92 @@
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { siteSettings, orgConfig, auditLog, 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, 'system')) error(403, 'Access denied');
const settings = await db.select().from(siteSettings).orderBy(siteSettings.key);
const configs = await db.select().from(orgConfig).orderBy(orgConfig.key);
const recentAudit = await db
.select({
id: auditLog.id,
action: auditLog.action,
entity: auditLog.entity,
entityId: auditLog.entityId,
diff: auditLog.diff,
createdAt: auditLog.createdAt,
userName: users.name,
userEmail: users.email
})
.from(auditLog)
.leftJoin(users, eq(auditLog.userId, users.id))
.orderBy(desc(auditLog.createdAt))
.limit(50);
return { settings, configs, recentAudit };
};
export const actions: Actions = {
saveSetting: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied');
const fd = await request.formData();
const key = fd.get('key')?.toString().trim() ?? '';
const value = fd.get('value')?.toString() ?? '';
if (!key) return fail(400, { error: 'Key is required' });
const existing = await db.select({ id: siteSettings.id }).from(siteSettings).where(eq(siteSettings.key, key)).limit(1);
if (existing.length > 0) {
await db.update(siteSettings).set({ value }).where(eq(siteSettings.key, key));
} else {
await db.insert(siteSettings).values({ key, value });
}
await logAudit({
userId: locals.user!.id,
action: 'update_site_setting',
entity: 'site_setting',
diff: { key, value }
});
return { success: true };
},
saveConfig: async ({ request, locals }) => {
if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied');
const fd = await request.formData();
const key = fd.get('key')?.toString().trim() ?? '';
const valueStr = fd.get('value')?.toString() ?? '';
if (!key) return fail(400, { error: 'Key is required' });
let value: unknown;
try {
value = JSON.parse(valueStr);
} catch {
value = valueStr;
}
const existing = await db.select({ id: orgConfig.id }).from(orgConfig).where(eq(orgConfig.key, key)).limit(1);
if (existing.length > 0) {
await db.update(orgConfig).set({ value: value as Record<string, unknown> }).where(eq(orgConfig.key, key));
} else {
await db.insert(orgConfig).values({ key, value: value as Record<string, unknown> });
}
await logAudit({
userId: locals.user!.id,
action: 'update_org_config',
entity: 'org_config',
diff: { key }
});
return { success: true };
}
};
+169
View File
@@ -0,0 +1,169 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let submitting = $state<string | null>(null);
let showSuccess = $state(false);
let editingSetting = $state<string | null>(null);
let editingConfig = $state<string | null>(null);
let newSettingKey = $state('');
let newSettingValue = $state('');
function handleEnhance(key: string) {
submitting = key;
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
submitting = null;
await update({ reset: false });
if (result.type === 'success') {
showSuccess = true;
editingSetting = null;
editingConfig = null;
setTimeout(() => showSuccess = false, 3000);
}
};
}
function formatDate(d: string | Date): string {
return new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function configDisplay(val: unknown): string {
if (typeof val === 'string') return val;
return JSON.stringify(val, null, 2);
}
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>System Settings — Admin</title></svelte:head>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">System Settings</h1>
</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 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>
Settings saved.
</div>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Site Settings -->
<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">Site Settings</h2>
<form method="POST" action="?/saveSetting" use:enhance={() => handleEnhance('new-setting')} class="mb-6 p-4 bg-gray-50 rounded-lg">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Add / Update Setting</p>
<div class="space-y-3">
<div>
<label for="setting-key" class={labelClass}>Key</label>
<input id="setting-key" name="key" type="text" required bind:value={newSettingKey} class={inputClass} placeholder="site_title" />
</div>
<div>
<label for="setting-value" class={labelClass}>Value</label>
<input id="setting-value" name="value" type="text" bind:value={newSettingValue} class={inputClass} />
</div>
</div>
<button type="submit" disabled={submitting === 'new-setting'} class="mt-3 px-4 py-2 text-xs font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark disabled:opacity-50 cursor-pointer">
{submitting === 'new-setting' ? 'Saving...' : 'Save'}
</button>
</form>
{#if data.settings.length === 0}
<p class="text-sm text-gray-400">No settings configured.</p>
{:else}
<div class="space-y-2">
{#each data.settings as setting}
<div class="border border-gray-soft rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<code class="text-xs font-semibold text-gray-700">{setting.key}</code>
<button onclick={() => editingSetting = editingSetting === setting.key ? null : setting.key} class="text-xs text-teal-brand hover:text-teal-dark cursor-pointer">
{editingSetting === setting.key ? 'Cancel' : 'Edit'}
</button>
</div>
{#if editingSetting === setting.key}
<form method="POST" action="?/saveSetting" use:enhance={() => handleEnhance(`setting-${setting.key}`)}>
<input type="hidden" name="key" value={setting.key} />
<input name="value" type="text" value={setting.value} class="{inputClass} mb-2" />
<button type="submit" disabled={submitting === `setting-${setting.key}`} class="px-3 py-1.5 text-xs font-medium rounded bg-teal-brand text-white hover:bg-teal-dark disabled:opacity-50 cursor-pointer">Save</button>
</form>
{:else}
<p class="text-xs text-gray-600 truncate">{setting.value}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Org Config -->
<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">Org Config</h2>
{#if data.configs.length === 0}
<p class="text-sm text-gray-400">No config entries.</p>
{:else}
<div class="space-y-3">
{#each data.configs as cfg}
<div class="border border-gray-soft rounded-lg p-3">
<div class="flex items-center justify-between mb-1">
<code class="text-xs font-semibold text-gray-700">{cfg.key}</code>
<button onclick={() => editingConfig = editingConfig === cfg.key ? null : cfg.key} class="text-xs text-teal-brand hover:text-teal-dark cursor-pointer">
{editingConfig === cfg.key ? 'Cancel' : 'Edit'}
</button>
</div>
{#if editingConfig === cfg.key}
<form method="POST" action="?/saveConfig" use:enhance={() => handleEnhance(`config-${cfg.key}`)}>
<input type="hidden" name="key" value={cfg.key} />
<textarea name="value" rows="4" class="{inputClass} font-mono text-xs mb-2">{configDisplay(cfg.value)}</textarea>
<button type="submit" disabled={submitting === `config-${cfg.key}`} class="px-3 py-1.5 text-xs font-medium rounded bg-teal-brand text-white hover:bg-teal-dark disabled:opacity-50 cursor-pointer">Save</button>
</form>
{:else}
<pre class="text-xs text-gray-600 bg-gray-50 rounded p-2 overflow-auto max-h-24">{configDisplay(cfg.value)}</pre>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Audit Log -->
<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">Recent Audit Log</h2>
{#if data.recentAudit.length === 0}
<p class="text-sm text-gray-400">No audit entries.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-xs text-left">
<thead>
<tr class="border-b border-gray-soft">
<th class="pb-2 font-semibold text-gray-600">Time</th>
<th class="pb-2 font-semibold text-gray-600">User</th>
<th class="pb-2 font-semibold text-gray-600">Action</th>
<th class="pb-2 font-semibold text-gray-600">Entity</th>
</tr>
</thead>
<tbody>
{#each data.recentAudit as entry}
<tr class="border-b border-gray-soft last:border-0">
<td class="py-2 text-gray-400 whitespace-nowrap">{formatDate(entry.createdAt)}</td>
<td class="py-2 text-gray-600">{entry.userName ?? entry.userEmail ?? 'System'}</td>
<td class="py-2 font-mono text-gray-700">{entry.action}</td>
<td class="py-2 text-gray-500">{entry.entity ?? '—'}{entry.entityId ? ` #${entry.entityId}` : ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
+51
View File
@@ -0,0 +1,51 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pageViews, pets } from '$lib/server/schema';
import { eq, gte, desc, count, and } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import { hasPermission } from '$lib/roles';
import type { Role } from '$lib/roles';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!hasPermission(locals.user!.role as Role, 'system')) error(403, 'Access denied');
const days = parseInt(url.searchParams.get('days') ?? '7', 10);
const since = new Date();
since.setDate(since.getDate() - days);
// Top paths
const topPaths = await db
.select({
path: pageViews.path,
views: count()
})
.from(pageViews)
.where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false)))
.groupBy(pageViews.path)
.orderBy(desc(count()))
.limit(25);
// Top referrers
const topReferrers = await db
.select({
referrer: pageViews.referrer,
views: count()
})
.from(pageViews)
.where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, false)))
.groupBy(pageViews.referrer)
.orderBy(desc(count()))
.limit(10);
// Total and bot counts
const [totalRow] = await db.select({ total: count() }).from(pageViews).where(gte(pageViews.createdAt, since));
const [botRow] = await db.select({ total: count() }).from(pageViews).where(and(gte(pageViews.createdAt, since), eq(pageViews.isBot, true)));
return {
topPaths,
topReferrers: topReferrers.filter(r => r.referrer),
totalViews: totalRow?.total ?? 0,
botViews: botRow?.total ?? 0,
days
};
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
let { data } = $props();
function handleDaysChange(days: string) {
const url = new URL(page.url);
url.searchParams.set('days', days);
goto(url.pathname + url.search);
}
</script>
<svelte:head><title>Traffic — Admin</title></svelte:head>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Traffic</h1>
<select
value={String(data.days)}
onchange={(e) => handleDaysChange(e.currentTarget.value)}
class="px-3 py-2 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand"
>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Human Page Views</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{(data.totalViews - data.botViews).toLocaleString()}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
<p class="text-xs text-gray-400 uppercase tracking-wide">Bot Requests</p>
<p class="text-3xl font-bold text-gray-400 mt-1">{data.botViews.toLocaleString()}</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-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">Top Pages</h2>
{#if data.topPaths.length === 0}
<p class="text-sm text-gray-400">No data yet.</p>
{:else}
<div class="space-y-2">
{#each data.topPaths as row}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700 truncate flex-1 font-mono text-xs">{row.path}</span>
<span class="text-gray-500 font-medium ml-4 flex-shrink-0">{row.views.toLocaleString()}</span>
</div>
{/each}
</div>
{/if}
</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">Top Referrers</h2>
{#if data.topReferrers.length === 0}
<p class="text-sm text-gray-400">No referrer data yet.</p>
{:else}
<div class="space-y-2">
{#each data.topReferrers as row}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-700 truncate flex-1 text-xs">{row.referrer}</span>
<span class="text-gray-500 font-medium ml-4 flex-shrink-0">{row.views.toLocaleString()}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>