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