feat(admin): Task 34 — donations, expenses, and financial reports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:30:55 -05:00
co-authored by Claude Sonnet 4.6
parent f06e85e6f8
commit d1e7a52eec
14 changed files with 1458 additions and 0 deletions
+117
View File
@@ -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()} />