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:
@@ -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>
|
||||
@@ -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'
|
||||
};
|
||||
@@ -77,6 +77,22 @@ export const notificationKindEnum = pgEnum('notification_kind', [
|
||||
'maintenance_event_recorded',
|
||||
'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 fk = (name: string) => uuid(name);
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,7 @@ export * from './tenancy';
|
||||
export * from './properties';
|
||||
export * from './rooms';
|
||||
export * from './accounts';
|
||||
export * from './expenses';
|
||||
export * from './assets';
|
||||
export * from './documents';
|
||||
export * from './checklists';
|
||||
|
||||
@@ -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}/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>
|
||||
Reference in New Issue
Block a user