From 6b6dab6cf867b927bd5b7f38a5386c83ac9d72d2 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 12:33:05 -0500 Subject: [PATCH] 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) --- src/routes/admin/+layout.server.ts | 40 ++++++++++ src/routes/admin/+layout.svelte | 92 +++++++++++++++++++++++ src/routes/admin/+page.server.ts | 78 +++++++++++++++++++ src/routes/admin/+page.svelte | 115 +++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 src/routes/admin/+layout.server.ts create mode 100644 src/routes/admin/+layout.svelte create mode 100644 src/routes/admin/+page.server.ts create mode 100644 src/routes/admin/+page.svelte diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..e69a77f --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -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') + } + }; +}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..e9c7652 --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,92 @@ + + +
+ +
+

{data.orgConfig.orgInfo.name} Admin

+ +
+ +
+ + + + + {#if sidebarOpen} +
sidebarOpen = false} + role="button" + tabindex="-1" + onkeydown={() => {}} + >
+ {/if} + + +
+ {@render children()} +
+
+
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..3a9796b --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -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 + }; +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..6e4d4c5 --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,115 @@ + + + + Dashboard โ€” {data.orgConfig.orgInfo.name} Admin + + +

Dashboard

+ +{#if data.pendingSubmissions > 0 || data.pendingRequests > 0} +
+
+

Moderation Queue

+

+ 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. +

+
+ + Review Now + +
+{/if} + +
+
+

Available Pets

+

{data.availablePets}

+
+
+

Pending Applications

+

{data.pendingApplications}

+
+
+

Active Fosters

+

{data.activeFosters}

+
+
+

This Month's Donations

+

{fmt.format(data.monthlyDonations)}

+
+
+ + +
+

Recent Activity

+
+ {#if data.recentActivity.length === 0} +
+ No recent activity recorded. +
+ {:else} +
    + {#each data.recentActivity as entry} +
  • + + {formatAction(entry.action)} + +
    +

    + {entry.userName ?? 'System'} + {entry.action === 'delete' ? 'deleted' : entry.action === 'create' ? 'created' : 'updated'} + {formatEntity(entry.entity)} + {#if entry.entityId} + #{entry.entityId} + {/if} +

    +
    + {formatDate(entry.createdAt)} +
  • + {/each} +
+ {/if} +
+
+ +

Welcome, {data.user.name}.