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
+25
View File
@@ -0,0 +1,25 @@
CREATE TYPE "public"."expense_kind" AS ENUM('water', 'electricity', 'gas', 'internet', 'phone', 'cable', 'waste', 'maintenance', 'repair', 'cleaning', 'insurance', 'tax', 'rent', 'other');--> statement-breakpoint
CREATE TABLE "property_expenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"property_id" uuid NOT NULL,
"account_id" uuid,
"kind" "expense_kind" NOT NULL,
"amount" numeric(18, 4) NOT NULL,
"currency" varchar(3) NOT NULL,
"period_start" timestamp with time zone,
"period_end" timestamp with time zone,
"incurred_at" timestamp with time zone NOT NULL,
"vendor" varchar(128),
"reference" varchar(128),
"notes" text,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "property_expenses" ADD CONSTRAINT "property_expenses_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_expenses" ADD CONSTRAINT "property_expenses_account_id_property_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."property_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_expenses" ADD CONSTRAINT "property_expenses_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "expenses_by_property_time" ON "property_expenses" USING btree ("property_id","incurred_at");--> statement-breakpoint
CREATE INDEX "expenses_by_property_kind" ON "property_expenses" USING btree ("property_id","kind","incurred_at");--> statement-breakpoint
CREATE INDEX "expenses_by_account" ON "property_expenses" USING btree ("account_id");
@@ -0,0 +1,3 @@
-- Updated_at trigger for property_expenses.
CREATE TRIGGER property_expenses_set_updated_at BEFORE UPDATE ON "property_expenses"
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -99,6 +99,20 @@
"when": 1776930973516, "when": 1776930973516,
"tag": "0013_notifications", "tag": "0013_notifications",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1776932841675,
"tag": "0014_property_expenses",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1776932900000,
"tag": "0015_expenses_updated_at_trigger",
"breakpoints": true
} }
] ]
} }
+153
View File
@@ -0,0 +1,153 @@
<script lang="ts">
import type { ExpenseKind } from '$lib/expenses';
import { EXPENSE_KIND_LABEL } from '$lib/expenses';
interface Point {
month: string;
totals: Partial<Record<ExpenseKind, number>>;
}
interface Props {
series: Point[];
kinds: ExpenseKind[];
currency: string | null;
/** Height in SVG user units; width flows 100% of parent. */
height?: number;
}
let { series, kinds, currency, height = 220 }: Props = $props();
// Palette — extend if you add more series. Colors tuned for both light + dark.
const PALETTE: Record<ExpenseKind, string> = {
electricity: '#f59e0b',
water: '#06b6d4',
gas: '#ef4444',
internet: '#8b5cf6',
phone: '#10b981',
cable: '#ec4899',
waste: '#6b7280',
maintenance: '#3b82f6',
repair: '#f97316',
cleaning: '#14b8a6',
insurance: '#6366f1',
tax: '#dc2626',
rent: '#0ea5e9',
other: '#9ca3af'
};
// Chart geometry. Width is set via viewBox + preserveAspectRatio so it scales
// to container. Height is fixed.
const W = 600;
const PAD_L = 48;
const PAD_R = 16;
const PAD_T = 16;
const PAD_B = 32;
const INNER_W = W - PAD_L - PAD_R;
const maxVal = $derived(
Math.max(
0,
...series.flatMap((p) => kinds.map((k) => p.totals[k] ?? 0))
)
);
const yMax = $derived(niceCeil(maxVal));
const innerH = $derived(height - PAD_T - PAD_B);
const n = $derived(series.length);
const stepX = $derived(n > 1 ? INNER_W / (n - 1) : 0);
function niceCeil(v: number): number {
if (v <= 0) return 1;
const exp = Math.floor(Math.log10(v));
const base = Math.pow(10, exp);
const norm = v / base;
let niceNorm: number;
if (norm <= 1) niceNorm = 1;
else if (norm <= 2) niceNorm = 2;
else if (norm <= 5) niceNorm = 5;
else niceNorm = 10;
return niceNorm * base;
}
function x(i: number): number {
return PAD_L + i * stepX;
}
function y(v: number): number {
if (yMax <= 0) return PAD_T + innerH;
return PAD_T + innerH - (v / yMax) * innerH;
}
function pathFor(kind: ExpenseKind): string {
const pts: string[] = [];
for (let i = 0; i < series.length; i++) {
const v = series[i].totals[kind] ?? 0;
pts.push(`${i === 0 ? 'M' : 'L'} ${x(i).toFixed(2)} ${y(v).toFixed(2)}`);
}
return pts.join(' ');
}
function fmtTick(v: number): string {
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M';
if (v >= 1_000) return (v / 1_000).toFixed(1) + 'k';
return String(Math.round(v));
}
function monthLabel(m: string): string {
// "2026-04" → "Apr"
const d = new Date(m + '-01T00:00:00Z');
return d.toLocaleString(undefined, { month: 'short', timeZone: 'UTC' });
}
// Show at most ~6 x-axis labels so they don't overlap on narrow screens.
const xLabelStride = $derived(Math.max(1, Math.ceil(n / 6)));
const yTicks = $derived([0, 0.25, 0.5, 0.75, 1].map((f) => yMax * f));
const hasAnyData = $derived(maxVal > 0);
</script>
<div>
{#if !hasAnyData}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No {kinds.map((k) => EXPENSE_KIND_LABEL[k].toLowerCase()).join(' or ')} expenses in the last {n} months yet.
</div>
{:else}
<svg viewBox="0 0 {W} {height}" preserveAspectRatio="none" class="w-full" role="img" aria-label="Expense trend">
<!-- y-axis grid + labels -->
{#each yTicks as t}
<line x1={PAD_L} x2={W - PAD_R} y1={y(t)} y2={y(t)} class="stroke-gray-200 dark:stroke-gray-700" stroke-width="0.5" />
<text x={PAD_L - 6} y={y(t) + 3} text-anchor="end" font-size="10" class="fill-gray-500 dark:fill-gray-400">{fmtTick(t)}</text>
{/each}
<!-- x-axis labels -->
{#each series as p, i}
{#if i % xLabelStride === 0 || i === series.length - 1}
<text x={x(i)} y={height - 10} text-anchor="middle" font-size="10" class="fill-gray-500 dark:fill-gray-400">{monthLabel(p.month)}</text>
{/if}
{/each}
<!-- series lines + points -->
{#each kinds as kind}
<path d={pathFor(kind)} fill="none" stroke={PALETTE[kind]} stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
{#each series as p, i}
<circle cx={x(i)} cy={y(p.totals[kind] ?? 0)} r="2.5" fill={PALETTE[kind]}>
<title>{EXPENSE_KIND_LABEL[kind]}{monthLabel(p.month)} {p.month.slice(0, 4)}: {(p.totals[kind] ?? 0).toLocaleString()} {currency ?? ''}</title>
</circle>
{/each}
{/each}
</svg>
<div class="mt-2 flex flex-wrap gap-3 text-xs">
{#each kinds as kind}
<span class="inline-flex items-center gap-1 text-gray-700 dark:text-gray-300">
<span class="inline-block h-2 w-4 rounded" style="background:{PALETTE[kind]}"></span>
{EXPENSE_KIND_LABEL[kind]}
</span>
{/each}
{#if currency}
<span class="ml-auto text-gray-400">Currency: {currency}</span>
{/if}
</div>
{/if}
</div>
+51
View File
@@ -0,0 +1,51 @@
// Client-safe expense constants. Mirrors expense_kind in schema/_shared.ts.
export type ExpenseKind =
| 'water'
| 'electricity'
| 'gas'
| 'internet'
| 'phone'
| 'cable'
| 'waste'
| 'maintenance'
| 'repair'
| 'cleaning'
| 'insurance'
| 'tax'
| 'rent'
| 'other';
export const EXPENSE_KINDS: readonly ExpenseKind[] = [
'electricity',
'water',
'gas',
'internet',
'phone',
'cable',
'waste',
'maintenance',
'repair',
'cleaning',
'insurance',
'tax',
'rent',
'other'
] as const;
export const EXPENSE_KIND_LABEL: Record<ExpenseKind, string> = {
water: 'Water',
electricity: 'Electricity',
gas: 'Gas',
internet: 'Internet',
phone: 'Phone',
cable: 'Cable TV',
waste: 'Waste',
maintenance: 'Maintenance',
repair: 'Repair',
cleaning: 'Cleaning',
insurance: 'Insurance',
tax: 'Tax',
rent: 'Rent',
other: 'Other'
};
+16
View File
@@ -77,6 +77,22 @@ export const notificationKindEnum = pgEnum('notification_kind', [
'maintenance_event_recorded', 'maintenance_event_recorded',
'generic' 'generic'
]); ]);
export const expenseKindEnum = pgEnum('expense_kind', [
'water',
'electricity',
'gas',
'internet',
'phone',
'cable',
'waste',
'maintenance',
'repair',
'cleaning',
'insurance',
'tax',
'rent',
'other'
]);
export const pk = () => uuid('id').primaryKey().default(sql`gen_random_uuid()`); export const pk = () => uuid('id').primaryKey().default(sql`gen_random_uuid()`);
export const fk = (name: string) => uuid(name); export const fk = (name: string) => uuid(name);
+46
View File
@@ -0,0 +1,46 @@
import { pgTable, varchar, text, numeric, timestamp, index } from 'drizzle-orm/pg-core';
import { properties } from './properties';
import { propertyAccounts } from './accounts';
import { users } from './tenancy';
import { expenseKindEnum, pk, fk, createdAt, updatedAt } from './_shared';
// Recurring costs attached to a property: electricity bills, water bills,
// maintenance, repair jobs, insurance payments, etc. Optionally linked to a
// property_account so a utility bill ties to the actual meter on file.
export const propertyExpenses = pgTable(
'property_expenses',
{
id: pk(),
propertyId: fk('property_id')
.notNull()
.references(() => properties.id, { onDelete: 'cascade' }),
// Optional link to the account (meter / ISP subscription) this expense
// was charged against. Account deletion doesn't wipe expense history.
accountId: fk('account_id').references(() => propertyAccounts.id, {
onDelete: 'set null'
}),
kind: expenseKindEnum('kind').notNull(),
amount: numeric('amount', { precision: 18, scale: 4 }).notNull(),
// ISO 4217 code. Defaults to company currency if blank at input time.
currency: varchar('currency', { length: 3 }).notNull(),
// Billing period (optional — some expenses are one-off, not periodic).
periodStart: timestamp('period_start', { withTimezone: true, mode: 'date' }),
periodEnd: timestamp('period_end', { withTimezone: true, mode: 'date' }),
// When the expense was incurred/dated (required for reporting).
incurredAt: timestamp('incurred_at', { withTimezone: true }).notNull(),
vendor: varchar('vendor', { length: 128 }),
reference: varchar('reference', { length: 128 }), // invoice #, job id, etc.
notes: text('notes'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byPropertyTime: index('expenses_by_property_time').on(t.propertyId, t.incurredAt),
byPropertyKind: index('expenses_by_property_kind').on(t.propertyId, t.kind, t.incurredAt),
byAccount: index('expenses_by_account').on(t.accountId)
})
);
export type PropertyExpense = typeof propertyExpenses.$inferSelect;
export type NewPropertyExpense = typeof propertyExpenses.$inferInsert;
+1
View File
@@ -3,6 +3,7 @@ export * from './tenancy';
export * from './properties'; export * from './properties';
export * from './rooms'; export * from './rooms';
export * from './accounts'; export * from './accounts';
export * from './expenses';
export * from './assets'; export * from './assets';
export * from './documents'; export * from './documents';
export * from './checklists'; export * from './checklists';
+283
View File
@@ -0,0 +1,283 @@
import { and, asc, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { propertyAccounts } from '$lib/server/db/schema/accounts';
import {
propertyExpenses,
type NewPropertyExpense,
type PropertyExpense
} from '$lib/server/db/schema/expenses';
import type { ExpenseKind } from '$lib/expenses';
export type { ExpenseKind };
async function assertProperty(companyId: string, propertyId: string): Promise<void> {
const [p] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, propertyId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!p) throw new Error('property not found');
}
async function assertAccountInProperty(
companyId: string,
propertyId: string,
accountId: string
): Promise<void> {
const [row] = await db
.select({ id: propertyAccounts.id })
.from(propertyAccounts)
.innerJoin(properties, eq(properties.id, propertyAccounts.propertyId))
.where(
and(
eq(propertyAccounts.id, accountId),
eq(propertyAccounts.propertyId, propertyId),
eq(properties.companyId, companyId)
)
)
.limit(1);
if (!row) throw new Error('account does not belong to this property');
}
export interface ExpenseCreateInput {
companyId: string;
propertyId: string;
createdBy: string;
kind: ExpenseKind;
amount: number;
currency: string;
incurredAt: Date;
periodStart?: Date | null;
periodEnd?: Date | null;
vendor?: string | null;
reference?: string | null;
notes?: string | null;
accountId?: string | null;
}
export async function createExpense(input: ExpenseCreateInput): Promise<{ id: string }> {
await assertProperty(input.companyId, input.propertyId);
if (input.accountId) {
await assertAccountInProperty(input.companyId, input.propertyId, input.accountId);
}
if (!Number.isFinite(input.amount)) throw new Error('amount must be a number');
const cur = input.currency.trim().toUpperCase();
if (cur.length !== 3) throw new Error('currency must be a 3-letter ISO code');
const values: NewPropertyExpense = {
propertyId: input.propertyId,
accountId: input.accountId ?? null,
kind: input.kind,
amount: String(input.amount),
currency: cur,
incurredAt: input.incurredAt,
periodStart: input.periodStart ?? null,
periodEnd: input.periodEnd ?? null,
vendor: input.vendor?.trim() || null,
reference: input.reference?.trim() || null,
notes: input.notes?.trim() || null,
createdBy: input.createdBy
};
const [row] = await db
.insert(propertyExpenses)
.values(values)
.returning({ id: propertyExpenses.id });
return row;
}
export interface ExpenseUpdateInput {
kind?: ExpenseKind;
amount?: number;
currency?: string;
incurredAt?: Date;
periodStart?: Date | null;
periodEnd?: Date | null;
vendor?: string | null;
reference?: string | null;
notes?: string | null;
accountId?: string | null;
}
export async function updateExpense(
companyId: string,
expenseId: string,
patch: ExpenseUpdateInput
): Promise<void> {
const [row] = await db
.select({
id: propertyExpenses.id,
propertyId: propertyExpenses.propertyId
})
.from(propertyExpenses)
.innerJoin(properties, eq(properties.id, propertyExpenses.propertyId))
.where(and(eq(propertyExpenses.id, expenseId), eq(properties.companyId, companyId)))
.limit(1);
if (!row) throw new Error('expense not found');
if (patch.accountId !== undefined && patch.accountId !== null) {
await assertAccountInProperty(companyId, row.propertyId, patch.accountId);
}
const update: Record<string, unknown> = {};
if (patch.kind !== undefined) update.kind = patch.kind;
if (patch.amount !== undefined) {
if (!Number.isFinite(patch.amount)) throw new Error('amount must be a number');
update.amount = String(patch.amount);
}
if (patch.currency !== undefined) {
const cur = patch.currency.trim().toUpperCase();
if (cur.length !== 3) throw new Error('currency must be a 3-letter ISO code');
update.currency = cur;
}
if (patch.incurredAt !== undefined) update.incurredAt = patch.incurredAt;
if (patch.periodStart !== undefined) update.periodStart = patch.periodStart;
if (patch.periodEnd !== undefined) update.periodEnd = patch.periodEnd;
if (patch.vendor !== undefined) update.vendor = patch.vendor?.trim() || null;
if (patch.reference !== undefined) update.reference = patch.reference?.trim() || null;
if (patch.notes !== undefined) update.notes = patch.notes?.trim() || null;
if (patch.accountId !== undefined) update.accountId = patch.accountId;
if (Object.keys(update).length === 0) return;
await db.update(propertyExpenses).set(update).where(eq(propertyExpenses.id, expenseId));
}
export async function deleteExpense(companyId: string, expenseId: string): Promise<void> {
const [row] = await db
.select({ id: propertyExpenses.id })
.from(propertyExpenses)
.innerJoin(properties, eq(properties.id, propertyExpenses.propertyId))
.where(and(eq(propertyExpenses.id, expenseId), eq(properties.companyId, companyId)))
.limit(1);
if (!row) return;
await db.delete(propertyExpenses).where(eq(propertyExpenses.id, expenseId));
}
export async function listExpensesForProperty(
companyId: string,
propertyId: string,
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
await assertProperty(companyId, propertyId);
const where = [eq(propertyExpenses.propertyId, propertyId)];
if (opts.kinds && opts.kinds.length > 0) {
where.push(inArray(propertyExpenses.kind, opts.kinds));
}
return db
.select()
.from(propertyExpenses)
.where(and(...where))
.orderBy(desc(propertyExpenses.incurredAt))
.limit(opts.limit ?? 500);
}
export interface MonthlySeriesPoint {
// ISO month label e.g. 2026-04
month: string;
// kind → summed amount in that month (only kinds with data present).
totals: Partial<Record<ExpenseKind, number>>;
}
/**
* Sum expenses by (month, kind) for the last N months. Used by the chart on
* the property expenses tab. Currency is not converted — assumes the property
* is billed in a single currency; if mixed, the chart is still useful as a
* unit-less trend line.
*/
export async function monthlySeriesForProperty(
companyId: string,
propertyId: string,
kinds: ExpenseKind[],
months = 12
): Promise<MonthlySeriesPoint[]> {
await assertProperty(companyId, propertyId);
if (kinds.length === 0 || months < 1) return [];
const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1), 1));
const rows = await db
.select({
month: sql<string>`to_char(date_trunc('month', ${propertyExpenses.incurredAt}), 'YYYY-MM')`,
kind: propertyExpenses.kind,
total: sql<string>`sum(${propertyExpenses.amount})`
})
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.kind, kinds),
gte(propertyExpenses.incurredAt, start),
lt(propertyExpenses.incurredAt, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)))
)
)
.groupBy(
sql`date_trunc('month', ${propertyExpenses.incurredAt})`,
propertyExpenses.kind
);
// Build a zero-filled series across all months.
const byMonth = new Map<string, Partial<Record<ExpenseKind, number>>>();
for (let i = 0; i < months; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1 - i), 1));
const label = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
byMonth.set(label, {});
}
for (const r of rows) {
const bucket = byMonth.get(r.month);
if (!bucket) continue;
const n = Number(r.total);
if (Number.isFinite(n)) bucket[r.kind as ExpenseKind] = n;
}
return Array.from(byMonth.entries()).map(([month, totals]) => ({ month, totals }));
}
/** Totals by kind for the last N days, plus a grand total. */
export async function summaryForProperty(
companyId: string,
propertyId: string,
days = 365
): Promise<{
byKind: Partial<Record<ExpenseKind, number>>;
grandTotal: number;
currency: string | null;
}> {
await assertProperty(companyId, propertyId);
const since = new Date(Date.now() - days * 86_400_000);
const rows = await db
.select({
kind: propertyExpenses.kind,
total: sql<string>`sum(${propertyExpenses.amount})`,
currency: propertyExpenses.currency
})
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
gte(propertyExpenses.incurredAt, since)
)
)
.groupBy(propertyExpenses.kind, propertyExpenses.currency);
const byKind: Partial<Record<ExpenseKind, number>> = {};
let grand = 0;
const currencies = new Set<string>();
for (const r of rows) {
const n = Number(r.total);
if (!Number.isFinite(n)) continue;
byKind[r.kind as ExpenseKind] = (byKind[r.kind as ExpenseKind] ?? 0) + n;
grand += n;
currencies.add(r.currency);
}
const currency = currencies.size === 1 ? Array.from(currencies)[0] : null;
return { byKind, grandTotal: grand, currency };
}
// Unused import placeholder — asc kept in case we add a low-level listing helper later.
void asc;
@@ -10,6 +10,7 @@
{ href: `/properties/${data.property.id}/rooms`, label: 'Rooms' }, { href: `/properties/${data.property.id}/rooms`, label: 'Rooms' },
{ href: `/properties/${data.property.id}/assets`, label: 'Assets' }, { href: `/properties/${data.property.id}/assets`, label: 'Assets' },
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' }, { href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
{ href: `/properties/${data.property.id}/expenses`, label: 'Expenses' },
{ href: `/properties/${data.property.id}/documents`, label: 'Documents' } { href: `/properties/${data.property.id}/documents`, label: 'Documents' }
]); ]);
</script> </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>