diff --git a/src/routes/admin/donations/+page.server.ts b/src/routes/admin/donations/+page.server.ts
new file mode 100644
index 0000000..e627ee5
--- /dev/null
+++ b/src/routes/admin/donations/+page.server.ts
@@ -0,0 +1,80 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { donations, pets } from '$lib/server/schema';
+import { eq, and, gte, lte, count, sum } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+const PER_PAGE = 20;
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+
+ const source = url.searchParams.get('source') ?? '';
+ const startDate = url.searchParams.get('startDate') ?? '';
+ const endDate = url.searchParams.get('endDate') ?? '';
+ const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
+ const offset = (page - 1) * PER_PAGE;
+
+ const conditions = [];
+ if (source) conditions.push(eq(donations.source, source as typeof donations.source.enumValues[number]));
+ if (startDate) conditions.push(gte(donations.date, new Date(startDate)));
+ if (endDate) {
+ const end = new Date(endDate);
+ end.setHours(23, 59, 59, 999);
+ conditions.push(lte(donations.date, end));
+ }
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const [totalRow] = await db.select({ total: count() }).from(donations).where(where);
+ const total = totalRow?.total ?? 0;
+ const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
+
+ const [filteredSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(where);
+ const filteredTotal = filteredSumRow?.total ?? 0;
+
+ const now = new Date();
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const [monthSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, monthStart));
+ const monthTotal = monthSumRow?.total ?? 0;
+
+ const yearStart = new Date(now.getFullYear(), 0, 1);
+ const [yearSumRow] = await db.select({ total: sum(donations.amount) }).from(donations).where(gte(donations.date, yearStart));
+ const yearTotal = yearSumRow?.total ?? 0;
+
+ const rows = await db
+ .select({
+ id: donations.id,
+ source: donations.source,
+ amount: donations.amount,
+ donorName: donations.donorName,
+ donorEmail: donations.donorEmail,
+ petId: donations.petId,
+ petName: pets.name,
+ isRecurring: donations.isRecurring,
+ notes: donations.notes,
+ date: donations.date,
+ createdAt: donations.createdAt
+ })
+ .from(donations)
+ .leftJoin(pets, eq(donations.petId, pets.id))
+ .where(where)
+ .orderBy(donations.createdAt)
+ .limit(PER_PAGE)
+ .offset(offset);
+
+ return {
+ donations: rows,
+ total,
+ page,
+ totalPages,
+ filteredTotal,
+ monthTotal,
+ yearTotal,
+ source,
+ startDate,
+ endDate
+ };
+};
diff --git a/src/routes/admin/donations/+page.svelte b/src/routes/admin/donations/+page.svelte
new file mode 100644
index 0000000..ab078c3
--- /dev/null
+++ b/src/routes/admin/donations/+page.svelte
@@ -0,0 +1,135 @@
+
+
+
+ Donations — Admin
+
+
+
+
+
+
+
+
This Month
+
{fmt.format(data.monthTotal / 100)}
+
+
+
This Year
+
{fmt.format(data.yearTotal / 100)}
+
+
+
Filtered Total
+
{fmt.format(data.filteredTotal / 100)}
+
+
+
+
+
+
+
+
+
+ handleFilterChange('startDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" />
+
+
+
+ handleFilterChange('endDate', e.currentTarget.value)} class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand" />
+
+
+
+
+{data.total} donation{data.total !== 1 ? 's' : ''} found
+
+
+ {#snippet row(donation)}
+
+
+ {donation.donorName ?? 'Anonymous'}
+
+ {#if donation.donorEmail}
+ {donation.donorEmail}
+ {/if}
+ |
+ {fmt.format(donation.amount / 100)} |
+
+
+ {donation.source.replace(/_/g, ' ')}
+
+ |
+ {donation.petName ?? '—'} |
+ {formatDate(donation.date)} |
+
+ {#if donation.isRecurring}
+
+ Yes
+
+ {:else}
+ One-time
+ {/if}
+ |
+
+ Edit
+ |
+ {/snippet}
+
+
+
diff --git a/src/routes/admin/donations/[id]/+page.server.ts b/src/routes/admin/donations/[id]/+page.server.ts
new file mode 100644
index 0000000..f5d1390
--- /dev/null
+++ b/src/routes/admin/donations/[id]/+page.server.ts
@@ -0,0 +1,74 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { donations, pets } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Donation not found');
+
+ const [donation] = await db.select().from(donations).where(eq(donations.id, id));
+ if (!donation) error(404, 'Donation not found');
+
+ const petList = await db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name);
+ return { donation, pets: petList };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+
+ const source = fd.get('source')?.toString() ?? '';
+ const amountStr = fd.get('amount')?.toString().trim() ?? '';
+ const donorName = fd.get('donorName')?.toString().trim() || null;
+ const donorEmail = fd.get('donorEmail')?.toString().trim() || null;
+ const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null;
+ const isRecurring = fd.get('isRecurring') === 'on';
+ const notes = fd.get('notes')?.toString().trim() || null;
+ const purpose = fd.get('purpose')?.toString().trim() || null;
+
+ const validSources = ['stripe', 'paypal', 'venmo', 'givebutter', 'cash', 'check', 'zelle', 'google_pay', 'square', 'other'];
+ if (!validSources.includes(source)) return fail(400, { error: 'Please select a valid source' });
+
+ const parsedAmount = parseFloat(amountStr);
+ if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) return fail(400, { error: 'Amount must be a positive number' });
+
+ await db.update(donations).set({
+ source: source as typeof donations.source.enumValues[number],
+ amount: Math.round(parsedAmount * 100),
+ donorName,
+ donorEmail,
+ petId,
+ isRecurring,
+ notes,
+ purpose
+ }).where(eq(donations.id, id));
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'update_donation',
+ entity: 'donation',
+ entityId: id,
+ diff: { source, amount: parsedAmount, donorName }
+ });
+
+ return { success: true };
+ },
+
+ delete: async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+
+ await db.delete(donations).where(eq(donations.id, id));
+ await logAudit({ userId: locals.user!.id, action: 'delete_donation', entity: 'donation', entityId: id });
+
+ redirect(303, '/admin/donations');
+ }
+};
diff --git a/src/routes/admin/donations/[id]/+page.svelte b/src/routes/admin/donations/[id]/+page.svelte
new file mode 100644
index 0000000..0774f46
--- /dev/null
+++ b/src/routes/admin/donations/[id]/+page.svelte
@@ -0,0 +1,151 @@
+
+
+
+ Edit Donation — Admin
+
+
+
+
+
+ {#if showSuccess}
+
+
+ Donation updated successfully.
+
+ {/if}
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
+
+
+
deleteFormEl?.requestSubmit()}
+ oncancel={() => confirmDelete = false}
+ />
+
diff --git a/src/routes/admin/donations/new/+page.server.ts b/src/routes/admin/donations/new/+page.server.ts
new file mode 100644
index 0000000..bff7991
--- /dev/null
+++ b/src/routes/admin/donations/new/+page.server.ts
@@ -0,0 +1,62 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { donations, pets } from '$lib/server/schema';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+ const petList = await db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name);
+ return { pets: petList };
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+ const fd = await request.formData();
+
+ const source = fd.get('source')?.toString() ?? '';
+ const amountStr = fd.get('amount')?.toString().trim() ?? '';
+ const donorName = fd.get('donorName')?.toString().trim() || null;
+ const donorEmail = fd.get('donorEmail')?.toString().trim() || null;
+ const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null;
+ const isRecurring = fd.get('isRecurring') === 'on';
+ const notes = fd.get('notes')?.toString().trim() || null;
+ const purpose = fd.get('purpose')?.toString().trim() || null;
+
+ const validSources = ['stripe', 'paypal', 'venmo', 'givebutter', 'cash', 'check', 'zelle', 'google_pay', 'square', 'other'];
+ if (!validSources.includes(source)) {
+ return fail(400, { error: 'Please select a valid source', values: Object.fromEntries(fd) });
+ }
+
+ const parsedAmount = parseFloat(amountStr);
+ if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) {
+ return fail(400, { error: 'Amount must be a positive number', values: Object.fromEntries(fd) });
+ }
+
+ const amountCents = Math.round(parsedAmount * 100);
+
+ const [result] = await db.insert(donations).values({
+ source: source as typeof donations.source.enumValues[number],
+ amount: amountCents,
+ donorName,
+ donorEmail,
+ petId,
+ isRecurring,
+ notes,
+ purpose
+ });
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'create_donation',
+ entity: 'donation',
+ entityId: result.insertId,
+ diff: { source, amount: parsedAmount, donorName }
+ });
+
+ redirect(303, '/admin/donations');
+ }
+};
diff --git a/src/routes/admin/donations/new/+page.svelte b/src/routes/admin/donations/new/+page.svelte
new file mode 100644
index 0000000..f1ccf18
--- /dev/null
+++ b/src/routes/admin/donations/new/+page.svelte
@@ -0,0 +1,115 @@
+
+
+
+ Add Donation — Admin
+
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/expenses/+page.server.ts b/src/routes/admin/expenses/+page.server.ts
new file mode 100644
index 0000000..62c1d5e
--- /dev/null
+++ b/src/routes/admin/expenses/+page.server.ts
@@ -0,0 +1,76 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { expenses, pets, vets } from '$lib/server/schema';
+import { eq, and, gte, lte, like, count, sum, desc } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+const PER_PAGE = 20;
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+
+ const category = url.searchParams.get('category') ?? '';
+ const startDate = url.searchParams.get('startDate') ?? '';
+ const endDate = url.searchParams.get('endDate') ?? '';
+ const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
+ const offset = (page - 1) * PER_PAGE;
+
+ const conditions = [];
+ if (category) conditions.push(like(expenses.category, `%${category}%`));
+ if (startDate) conditions.push(gte(expenses.date, new Date(startDate)));
+ if (endDate) conditions.push(lte(expenses.date, new Date(endDate)));
+
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
+
+ const [totalRow] = await db.select({ total: count() }).from(expenses).where(where);
+ const total = totalRow?.total ?? 0;
+ const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
+
+ const [filteredSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(where);
+ const filteredTotal = filteredSumRow?.total ?? 0;
+
+ const now = new Date();
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const [monthSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(gte(expenses.date, monthStart));
+ const monthTotal = monthSumRow?.total ?? 0;
+
+ const yearStart = new Date(now.getFullYear(), 0, 1);
+ const [yearSumRow] = await db.select({ total: sum(expenses.amount) }).from(expenses).where(gte(expenses.date, yearStart));
+ const yearTotal = yearSumRow?.total ?? 0;
+
+ const rows = await db
+ .select({
+ id: expenses.id,
+ category: expenses.category,
+ amount: expenses.amount,
+ description: expenses.description,
+ date: expenses.date,
+ receiptUrl: expenses.receiptUrl,
+ petId: expenses.petId,
+ petName: pets.name,
+ vetId: expenses.vetId,
+ vetName: vets.name
+ })
+ .from(expenses)
+ .leftJoin(pets, eq(expenses.petId, pets.id))
+ .leftJoin(vets, eq(expenses.vetId, vets.id))
+ .where(where)
+ .orderBy(desc(expenses.date))
+ .limit(PER_PAGE)
+ .offset(offset);
+
+ return {
+ expenses: rows,
+ total,
+ page,
+ totalPages,
+ filteredTotal,
+ monthTotal,
+ yearTotal,
+ category,
+ startDate,
+ endDate
+ };
+};
diff --git a/src/routes/admin/expenses/+page.svelte b/src/routes/admin/expenses/+page.svelte
new file mode 100644
index 0000000..aa7a5ce
--- /dev/null
+++ b/src/routes/admin/expenses/+page.svelte
@@ -0,0 +1,117 @@
+
+
+
+ Expenses — Admin
+
+
+
+
+
+
+
This Month
+
{fmt.format((data.monthTotal ?? 0) / 100)}
+
+
+
This Year
+
{fmt.format((data.yearTotal ?? 0) / 100)}
+
+
+
Filtered Total
+
{fmt.format((data.filteredTotal ?? 0) / 100)}
+
+
+
+
+
+{data.total} expense{data.total !== 1 ? 's' : ''} found
+
+
+ {#snippet row(expense)}
+
+
+ {expense.description || 'No description'}
+
+ |
+ {fmt.format(expense.amount / 100)} |
+
+ {#if expense.category}
+
+ {expense.category}
+
+ {:else}
+ —
+ {/if}
+ |
+ {expense.petName ?? '—'} |
+ {expense.vetName ?? '—'} |
+ {formatDate(expense.date)} |
+
+ Edit
+ |
+ {/snippet}
+
+
+
diff --git a/src/routes/admin/expenses/[id]/+page.server.ts b/src/routes/admin/expenses/[id]/+page.server.ts
new file mode 100644
index 0000000..2af4e06
--- /dev/null
+++ b/src/routes/admin/expenses/[id]/+page.server.ts
@@ -0,0 +1,77 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { expenses, pets, vets } from '$lib/server/schema';
+import { eq } from 'drizzle-orm';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ if (isNaN(id)) error(404, 'Expense not found');
+
+ const [expense] = await db.select().from(expenses).where(eq(expenses.id, id));
+ if (!expense) error(404, 'Expense not found');
+
+ const [petList, vetList] = await Promise.all([
+ db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name),
+ db.select({ id: vets.id, name: vets.name, clinic: vets.clinic }).from(vets).orderBy(vets.name)
+ ]);
+
+ return { expense, pets: petList, vets: vetList };
+};
+
+export const actions: Actions = {
+ update: async ({ request, params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+ const fd = await request.formData();
+
+ const description = fd.get('description')?.toString().trim() ?? '';
+ const category = fd.get('category')?.toString().trim() || null;
+ const amountStr = fd.get('amount')?.toString().trim() ?? '';
+ const dateStr = fd.get('date')?.toString() ?? '';
+ const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null;
+ const vetId = fd.get('vetId')?.toString() ? parseInt(fd.get('vetId') as string, 10) : null;
+ const notes = fd.get('notes')?.toString().trim() || null;
+
+ if (!description) return fail(400, { error: 'Description is required' });
+
+ const parsedAmount = parseFloat(amountStr);
+ if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) return fail(400, { error: 'Amount must be a positive number' });
+
+ if (!dateStr) return fail(400, { error: 'Date is required' });
+
+ await db.update(expenses).set({
+ description,
+ category,
+ amount: Math.round(parsedAmount * 100),
+ date: new Date(dateStr),
+ petId,
+ vetId,
+ notes
+ }).where(eq(expenses.id, id));
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'update_expense',
+ entity: 'expense',
+ entityId: id,
+ diff: { description, category, amount: parsedAmount }
+ });
+
+ return { success: true };
+ },
+
+ delete: async ({ params, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+ const id = parseInt(params.id, 10);
+
+ await db.delete(expenses).where(eq(expenses.id, id));
+ await logAudit({ userId: locals.user!.id, action: 'delete_expense', entity: 'expense', entityId: id });
+
+ redirect(303, '/admin/expenses');
+ }
+};
diff --git a/src/routes/admin/expenses/[id]/+page.svelte b/src/routes/admin/expenses/[id]/+page.svelte
new file mode 100644
index 0000000..212bb8f
--- /dev/null
+++ b/src/routes/admin/expenses/[id]/+page.svelte
@@ -0,0 +1,146 @@
+
+
+
+ Edit Expense — Admin
+
+
+
+
+
+ {#if showSuccess}
+
+
+ Expense updated successfully.
+
+ {/if}
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
+
+
+
deleteFormEl?.requestSubmit()}
+ oncancel={() => confirmDelete = false}
+ />
+
diff --git a/src/routes/admin/expenses/new/+page.server.ts b/src/routes/admin/expenses/new/+page.server.ts
new file mode 100644
index 0000000..bddef8f
--- /dev/null
+++ b/src/routes/admin/expenses/new/+page.server.ts
@@ -0,0 +1,64 @@
+import type { PageServerLoad, Actions } from './$types';
+import { db } from '$lib/server/db';
+import { expenses, pets, vets } from '$lib/server/schema';
+import { error, fail, redirect } from '@sveltejs/kit';
+import { logAudit } from '$lib/server/audit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+ const [petList, vetList] = await Promise.all([
+ db.select({ id: pets.id, name: pets.name }).from(pets).orderBy(pets.name),
+ db.select({ id: vets.id, name: vets.name, clinic: vets.clinic }).from(vets).orderBy(vets.name)
+ ]);
+ return { pets: petList, vets: vetList };
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'expenses')) error(403, 'Access denied');
+ const fd = await request.formData();
+
+ const description = fd.get('description')?.toString().trim() ?? '';
+ const category = fd.get('category')?.toString().trim() || null;
+ const amountStr = fd.get('amount')?.toString().trim() ?? '';
+ const dateStr = fd.get('date')?.toString() ?? '';
+ const petId = fd.get('petId')?.toString() ? parseInt(fd.get('petId') as string, 10) : null;
+ const vetId = fd.get('vetId')?.toString() ? parseInt(fd.get('vetId') as string, 10) : null;
+ const notes = fd.get('notes')?.toString().trim() || null;
+
+ if (!description) {
+ return fail(400, { error: 'Description is required', values: Object.fromEntries(fd) });
+ }
+
+ const parsedAmount = parseFloat(amountStr);
+ if (!amountStr || isNaN(parsedAmount) || parsedAmount <= 0) {
+ return fail(400, { error: 'Amount must be a positive number', values: Object.fromEntries(fd) });
+ }
+
+ if (!dateStr) {
+ return fail(400, { error: 'Date is required', values: Object.fromEntries(fd) });
+ }
+
+ const [result] = await db.insert(expenses).values({
+ description,
+ category,
+ amount: Math.round(parsedAmount * 100),
+ date: new Date(dateStr),
+ petId,
+ vetId,
+ notes
+ });
+
+ await logAudit({
+ userId: locals.user!.id,
+ action: 'create_expense',
+ entity: 'expense',
+ entityId: result.insertId,
+ diff: { description, category, amount: parsedAmount }
+ });
+
+ redirect(303, '/admin/expenses');
+ }
+};
diff --git a/src/routes/admin/expenses/new/+page.svelte b/src/routes/admin/expenses/new/+page.svelte
new file mode 100644
index 0000000..af12db0
--- /dev/null
+++ b/src/routes/admin/expenses/new/+page.svelte
@@ -0,0 +1,94 @@
+
+
+
+ Add Expense — Admin
+
+
+
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+
+
+
diff --git a/src/routes/admin/reports/+page.server.ts b/src/routes/admin/reports/+page.server.ts
new file mode 100644
index 0000000..e14a8a2
--- /dev/null
+++ b/src/routes/admin/reports/+page.server.ts
@@ -0,0 +1,84 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { donations, expenses } from '$lib/server/schema';
+import { sql, and, gte, lt, sum } from 'drizzle-orm';
+import { error } from '@sveltejs/kit';
+import { hasPermission } from '$lib/roles';
+import type { Role } from '$lib/roles';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ if (!hasPermission(locals.user!.role as Role, 'donations')) error(403, 'Access denied');
+
+ const year = parseInt(url.searchParams.get('year') ?? String(new Date().getFullYear()), 10);
+ const yearStart = new Date(year, 0, 1);
+ const yearEnd = new Date(year + 1, 0, 1);
+
+ // Donations by month
+ const donationsByMonth = await db
+ .select({
+ month: sql`MONTH(${donations.date})`.as('month'),
+ total: sql`COALESCE(SUM(${donations.amount}), 0)`.as('total')
+ })
+ .from(donations)
+ .where(and(gte(donations.date, yearStart), lt(donations.date, yearEnd)))
+ .groupBy(sql`MONTH(${donations.date})`)
+ .orderBy(sql`MONTH(${donations.date})`);
+
+ // Donations by source
+ const donationsBySource = await db
+ .select({
+ source: donations.source,
+ total: sql`COALESCE(SUM(${donations.amount}), 0)`.as('total')
+ })
+ .from(donations)
+ .where(and(gte(donations.date, yearStart), lt(donations.date, yearEnd)))
+ .groupBy(donations.source)
+ .orderBy(sql`SUM(${donations.amount}) DESC`);
+
+ // Expenses by month
+ const expensesByMonth = await db
+ .select({
+ month: sql`MONTH(${expenses.date})`.as('month'),
+ total: sql`COALESCE(SUM(${expenses.amount}), 0)`.as('total')
+ })
+ .from(expenses)
+ .where(and(gte(expenses.date, yearStart), lt(expenses.date, yearEnd)))
+ .groupBy(sql`MONTH(${expenses.date})`)
+ .orderBy(sql`MONTH(${expenses.date})`);
+
+ // Expenses by category
+ const expensesByCategory = await db
+ .select({
+ category: expenses.category,
+ total: sql`COALESCE(SUM(${expenses.amount}), 0)`.as('total')
+ })
+ .from(expenses)
+ .where(and(gte(expenses.date, yearStart), lt(expenses.date, yearEnd)))
+ .groupBy(expenses.category)
+ .orderBy(sql`SUM(${expenses.amount}) DESC`);
+
+ const donMonthMap = new Map(donationsByMonth.map((r) => [r.month, r.total]));
+ const expMonthMap = new Map(expensesByMonth.map((r) => [r.month, r.total]));
+
+ const monthlyBreakdown = Array.from({ length: 12 }, (_, i) => {
+ const m = i + 1;
+ const d = donMonthMap.get(m) ?? 0;
+ const e = expMonthMap.get(m) ?? 0;
+ return { month: m, donations: d, expenses: e, net: d - e };
+ });
+
+ const totalDonations = monthlyBreakdown.reduce((s, r) => s + r.donations, 0);
+ const totalExpenses = monthlyBreakdown.reduce((s, r) => s + r.expenses, 0);
+
+ return {
+ year,
+ donationsByMonth,
+ expensesByMonth,
+ donationsBySource: donationsBySource.map(r => ({ source: r.source, total: r.total })),
+ expensesByCategory: expensesByCategory.map(r => ({ category: r.category ?? 'Uncategorized', total: r.total })),
+ totalDonations,
+ totalExpenses,
+ net: totalDonations - totalExpenses,
+ monthlyBreakdown
+ };
+};
diff --git a/src/routes/admin/reports/+page.svelte b/src/routes/admin/reports/+page.svelte
new file mode 100644
index 0000000..b861e04
--- /dev/null
+++ b/src/routes/admin/reports/+page.svelte
@@ -0,0 +1,183 @@
+
+
+
+ Financial Reports — Admin
+
+
+
+
+
Financial Reports
+
+
+
+
+
+
+
+
+
+
+
Total Donations
+
{fmt(data.totalDonations)}
+
+
+
Total Expenses
+
{fmt(data.totalExpenses)}
+
+
+
Net Income
+
= 0} class:text-red-600={data.net < 0}>{fmt(data.net)}
+
+
+
+
+
+
Monthly Breakdown — {data.year}
+
+
+
+
+ | Month |
+ Donations |
+ Expenses |
+ Net |
+
+
+
+ {#each data.monthlyBreakdown as row}
+
+ | {MONTH_NAMES[row.month - 1]} |
+ {fmt(row.donations)} |
+ {fmt(row.expenses)} |
+ = 0} class:text-red-600={row.net < 0}>{fmt(row.net)} |
+
+ {/each}
+
+
+
+ | Total |
+ {fmt(data.totalDonations)} |
+ {fmt(data.totalExpenses)} |
+ = 0} class:text-red-600={data.net < 0}>{fmt(data.net)} |
+
+
+
+
+
+
+
+
+
+
Donations by Source
+ {#if data.donationsBySource.length === 0}
+
No donation data for {data.year}.
+ {:else}
+
+ {#each data.donationsBySource as row}
+ {@const pct = (row.total / maxOf(data.donationsBySource)) * 100}
+
+
+ {row.source.replace(/_/g, ' ')}
+ {fmt(row.total)}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
Expenses by Category
+ {#if data.expensesByCategory.length === 0}
+
No expense data for {data.year}.
+ {:else}
+
+ {#each data.expensesByCategory as row, i}
+ {@const pct = (row.total / maxOf(data.expensesByCategory)) * 100}
+
+
+ {row.category}
+ {fmt(row.total)}
+
+
+
+ {/each}
+
+ {/if}
+
+
+