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

Donations

+ + + + + Add Donation + +
+ + +
+
+

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

Edit Donation

+
+ + {#if showSuccess} +
+ + + + Donation updated successfully. +
+ {/if} + + {#if form?.error} +
{form.error}
+ {/if} + +
+
+

Donation Details

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

Add Donation

+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
{ + submitting = true; + return async ({ update }) => { submitting = false; await update(); }; + }} + class="space-y-6" + > +
+

Donation Details

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

Expenses

+ + + + + Add Expense + +
+ +
+
+

This Month

+

{fmt.format((data.monthTotal ?? 0) / 100)}

+
+
+

This Year

+

{fmt.format((data.yearTotal ?? 0) / 100)}

+
+
+

Filtered Total

+

{fmt.format((data.filteredTotal ?? 0) / 100)}

+
+
+ +
+
+
+ handleFilterChange('category', e.currentTarget.value)} + class="w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand" + /> +
+
+ + 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} 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 + + +
+
+ + + + + +

Edit Expense

+
+ + {#if showSuccess} +
+ + + + Expense updated successfully. +
+ {/if} + + {#if form?.error} +
{form.error}
+ {/if} + +
+
+

Expense Details

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {#if expense.receiptUrl} +
+

Receipt

+ View receipt +
+ {/if} + +
+ + +
+
+
+ +
+ +
+ Cancel + +
+
+
+ + + + 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 + + +
+
+ + + + + +

Add Expense

+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
{ submitting = true; return async ({ update }) => { submitting = false; await update(); }; }} + class="space-y-6" + > +
+

Expense Details

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ Cancel + +
+
+
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}

+
+ + + + + + + + + + + {#each data.monthlyBreakdown as row} + + + + + + + {/each} + + + + + + + + + +
MonthDonationsExpensesNet
{MONTH_NAMES[row.month - 1]}{fmt(row.donations)}{fmt(row.expenses)}= 0} class:text-red-600={row.net < 0}>{fmt(row.net)}
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} +
+
+