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,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()} />
|
||||
Reference in New Issue
Block a user