feat(properties): expenses tab with electricity+water chart

- expense_kind enum (utilities + maintenance/repair/cleaning/insurance/tax/rent/other)
- property_expenses table with optional link to a property_accounts row
  (preserves history via ON DELETE SET NULL)
- services/expenses.ts: CRUD + 12-month monthly series aggregation +
  year-to-date summary by kind
- /properties/[id]/expenses tab: inline SVG line chart for electricity +
  water last 12 months (no chart library), summary card, add/edit/delete
  inline with account linking when kind matches
This commit is contained in:
2026-04-23 15:32:20 +07:00
parent b59904fdae
commit 3417ed6698
14 changed files with 10102 additions and 0 deletions
@@ -10,6 +10,7 @@
{ href: `/properties/${data.property.id}/rooms`, label: 'Rooms' },
{ href: `/properties/${data.property.id}/assets`, label: 'Assets' },
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
{ href: `/properties/${data.property.id}/expenses`, label: 'Expenses' },
{ href: `/properties/${data.property.id}/documents`, label: 'Documents' }
]);
</script>
@@ -0,0 +1,158 @@
import { fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { requireCompany } from '$lib/server/auth/guards';
import { db } from '$lib/server/db/client';
import { propertyAccounts } from '$lib/server/db/schema/accounts';
import { companies } from '$lib/server/db/schema/tenancy';
import {
createExpense,
deleteExpense,
listExpensesForProperty,
monthlySeriesForProperty,
summaryForProperty,
updateExpense,
type ExpenseKind
} from '$lib/server/services/expenses';
import type { Actions, PageServerLoad } from './$types';
const KINDS = [
'water',
'electricity',
'gas',
'internet',
'phone',
'cable',
'waste',
'maintenance',
'repair',
'cleaning',
'insurance',
'tax',
'rent',
'other'
] as const;
const Schema = z.object({
kind: z.enum(KINDS),
amount: z.coerce.number().positive('Amount must be positive'),
currency: z.string().trim().length(3),
incurred_at: z.string().min(1, 'Date required'),
period_start: z.string().optional().or(z.literal('')),
period_end: z.string().optional().or(z.literal('')),
vendor: z.string().trim().max(128).optional().or(z.literal('')),
reference: z.string().trim().max(128).optional().or(z.literal('')),
notes: z.string().trim().max(2000).optional().or(z.literal('')),
account_id: z.string().uuid().optional().or(z.literal(''))
});
interface CompanySettings {
default_currency?: string;
}
function parseSettings(raw: string | null | undefined): CompanySettings {
if (!raw) return {};
try {
return JSON.parse(raw) as CompanySettings;
} catch {
return {};
}
}
export const load: PageServerLoad = async ({ locals, params }) => {
const { company } = requireCompany(locals);
const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([
listExpensesForProperty(company.id, params.id),
db
.select({
id: propertyAccounts.id,
kind: propertyAccounts.kind,
provider: propertyAccounts.provider,
label: propertyAccounts.label,
meterNumber: propertyAccounts.meterNumber
})
.from(propertyAccounts)
.where(eq(propertyAccounts.propertyId, params.id)),
monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], 12),
summaryForProperty(company.id, params.id, 365),
db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1)
]);
const defaultCurrency = parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD';
return {
expenses,
accounts,
chartSeries: series,
summary,
defaultCurrency
};
};
export const actions: Actions = {
create: async ({ request, locals, params }) => {
const { company, user } = requireCompany(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const v = parsed.data;
try {
await createExpense({
companyId: company.id,
propertyId: params.id,
createdBy: user.id,
kind: v.kind as ExpenseKind,
amount: v.amount,
currency: v.currency,
incurredAt: new Date(v.incurred_at),
periodStart: v.period_start ? new Date(v.period_start) : null,
periodEnd: v.period_end ? new Date(v.period_end) : null,
vendor: v.vendor || null,
reference: v.reference || null,
notes: v.notes || null,
accountId: v.account_id || null
});
} catch (e) {
return fail(400, { error: (e as Error).message, values: raw });
}
return { ok: true };
},
update: async ({ request, locals }) => {
const { company } = requireCompany(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await updateExpense(company.id, id, {
kind: v.kind as ExpenseKind,
amount: v.amount,
currency: v.currency,
incurredAt: new Date(v.incurred_at),
periodStart: v.period_start ? new Date(v.period_start) : null,
periodEnd: v.period_end ? new Date(v.period_end) : null,
vendor: v.vendor || null,
reference: v.reference || null,
notes: v.notes || null,
accountId: v.account_id || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ request, locals }) => {
const { company } = requireCompany(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
await deleteExpense(company.id, id);
return { ok: true };
}
};
// silence unused import for Drizzle helpers we might lean on later
void and;
@@ -0,0 +1,267 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ExpenseChart from '$lib/components/ExpenseChart.svelte';
import { EXPENSE_KINDS, EXPENSE_KIND_LABEL, type ExpenseKind } from '$lib/expenses';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
let editingId = $state<string | null>(null);
const chartCurrency = $derived(data.summary.currency ?? data.defaultCurrency);
function fmtMoney(amount: number | string, currency: string | null): string {
const n = typeof amount === 'string' ? Number(amount) : amount;
if (!Number.isFinite(n)) return '—';
return `${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency ?? ''}`.trim();
}
function dateInput(d: Date | string | null | undefined): string {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0, 10);
}
// Only show accounts whose kind matches what's expensable as a utility.
function accountsForKind(kind: string) {
const matches = new Set(['water', 'electricity', 'gas', 'internet', 'phone', 'cable', 'waste']);
if (!matches.has(kind)) return [];
return data.accounts.filter((a) => a.kind === kind);
}
const totalByKind = $derived(
(Object.entries(data.summary.byKind) as [ExpenseKind, number][]).sort(
(a, b) => b[1] - a[1]
)
);
</script>
<div class="space-y-6">
<!-- Chart: electricity + water -->
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex items-baseline justify-between gap-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Electricity & water · last 12 months
</h2>
<span class="text-xs text-gray-400">
{chartCurrency}
</span>
</div>
<ExpenseChart
series={data.chartSeries}
kinds={['electricity', 'water']}
currency={chartCurrency}
/>
</section>
<!-- Summary cards -->
<section class="flex items-end justify-between gap-4">
<div>
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last 12 months</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
{fmtMoney(data.summary.grandTotal, data.summary.currency ?? data.defaultCurrency)}
</div>
{#if totalByKind.length > 0}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
{#each totalByKind.slice(0, 6) as [kind, total]}
<span>{EXPENSE_KIND_LABEL[kind]}: <span class="font-medium text-gray-700 dark:text-gray-200">{fmtMoney(total, data.summary.currency ?? data.defaultCurrency)}</span></span>
{/each}
</div>
{/if}
</div>
<button type="button" onclick={() => { showForm = !showForm; editingId = null; }}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New expense'}
</button>
</section>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if showForm}
<form method="post" action="?/create"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') showForm = false;
}}
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-3 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Kind <span class="text-red-500">*</span></span>
<select name="kind" required class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each EXPENSE_KINDS as k}<option value={k}>{EXPENSE_KIND_LABEL[k]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Amount <span class="text-red-500">*</span></span>
<input name="amount" type="number" step="0.01" min="0" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Currency <span class="text-red-500">*</span></span>
<input name="currency" maxlength="3" required value={data.defaultCurrency}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Incurred on <span class="text-red-500">*</span></span>
<input name="incurred_at" type="date" required value={new Date().toISOString().slice(0, 10)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Period start</span>
<input name="period_start" type="date"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Period end</span>
<input name="period_end" type="date"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Vendor</span>
<input name="vendor" placeholder="e.g. MEA, TrueOnline"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Reference</span>
<input name="reference" placeholder="invoice #"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Linked account</span>
<select name="account_id"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— none —</option>
{#each data.accounts as a}
<option value={a.id}>{a.kind} · {a.provider ?? a.label ?? a.meterNumber ?? a.id.slice(0, 8)}</option>
{/each}
</select>
</label>
<label class="block sm:col-span-3">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add expense</button>
</div>
</form>
{/if}
<!-- List -->
{#if data.expenses.length === 0 && !showForm}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No expenses recorded for this property yet.
</div>
{:else if data.expenses.length > 0}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Date</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Kind</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Amount</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Vendor</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Reference</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Notes</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.expenses as e}
{#if editingId === e.id}
<tr>
<td colspan="7" class="px-4 py-3">
<form method="post" action="?/update"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingId = null;
}}
class="grid gap-2 sm:grid-cols-4">
<input type="hidden" name="id" value={e.id} />
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Kind</span>
<select name="kind" required class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each EXPENSE_KINDS as k}<option value={k} selected={e.kind === k}>{EXPENSE_KIND_LABEL[k]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Amount</span>
<input name="amount" type="number" step="0.01" min="0" required value={e.amount}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Currency</span>
<input name="currency" maxlength="3" required value={e.currency}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm uppercase dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Incurred</span>
<input name="incurred_at" type="date" required value={dateInput(e.incurredAt)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Period start</span>
<input name="period_start" type="date" value={dateInput(e.periodStart)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Period end</span>
<input name="period_end" type="date" value={dateInput(e.periodEnd)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Vendor</span>
<input name="vendor" value={e.vendor ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Reference</span>
<input name="reference" value={e.reference ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-mono dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Account</span>
<select name="account_id" class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— none —</option>
{#each data.accounts as a}
<option value={a.id} selected={e.accountId === a.id}>{a.kind} · {a.provider ?? a.label ?? a.id.slice(0, 8)}</option>
{/each}
</select>
</label>
<label class="block sm:col-span-4">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes" value={e.notes ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<div class="sm:col-span-4 flex justify-end gap-2">
<button type="button" onclick={() => (editingId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
</td>
</tr>
{:else}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{new Date(e.incurredAt).toLocaleDateString()}</td>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">{EXPENSE_KIND_LABEL[e.kind as ExpenseKind]}</td>
<td class="px-4 py-2 text-right text-sm font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap">{fmtMoney(e.amount, e.currency)}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{e.vendor ?? '—'}</td>
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{e.reference ?? '—'}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">{e.notes ?? '—'}</td>
<td class="px-4 py-2 text-right text-xs">
<div class="flex justify-end gap-2">
<button type="button" onclick={() => { editingId = e.id; showForm = false; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
<form method="post" action="?/delete" use:enhance class="inline">
<input type="hidden" name="id" value={e.id} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">delete</button>
</form>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>