feat(admin): Task 34 — donations, expenses, and financial reports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const sources = [
|
||||
'stripe', 'paypal', 'venmo', 'givebutter', 'cash',
|
||||
'check', 'zelle', 'google_pay', 'square', 'other'
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ key: 'donorName', label: 'Donor' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'source', label: 'Source' },
|
||||
{ key: 'petName', label: 'Pet' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'recurring', label: 'Recurring' },
|
||||
{ key: 'actions', label: '' }
|
||||
];
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
function handleFilterChange(param: string, value: string) {
|
||||
const url = new URL(page.url);
|
||||
if (value) { url.searchParams.set(param, value); } else { url.searchParams.delete(param); }
|
||||
url.searchParams.delete('page');
|
||||
goto(url.pathname + url.search);
|
||||
}
|
||||
|
||||
function buildBaseUrl(): string {
|
||||
const url = new URL(page.url);
|
||||
return url.pathname + '?' + url.searchParams.toString();
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Donations — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Donations</h1>
|
||||
<a
|
||||
href="/admin/donations/new"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-teal-brand text-white text-sm font-medium rounded-lg hover:bg-teal-dark transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Donation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">This Month</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format(data.monthTotal / 100)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">This Year</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format(data.yearTotal / 100)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">Filtered Total</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format(data.filteredTotal / 100)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<select
|
||||
class="px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-sm text-gray-brand focus:outline-none focus:ring-2 focus:ring-teal-brand"
|
||||
value={data.source}
|
||||
onchange={(e) => handleFilterChange('source', e.currentTarget.value)}
|
||||
>
|
||||
<option value="">All Sources</option>
|
||||
{#each sources as s}
|
||||
<option value={s}>{s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="startDate" class="text-sm text-gray-500 whitespace-nowrap">From</label>
|
||||
<input type="date" id="startDate" value={data.startDate} onchange={(e) => 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" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="endDate" class="text-sm text-gray-500 whitespace-nowrap">To</label>
|
||||
<input type="date" id="endDate" value={data.endDate} onchange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mb-3">{data.total} donation{data.total !== 1 ? 's' : ''} found</p>
|
||||
|
||||
<DataTable {columns} rows={data.donations}>
|
||||
{#snippet row(donation)}
|
||||
<td class="px-4 py-3">
|
||||
<a href="/admin/donations/{donation.id}" class="font-medium text-teal-brand hover:text-teal-dark hover:underline">
|
||||
{donation.donorName ?? 'Anonymous'}
|
||||
</a>
|
||||
{#if donation.donorEmail}
|
||||
<p class="text-xs text-gray-400">{donation.donorEmail}</p>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{fmt.format(donation.amount / 100)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800 ring-1 ring-inset ring-gray-600/20">
|
||||
{donation.source.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{donation.petName ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-600">{formatDate(donation.date)}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if donation.isRecurring}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-100 rounded-full px-2 py-0.5">
|
||||
Yes
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">One-time</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/admin/donations/{donation.id}" class="text-teal-brand hover:text-teal-dark text-sm font-medium transition-colors">Edit</a>
|
||||
</td>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Pagination page={data.page} totalPages={data.totalPages} baseUrl={buildBaseUrl()} />
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const donation = $derived(data.donation);
|
||||
|
||||
const sources = [
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'paypal', label: 'PayPal' },
|
||||
{ value: 'venmo', label: 'Venmo' },
|
||||
{ value: 'givebutter', label: 'GiveButter' },
|
||||
{ value: 'cash', label: 'Cash' },
|
||||
{ value: 'check', label: 'Check' },
|
||||
{ value: 'zelle', label: 'Zelle' },
|
||||
{ value: 'google_pay', label: 'Google Pay' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let submitting = $state(false);
|
||||
let showSuccess = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let deleteFormEl: HTMLFormElement | undefined = $state();
|
||||
|
||||
function handleEnhance() {
|
||||
submitting = true;
|
||||
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
|
||||
submitting = false;
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') {
|
||||
showSuccess = true;
|
||||
setTimeout(() => showSuccess = false, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit Donation — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/donations" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Edit Donation</h1>
|
||||
</div>
|
||||
|
||||
{#if showSuccess}
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Donation updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/update" use:enhance={handleEnhance} class="space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Donation Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="source" class={labelClass}>Source <span class="text-red-500">*</span></label>
|
||||
<select id="source" name="source" required class={inputClass}>
|
||||
<option value="">Select source</option>
|
||||
{#each sources as s}
|
||||
<option value={s.value} selected={donation.source === s.value}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="amount" class={labelClass}>Amount ($) <span class="text-red-500">*</span></label>
|
||||
<input type="number" id="amount" name="amount" required step="0.01" min="0.01" value={(donation.amount / 100).toFixed(2)} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="donorName" class={labelClass}>Donor Name</label>
|
||||
<input type="text" id="donorName" name="donorName" value={donation.donorName ?? ''} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="donorEmail" class={labelClass}>Donor Email</label>
|
||||
<input type="email" id="donorEmail" name="donorEmail" value={donation.donorEmail ?? ''} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="petId" class={labelClass}>Linked Pet (optional)</label>
|
||||
<select id="petId" name="petId" class={inputClass}>
|
||||
<option value="">No pet linked</option>
|
||||
{#each data.pets as pet}
|
||||
<option value={pet.id} selected={donation.petId === pet.id}>{pet.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purpose" class={labelClass}>Purpose</label>
|
||||
<input type="text" id="purpose" name="purpose" value={donation.purpose ?? ''} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<label class="inline-flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="isRecurring" checked={donation.isRecurring} class="w-5 h-5 rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
|
||||
<span class="text-sm font-medium text-gray-700">Recurring donation</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class={labelClass}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" class="{inputClass} resize-y">{donation.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="button" onclick={() => confirmDelete = true} class="px-4 py-2.5 text-sm font-medium rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors cursor-pointer">
|
||||
Delete Donation
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<a href="/admin/donations" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors">Cancel</a>
|
||||
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance class="hidden"></form>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
title="Delete Donation"
|
||||
message="Are you sure you want to delete this donation? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
onconfirm={() => deleteFormEl?.requestSubmit()}
|
||||
oncancel={() => confirmDelete = false}
|
||||
/>
|
||||
</div>
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const sources = [
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'paypal', label: 'PayPal' },
|
||||
{ value: 'venmo', label: 'Venmo' },
|
||||
{ value: 'givebutter', label: 'GiveButter' },
|
||||
{ value: 'cash', label: 'Cash' },
|
||||
{ value: 'check', label: 'Check' },
|
||||
{ value: 'zelle', label: 'Zelle' },
|
||||
{ value: 'google_pay', label: 'Google Pay' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'other', label: 'Other' }
|
||||
];
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Add Donation — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/donations" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Add Donation</h1>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => { submitting = false; await update(); };
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Donation Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="source" class={labelClass}>Source <span class="text-red-500">*</span></label>
|
||||
<select id="source" name="source" required class={inputClass}>
|
||||
<option value="">Select source</option>
|
||||
{#each sources as s}
|
||||
<option value={s.value} selected={form?.values?.source === s.value}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="amount" class={labelClass}>Amount ($) <span class="text-red-500">*</span></label>
|
||||
<input type="number" id="amount" name="amount" required step="0.01" min="0.01" value={form?.values?.amount ?? ''} class={inputClass} placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="donorName" class={labelClass}>Donor Name</label>
|
||||
<input type="text" id="donorName" name="donorName" value={form?.values?.donorName ?? ''} class={inputClass} placeholder="Donor's full name" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="donorEmail" class={labelClass}>Donor Email</label>
|
||||
<input type="email" id="donorEmail" name="donorEmail" value={form?.values?.donorEmail ?? ''} class={inputClass} placeholder="donor@example.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="petId" class={labelClass}>Linked Pet (optional)</label>
|
||||
<select id="petId" name="petId" class={inputClass}>
|
||||
<option value="">No pet linked</option>
|
||||
{#each data.pets as pet}
|
||||
<option value={pet.id} selected={form?.values?.petId == String(pet.id)}>{pet.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purpose" class={labelClass}>Purpose</label>
|
||||
<input type="text" id="purpose" name="purpose" value={form?.values?.purpose ?? ''} class={inputClass} placeholder="e.g., General, Medical Fund" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<label class="inline-flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" name="isRecurring" checked={form?.values?.isRecurring === 'on'} class="w-5 h-5 rounded border-gray-soft text-teal-brand focus:ring-teal-brand" />
|
||||
<span class="text-sm font-medium text-gray-700">Recurring donation</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class={labelClass}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" class="{inputClass} resize-y" placeholder="Any additional notes...">{form?.values?.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/admin/donations" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors">Cancel</a>
|
||||
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{submitting ? 'Adding...' : 'Add Donation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Pagination from '$lib/components/ui/Pagination.svelte';
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const columns = [
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'category', label: 'Category' },
|
||||
{ key: 'petName', label: 'Pet' },
|
||||
{ key: 'vetName', label: 'Vet' },
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'actions', label: '' }
|
||||
];
|
||||
|
||||
const fmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
function handleFilterChange(param: string, value: string) {
|
||||
const url = new URL(page.url);
|
||||
if (value) { url.searchParams.set(param, value); } else { url.searchParams.delete(param); }
|
||||
url.searchParams.delete('page');
|
||||
goto(url.pathname + url.search);
|
||||
}
|
||||
|
||||
function buildBaseUrl(): string {
|
||||
const url = new URL(page.url);
|
||||
return url.pathname + '?' + url.searchParams.toString();
|
||||
}
|
||||
|
||||
function formatDate(d: string | Date): string {
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Expenses — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Expenses</h1>
|
||||
<a href="/admin/expenses/new" class="inline-flex items-center gap-2 px-4 py-2.5 bg-teal-brand text-white text-sm font-medium rounded-lg hover:bg-teal-dark transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Expense
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">This Month</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format((data.monthTotal ?? 0) / 100)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">This Year</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format((data.yearTotal ?? 0) / 100)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-5">
|
||||
<p class="text-sm text-gray-500">Filtered Total</p>
|
||||
<p class="text-2xl font-bold text-gray-900 mt-1">{fmt.format((data.filteredTotal ?? 0) / 100)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-4 mb-6">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by category..."
|
||||
value={data.category}
|
||||
onchange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="startDate" class="text-sm text-gray-500 whitespace-nowrap">From</label>
|
||||
<input type="date" id="startDate" value={data.startDate} onchange={(e) => 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" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="endDate" class="text-sm text-gray-500 whitespace-nowrap">To</label>
|
||||
<input type="date" id="endDate" value={data.endDate} onchange={(e) => 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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mb-3">{data.total} expense{data.total !== 1 ? 's' : ''} found</p>
|
||||
|
||||
<DataTable {columns} rows={data.expenses}>
|
||||
{#snippet row(expense)}
|
||||
<td class="px-4 py-3">
|
||||
<a href="/admin/expenses/{expense.id}" class="font-medium text-teal-brand hover:text-teal-dark hover:underline">
|
||||
{expense.description || 'No description'}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{fmt.format(expense.amount / 100)}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if expense.category}
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-gray-100 text-gray-800 ring-1 ring-inset ring-gray-600/20">
|
||||
{expense.category}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{expense.petName ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-600">{expense.vetName ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-gray-600">{formatDate(expense.date)}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/admin/expenses/{expense.id}" class="text-teal-brand hover:text-teal-dark text-sm font-medium transition-colors">Edit</a>
|
||||
</td>
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<Pagination page={data.page} totalPages={data.totalPages} baseUrl={buildBaseUrl()} />
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const expense = $derived(data.expense);
|
||||
|
||||
let submitting = $state(false);
|
||||
let showSuccess = $state(false);
|
||||
let confirmDelete = $state(false);
|
||||
let deleteFormEl: HTMLFormElement | undefined = $state();
|
||||
|
||||
function handleEnhance() {
|
||||
submitting = true;
|
||||
return async ({ update, result }: { update: (opts?: { reset?: boolean }) => Promise<void>; result: { type: string } }) => {
|
||||
submitting = false;
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') {
|
||||
showSuccess = true;
|
||||
setTimeout(() => showSuccess = false, 3000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateInput(d: Date | string | null): string {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Edit Expense — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/expenses" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Edit Expense</h1>
|
||||
</div>
|
||||
|
||||
{#if showSuccess}
|
||||
<div class="mb-6 p-4 bg-green-50 border border-green-200 rounded-xl text-sm text-green-700 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Expense updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/update" use:enhance={handleEnhance} class="space-y-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Expense Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label for="description" class={labelClass}>Description <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="description" name="description" required value={expense.description} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class={labelClass}>Category</label>
|
||||
<input type="text" id="category" name="category" value={expense.category ?? ''} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="amount" class={labelClass}>Amount ($) <span class="text-red-500">*</span></label>
|
||||
<input type="number" id="amount" name="amount" required step="0.01" min="0.01" value={(expense.amount / 100).toFixed(2)} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="date" class={labelClass}>Date <span class="text-red-500">*</span></label>
|
||||
<input type="date" id="date" name="date" required value={formatDateInput(expense.date)} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="petId" class={labelClass}>Pet (optional)</label>
|
||||
<select id="petId" name="petId" class={inputClass}>
|
||||
<option value="">No pet linked</option>
|
||||
{#each data.pets as pet}
|
||||
<option value={pet.id} selected={expense.petId === pet.id}>{pet.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="vetId" class={labelClass}>Vet (optional)</label>
|
||||
<select id="vetId" name="vetId" class={inputClass}>
|
||||
<option value="">No vet linked</option>
|
||||
{#each data.vets as vet}
|
||||
<option value={vet.id} selected={expense.vetId === vet.id}>
|
||||
{vet.name}{vet.clinic ? ` — ${vet.clinic}` : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if expense.receiptUrl}
|
||||
<div class="md:col-span-2">
|
||||
<p class={labelClass}>Receipt</p>
|
||||
<a href={expense.receiptUrl} target="_blank" class="text-sm text-teal-brand hover:text-teal-dark">View receipt</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class={labelClass}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" class="{inputClass} resize-y">{expense.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<button type="button" onclick={() => confirmDelete = true} class="px-4 py-2.5 text-sm font-medium rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors cursor-pointer">
|
||||
Delete Expense
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
<a href="/admin/expenses" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors">Cancel</a>
|
||||
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance class="hidden"></form>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirmDelete}
|
||||
title="Delete Expense"
|
||||
message="Are you sure you want to delete this expense? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
onconfirm={() => deleteFormEl?.requestSubmit()}
|
||||
oncancel={() => confirmDelete = false}
|
||||
/>
|
||||
</div>
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-soft bg-white text-gray-brand placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-brand focus:border-teal-brand text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Add Expense — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/admin/expenses" class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Add Expense</h1>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }}
|
||||
class="space-y-6"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-soft p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Expense Details</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="md:col-span-2">
|
||||
<label for="description" class={labelClass}>Description <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="description" name="description" required value={form?.values?.description ?? ''} class={inputClass} placeholder="What was this expense for?" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="category" class={labelClass}>Category</label>
|
||||
<input type="text" id="category" name="category" value={form?.values?.category ?? ''} class={inputClass} placeholder="e.g., Vet Bills, Food, Supplies" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="amount" class={labelClass}>Amount ($) <span class="text-red-500">*</span></label>
|
||||
<input type="number" id="amount" name="amount" required step="0.01" min="0.01" value={form?.values?.amount ?? ''} class={inputClass} placeholder="0.00" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="date" class={labelClass}>Date <span class="text-red-500">*</span></label>
|
||||
<input type="date" id="date" name="date" required value={form?.values?.date ?? ''} class={inputClass} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="petId" class={labelClass}>Pet (optional)</label>
|
||||
<select id="petId" name="petId" class={inputClass}>
|
||||
<option value="">No pet linked</option>
|
||||
{#each data.pets as pet}
|
||||
<option value={pet.id} selected={form?.values?.petId == String(pet.id)}>{pet.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="vetId" class={labelClass}>Vet (optional)</label>
|
||||
<select id="vetId" name="vetId" class={inputClass}>
|
||||
<option value="">No vet linked</option>
|
||||
{#each data.vets as vet}
|
||||
<option value={vet.id} selected={form?.values?.vetId == String(vet.id)}>
|
||||
{vet.name}{vet.clinic ? ` — ${vet.clinic}` : ''}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="notes" class={labelClass}>Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" class="{inputClass} resize-y" placeholder="Additional notes...">{form?.values?.notes ?? ''}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/admin/expenses" class="px-6 py-2.5 text-sm font-medium rounded-lg border border-gray-soft text-gray-brand hover:bg-gray-100 transition-colors">Cancel</a>
|
||||
<button type="submit" disabled={submitting} class="px-6 py-2.5 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{submitting ? 'Adding...' : 'Add Expense'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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<number>`MONTH(${donations.date})`.as('month'),
|
||||
total: sql<number>`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<number>`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<number>`MONTH(${expenses.date})`.as('month'),
|
||||
total: sql<number>`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<number>`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
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
stripe: 'bg-indigo-500', paypal: 'bg-blue-500', venmo: 'bg-cyan-500',
|
||||
givebutter: 'bg-amber-500', cash: 'bg-green-500', check: 'bg-emerald-600',
|
||||
zelle: 'bg-purple-500', google_pay: 'bg-red-500', square: 'bg-slate-600', other: 'bg-gray-400'
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = [
|
||||
'bg-rose-500', 'bg-orange-500', 'bg-amber-500', 'bg-lime-500',
|
||||
'bg-teal-500', 'bg-cyan-500', 'bg-blue-500', 'bg-indigo-500',
|
||||
'bg-violet-500', 'bg-pink-500', 'bg-red-400', 'bg-emerald-500'
|
||||
];
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
||||
|
||||
function fmt(n: number): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n / 100);
|
||||
}
|
||||
|
||||
function changeYear(e: Event) {
|
||||
const y = (e.target as HTMLSelectElement).value;
|
||||
goto(`/admin/reports?year=${y}`, { replaceState: true });
|
||||
}
|
||||
|
||||
function maxOf(arr: { total: number }[]): number {
|
||||
if (arr.length === 0) return 1;
|
||||
return Math.max(...arr.map((r) => r.total), 1);
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const rows: string[][] = [];
|
||||
rows.push(['Month', 'Donations', 'Expenses', 'Net']);
|
||||
for (const m of data.monthlyBreakdown) {
|
||||
rows.push([MONTH_NAMES[m.month - 1], (m.donations / 100).toFixed(2), (m.expenses / 100).toFixed(2), (m.net / 100).toFixed(2)]);
|
||||
}
|
||||
rows.push(['TOTAL', (data.totalDonations / 100).toFixed(2), (data.totalExpenses / 100).toFixed(2), (data.net / 100).toFixed(2)]);
|
||||
rows.push([], ['Donation Source', 'Amount']);
|
||||
for (const s of data.donationsBySource) {
|
||||
rows.push([s.source, (s.total / 100).toFixed(2)]);
|
||||
}
|
||||
rows.push([], ['Expense Category', 'Amount']);
|
||||
for (const c of data.expensesByCategory) {
|
||||
rows.push([c.category, (c.total / 100).toFixed(2)]);
|
||||
}
|
||||
|
||||
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `financial-report-${data.year}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Financial Reports — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Financial Reports</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<select value={data.year} onchange={changeYear} class="px-3 py-2 rounded-lg border border-gray-300 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-brand">
|
||||
{#each yearOptions as y}
|
||||
<option value={y}>{y}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button onclick={exportCsv} class="px-4 py-2 text-sm font-medium rounded-lg bg-teal-brand text-white hover:bg-teal-dark transition-colors cursor-pointer">Export CSV</button>
|
||||
<button onclick={() => window.print()} class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors cursor-pointer">Print</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Total Donations</p>
|
||||
<p class="text-2xl font-bold text-green-600 mt-1">{fmt(data.totalDonations)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Total Expenses</p>
|
||||
<p class="text-2xl font-bold text-red-600 mt-1">{fmt(data.totalExpenses)}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-xs text-gray-400 uppercase tracking-wide">Net Income</p>
|
||||
<p class="text-2xl font-bold mt-1" class:text-green-600={data.net >= 0} class:text-red-600={data.net < 0}>{fmt(data.net)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Breakdown Table -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Monthly Breakdown — {data.year}</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-3 px-2 font-medium text-gray-500">Month</th>
|
||||
<th class="text-right py-3 px-2 font-medium text-gray-500">Donations</th>
|
||||
<th class="text-right py-3 px-2 font-medium text-gray-500">Expenses</th>
|
||||
<th class="text-right py-3 px-2 font-medium text-gray-500">Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.monthlyBreakdown as row}
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="py-2.5 px-2 text-gray-900">{MONTH_NAMES[row.month - 1]}</td>
|
||||
<td class="py-2.5 px-2 text-right text-green-600">{fmt(row.donations)}</td>
|
||||
<td class="py-2.5 px-2 text-right text-red-600">{fmt(row.expenses)}</td>
|
||||
<td class="py-2.5 px-2 text-right font-medium" class:text-green-600={row.net >= 0} class:text-red-600={row.net < 0}>{fmt(row.net)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="border-t-2 border-gray-300 font-bold">
|
||||
<td class="py-3 px-2 text-gray-900">Total</td>
|
||||
<td class="py-3 px-2 text-right text-green-600">{fmt(data.totalDonations)}</td>
|
||||
<td class="py-3 px-2 text-right text-red-600">{fmt(data.totalExpenses)}</td>
|
||||
<td class="py-3 px-2 text-right" class:text-green-600={data.net >= 0} class:text-red-600={data.net < 0}>{fmt(data.net)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Donations by Source -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Donations by Source</h2>
|
||||
{#if data.donationsBySource.length === 0}
|
||||
<p class="text-sm text-gray-400">No donation data for {data.year}.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.donationsBySource as row}
|
||||
{@const pct = (row.total / maxOf(data.donationsBySource)) * 100}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700 capitalize">{row.source.replace(/_/g, ' ')}</span>
|
||||
<span class="text-sm font-medium text-gray-900">{fmt(row.total)}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-3">
|
||||
<div class="h-3 rounded-full transition-all {SOURCE_COLORS[row.source] ?? 'bg-gray-400'}" style="width: {pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Expenses by Category -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Expenses by Category</h2>
|
||||
{#if data.expensesByCategory.length === 0}
|
||||
<p class="text-sm text-gray-400">No expense data for {data.year}.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.expensesByCategory as row, i}
|
||||
{@const pct = (row.total / maxOf(data.expensesByCategory)) * 100}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm text-gray-700">{row.category}</span>
|
||||
<span class="text-sm font-medium text-gray-900">{fmt(row.total)}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-100 rounded-full h-3">
|
||||
<div class="h-3 rounded-full transition-all {CATEGORY_COLORS[i % CATEGORY_COLORS.length]}" style="width: {pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user