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