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