Add admin layout and dashboard (Task 31)

Sidebar with permission-filtered nav, mobile hamburger, org name from
config, logout button. Dashboard loads pet/application/foster/donation
stats and recent audit log activity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:33:05 -05:00
co-authored by Claude Opus 4.6
parent d1e7a52eec
commit 6b6dab6cf8
4 changed files with 325 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import type { LayoutServerLoad } from './$types';
import { hasPermission, canViewPets } from '$lib/roles';
import { db } from '$lib/server/db';
import { fosterSubmissions, supplyRequests } from '$lib/server/schema';
import { eq, count } from 'drizzle-orm';
// Note: auth redirect is handled by hooks.server.ts — no need to duplicate here.
export const load: LayoutServerLoad = async ({ locals }) => {
const hasPets = hasPermission(locals.user!.role, 'pets');
let pendingModeration = 0;
if (hasPets) {
const [[subRow], [reqRow]] = await Promise.all([
db.select({ total: count() }).from(fosterSubmissions).where(eq(fosterSubmissions.status, 'pending')),
db.select({ total: count() }).from(supplyRequests).where(eq(supplyRequests.status, 'pending'))
]);
pendingModeration = (subRow?.total ?? 0) + (reqRow?.total ?? 0);
}
return {
user: locals.user!,
pendingModeration,
canEditPets: hasPets,
permissions: {
pets: hasPets || canViewPets(locals.user!.role),
applications: hasPermission(locals.user!.role, 'applications'),
donations: hasPermission(locals.user!.role, 'donations'),
expenses: hasPermission(locals.user!.role, 'expenses'),
vets: hasPermission(locals.user!.role, 'vets'),
medical: hasPermission(locals.user!.role, 'medical'),
events: hasPermission(locals.user!.role, 'events'),
content: hasPermission(locals.user!.role, 'content'),
volunteers: hasPermission(locals.user!.role, 'volunteers'),
users: hasPermission(locals.user!.role, 'users'),
system: hasPermission(locals.user!.role, 'system'),
import: hasPermission(locals.user!.role, 'import')
}
};
};
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
let { data, children } = $props();
let sidebarOpen = $state(false);
const navItems = [
{ href: '/admin', label: 'Dashboard', icon: '📊', always: true },
{ href: '/admin/intake', label: '+ Intake', icon: '🐾', perm: 'pets', editOnly: true },
{ href: '/admin/pets', label: 'Pets', icon: '🐕', perm: 'pets' },
{ href: '/admin/applications', label: 'Applications', icon: '📋', perm: 'applications' },
{ href: '/admin/moderation', label: 'Moderation', icon: '🔍', perm: 'pets', editOnly: true, badge: true },
{ href: '/admin/donations', label: 'Donations', icon: '💰', perm: 'donations' },
{ href: '/admin/expenses', label: 'Expenses', icon: '🧾', perm: 'expenses' },
{ href: '/admin/vets', label: 'Vets', icon: '🏥', perm: 'vets' },
{ href: '/admin/events', label: 'Events', icon: '📅', perm: 'events' },
{ href: '/admin/volunteers', label: 'Volunteers', icon: '🙋', perm: 'volunteers' },
{ href: '/admin/content', label: 'Content', icon: '✏️', perm: 'content' },
{ href: '/admin/users', label: 'Users', icon: '👥', perm: 'users' },
{ href: '/admin/import', label: 'Import', icon: '📥', perm: 'import' },
{ href: '/admin/system', label: 'System', icon: '⚙️', perm: 'system' }
];
const visibleNav = navItems.filter((item: any) => {
if (item.always) return true;
if (!item.perm || !data.permissions[item.perm as keyof typeof data.permissions]) return false;
if (item.editOnly && !data.canEditPets) return false;
return true;
});
</script>
<div class="min-h-screen bg-gray-100">
<!-- Mobile header -->
<div class="lg:hidden flex items-center justify-between bg-white shadow px-4 py-3">
<h1 class="font-bold text-lg">{data.orgConfig.orgInfo.name} Admin</h1>
<button onclick={() => sidebarOpen = !sidebarOpen} class="p-2">
<span class="text-2xl">{sidebarOpen ? '✕' : '☰'}</span>
</button>
</div>
<div class="flex">
<!-- Sidebar -->
<aside class="{sidebarOpen ? 'block' : 'hidden'} lg:block fixed lg:sticky top-0 left-0 z-40 w-64 h-screen bg-white shadow-lg overflow-y-auto">
<div class="p-4 border-b">
<h1 class="font-bold text-lg">{data.orgConfig.orgInfo.name} Admin</h1>
<p class="text-sm text-gray-500">{data.user.name} ({data.user.role})</p>
</div>
<nav class="p-2">
{#each visibleNav as item}
<a
href={item.href}
class="flex items-center gap-3 px-3 py-2 rounded-lg text-gray-700 hover:bg-gray-100 transition"
onclick={() => sidebarOpen = false}
>
<span>{item.icon}</span>
<span class="flex-1">{item.label}</span>
{#if item.badge && data.pendingModeration > 0}
<span class="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 text-xs font-bold text-white bg-red-500 rounded-full">
{data.pendingModeration}
</span>
{/if}
</a>
{/each}
</nav>
<div class="p-2 border-t mt-auto">
<form method="POST" action="/logout">
<button type="submit" class="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-red-600 hover:bg-red-50 transition">
<span>🚪</span>
<span>Logout</span>
</button>
</form>
</div>
</aside>
<!-- Backdrop for mobile -->
{#if sidebarOpen}
<div
class="fixed inset-0 bg-black/30 z-30 lg:hidden"
onclick={() => sidebarOpen = false}
role="button"
tabindex="-1"
onkeydown={() => {}}
></div>
{/if}
<!-- Main content -->
<main class="flex-1 p-4 lg:p-8 min-w-0">
{@render children()}
</main>
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { pets, applications, fosterAssignments, donations, auditLog, users, fosterSubmissions, supplyRequests } from '$lib/server/schema';
import { eq, gte, sql, count, sum } from 'drizzle-orm';
import { hasPermission } from '$lib/roles';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!hasPermission(locals.user!.role, 'dashboard')) error(403, 'Access denied');
// Available pets count
const [availableRow] = await db
.select({ total: count() })
.from(pets)
.where(eq(pets.status, 'available'));
const availablePets = availableRow?.total ?? 0;
// Pending applications count
const [pendingRow] = await db
.select({ total: count() })
.from(applications)
.where(eq(applications.status, 'pending'));
const pendingApplications = pendingRow?.total ?? 0;
// Active fosters count
const [fosterRow] = await db
.select({ total: count() })
.from(fosterAssignments)
.where(eq(fosterAssignments.isCurrent, true));
const activeFosters = fosterRow?.total ?? 0;
// This month's donations sum (amount stored as int cents)
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const [donationRow] = await db
.select({ total: sum(donations.amount) })
.from(donations)
.where(gte(donations.date, monthStart));
const monthlyDonationsCents = parseInt(donationRow?.total ?? '0', 10);
const monthlyDonations = monthlyDonationsCents / 100;
// Pending moderation counts (for users with 'pets' permission)
let pendingSubmissions = 0;
let pendingRequests = 0;
if (hasPermission(locals.user!.role, 'pets')) {
const [[subRow], [reqRow]] = await Promise.all([
db.select({ total: count() }).from(fosterSubmissions).where(eq(fosterSubmissions.status, 'pending')),
db.select({ total: count() }).from(supplyRequests).where(eq(supplyRequests.status, 'pending'))
]);
pendingSubmissions = subRow?.total ?? 0;
pendingRequests = reqRow?.total ?? 0;
}
// Last 10 audit log entries with user name
const recentActivity = await db
.select({
id: auditLog.id,
action: auditLog.action,
entity: auditLog.entity,
entityId: auditLog.entityId,
createdAt: auditLog.createdAt,
userName: users.name
})
.from(auditLog)
.leftJoin(users, eq(auditLog.userId, users.id))
.orderBy(sql`${auditLog.createdAt} DESC`)
.limit(10);
return {
availablePets,
pendingApplications,
activeFosters,
monthlyDonations,
pendingSubmissions,
pendingRequests,
recentActivity
};
};
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts">
let { data } = $props();
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
function formatDate(d: string | Date): string {
return new Date(d).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
function formatAction(action: string): string {
return action.charAt(0).toUpperCase() + action.slice(1);
}
function formatEntity(entity: string | null): string {
if (!entity) return 'record';
return entity
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const actionColors: Record<string, string> = {
create: 'text-green-700 bg-green-100',
update: 'text-blue-700 bg-blue-100',
delete: 'text-red-700 bg-red-100'
};
</script>
<svelte:head>
<title>Dashboard — {data.orgConfig.orgInfo.name} Admin</title>
</svelte:head>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
{#if data.pendingSubmissions > 0 || data.pendingRequests > 0}
<div class="bg-teal-50 border border-teal-200 rounded-xl p-4 mb-6 flex items-center justify-between">
<div>
<p class="text-teal-900 font-medium">Moderation Queue</p>
<p class="text-teal-700 text-sm">
You have
{#if data.pendingSubmissions > 0}
{data.pendingSubmissions} foster submission{data.pendingSubmissions === 1 ? '' : 's'}
{/if}
{#if data.pendingSubmissions > 0 && data.pendingRequests > 0}
and
{/if}
{#if data.pendingRequests > 0}
{data.pendingRequests} supply request{data.pendingRequests === 1 ? '' : 's'}
{/if}
pending review.
</p>
</div>
<a href="/admin/moderation" class="px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition text-sm font-medium">
Review Now
</a>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500">Available Pets</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{data.availablePets}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500">Pending Applications</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{data.pendingApplications}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500">Active Fosters</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{data.activeFosters}</p>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<p class="text-sm text-gray-500">This Month's Donations</p>
<p class="text-3xl font-bold text-gray-900 mt-1">{fmt.format(data.monthlyDonations)}</p>
</div>
</div>
<!-- Recent Activity -->
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4">Recent Activity</h2>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{#if data.recentActivity.length === 0}
<div class="p-8 text-center text-gray-400">
No recent activity recorded.
</div>
{:else}
<ul class="divide-y divide-gray-100">
{#each data.recentActivity as entry}
<li class="px-5 py-3.5 flex items-center gap-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold {actionColors[entry.action] ?? 'text-gray-700 bg-gray-100'}">
{formatAction(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-gray-900">
<span class="font-medium">{entry.userName ?? 'System'}</span>
{entry.action === 'delete' ? 'deleted' : entry.action === 'create' ? 'created' : 'updated'}
{formatEntity(entry.entity)}
{#if entry.entityId}
<span class="text-gray-400">#{entry.entityId}</span>
{/if}
</p>
</div>
<span class="text-xs text-gray-400 whitespace-nowrap">{formatDate(entry.createdAt)}</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<p class="text-gray-500 mt-8 text-sm">Welcome, {data.user.name}.</p>