Add account detail page with transaction history, filters, and CSV export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 13:58:44 +07:00
parent 3a095851e9
commit 0d4fdb6fd7
3 changed files with 997 additions and 0 deletions
@@ -0,0 +1,324 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
users
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { postTransaction } from '$lib/server/accounts/ledger.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or, sql } from 'drizzle-orm';
const ALL_TYPES = [
'opening_balance',
'expense',
'invoice_payment',
'transfer_in',
'transfer_out',
'deposit',
'adjustment',
'reconciliation'
] as const;
type TxnType = (typeof ALL_TYPES)[number];
const EDITABLE_TYPES: readonly TxnType[] = ['deposit', 'adjustment'];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseSignedAmount(v: FormDataEntryValue | null): string | null {
const s = v?.toString();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n === 0) return null;
return n.toFixed(2);
}
function parseDate(v: FormDataEntryValue | null): Date | null {
const s = v?.toString();
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
function parseTxnType(v: FormDataEntryValue | null): TxnType | null {
const s = v?.toString();
if (!s) return null;
return (ALL_TYPES as readonly string[]).includes(s) ? (s as TxnType) : null;
}
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const [account] = await db
.select()
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1') || 1);
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam && (ALL_TYPES as readonly string[]).includes(typeParam)) {
conditions.push(eq(companyAccountTransactions.type, typeParam as TxnType));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const [totalRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(and(...conditions));
const totalCount = totalRow?.count ?? 0;
const counterparty = alias(companyAccounts, 'counterparty');
const transactions = await db
.select({
id: companyAccountTransactions.id,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
occurredAt: companyAccountTransactions.occurredAt,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
counterpartyName: counterparty.name,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
createdByName: users.displayName,
createdByEmail: users.email,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt))
.limit(PAGE_SIZE)
.offset((page - 1) * PAGE_SIZE);
const [balanceRow] = await db
.select({
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount}), '0')::text`
})
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, params.accountId));
return {
account,
transactions,
balance: balanceRow?.total ?? '0',
totalCount,
page,
pageSize: PAGE_SIZE,
filters: {
from: fromParam ?? '',
to: toParam ?? '',
type: typeParam ?? '',
q: qParam ?? ''
}
};
};
export const actions: Actions = {
addManualTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const type = parseTxnType(fd.get('type'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!type || !(EDITABLE_TYPES as readonly string[]).includes(type)) {
return fail(400, {
action: 'addManualTransaction',
error: 'Type must be deposit or adjustment'
});
}
if (!amount) {
return fail(400, {
action: 'addManualTransaction',
error: 'Amount must be a non-zero number'
});
}
if (!occurredAt) {
return fail(400, { action: 'addManualTransaction', error: 'Valid date is required' });
}
const [acct] = await db
.select({
id: companyAccounts.id,
currency: companyAccounts.currency,
name: companyAccounts.name
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!acct) error(404, 'Account not found');
await postTransaction(db, {
accountId: params.accountId,
companyId: params.companyId,
type,
amount,
currency: acct.currency,
occurredAt,
description,
reference,
createdBy: user.id
});
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`${type} of ${amount} ${acct.currency} recorded on "${acct.name}"`,
{ accountId: params.accountId, type, amount }
);
return { success: true, action: 'addManualTransaction' };
},
editTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
const amount = parseSignedAmount(fd.get('amount'));
const occurredAt = parseDate(fd.get('occurredAt'));
const description = trimOrNull(fd.get('description'));
const reference = trimOrNull(fd.get('reference'));
if (!id) return fail(400, { action: 'editTransaction', error: 'Transaction id is required' });
if (!amount) {
return fail(400, { action: 'editTransaction', error: 'Amount must be a non-zero number' });
}
if (!occurredAt) {
return fail(400, { action: 'editTransaction', error: 'Valid date is required' });
}
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'editTransaction',
error: 'This transaction type cannot be edited (auto-posted from expense/invoice/transfer)'
});
}
await db
.update(companyAccountTransactions)
.set({ amount, occurredAt, description, reference, updatedAt: new Date() })
.where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction edited on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'editTransaction' };
},
deleteTransaction: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteTransaction', error: 'Transaction id is required' });
const [existing] = await db
.select({ id: companyAccountTransactions.id, type: companyAccountTransactions.type })
.from(companyAccountTransactions)
.where(
and(
eq(companyAccountTransactions.id, id),
eq(companyAccountTransactions.accountId, params.accountId)
)
)
.limit(1);
if (!existing) error(404, 'Transaction not found');
if (!(EDITABLE_TYPES as readonly string[]).includes(existing.type)) {
return fail(400, {
action: 'deleteTransaction',
error: 'This transaction type cannot be deleted (auto-posted from expense/invoice/transfer)'
});
}
await db.delete(companyAccountTransactions).where(eq(companyAccountTransactions.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_transaction_added',
`Transaction deleted on account ${params.accountId}`,
{ accountId: params.accountId, transactionId: id }
);
return { success: true, action: 'deleteTransaction' };
}
};
@@ -0,0 +1,540 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type TxnType =
| 'opening_balance'
| 'expense'
| 'invoice_payment'
| 'transfer_in'
| 'transfer_out'
| 'deposit'
| 'adjustment'
| 'reconciliation';
const TYPE_LABELS: Record<TxnType, string> = {
opening_balance: 'Opening',
expense: 'Expense',
invoice_payment: 'Invoice',
transfer_in: 'Transfer In',
transfer_out: 'Transfer Out',
deposit: 'Deposit',
adjustment: 'Adjustment',
reconciliation: 'Reconciliation'
};
const TYPE_BADGE: Record<TxnType, string> = {
opening_balance: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
expense: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
invoice_payment: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
transfer_in: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
transfer_out: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
deposit: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
adjustment: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
reconciliation: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
};
const EDITABLE_TYPES: TxnType[] = ['deposit', 'adjustment'];
let showRecord = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
function formatAmount(amount: string, currency: string): string {
const n = Number(amount);
const fmt = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${fmt.format(n)} ${currency}`;
}
function formatDate(d: Date | string): string {
const dt = typeof d === 'string' ? new Date(d) : d;
return dt.toISOString().split('T')[0];
}
function balanceClass(amount: string): string {
const n = Number(amount);
if (n > 0) return 'text-emerald-600 dark:text-emerald-400';
if (n < 0) return 'text-red-600 dark:text-red-400';
return 'text-gray-500 dark:text-gray-400';
}
function todayIso(): string {
const d = new Date();
return d.toISOString().split('T')[0];
}
function utilisation(balance: string, limit: string | null): number | null {
if (!limit) return null;
const lim = Number(limit);
const bal = Number(balance);
if (!Number.isFinite(lim) || lim <= 0) return null;
const used = Math.max(0, -bal);
return Math.min(100, Math.round((used / lim) * 100));
}
const totalPages = $derived(Math.max(1, Math.ceil(data.totalCount / data.pageSize)));
function pageHref(p: number): string {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
if (p > 1) params.set('page', String(p));
const qs = params.toString();
return qs ? `?${qs}` : '';
}
const exportHref = $derived.by(() => {
const params = new URLSearchParams();
if (data.filters.from) params.set('from', data.filters.from);
if (data.filters.to) params.set('to', data.filters.to);
if (data.filters.type) params.set('type', data.filters.type);
if (data.filters.q) params.set('q', data.filters.q);
const qs = params.toString();
return `/companies/${data.company.id}/accounts/${data.account.id}/export${qs ? '?' + qs : ''}`;
});
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
</script>
<svelte:head>
<title>{data.account.name} - Accounts - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header>
<div class="mb-2">
<a
href={`/companies/${data.company.id}/accounts`}
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Accounts
</a>
</div>
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.account.name}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.account.accountType} · {data.account.currency}
{#if data.account.isArchived} · <span class="font-medium">Archived</span>{/if}
</p>
</div>
<div class="text-right">
<p class="text-3xl font-bold {balanceClass(data.balance)}">
{formatAmount(data.balance, data.account.currency)}
</p>
<p class="text-xs text-gray-400">Current balance</p>
</div>
</div>
{#if data.account.accountType === 'credit_card' && data.account.creditLimit}
{@const pct = utilisation(data.balance, data.account.creditLimit)}
<div class="mt-4 rounded-md border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">
Credit limit: {formatAmount(data.account.creditLimit, data.account.currency)}
</span>
{#if pct !== null}
<span class="text-xs text-gray-500">{pct}% used</span>
{/if}
</div>
{#if pct !== null}
<div class="mt-2 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full {pct > 80 ? 'bg-red-500' : pct > 50 ? 'bg-amber-500' : 'bg-emerald-500'}"
style="width: {pct}%"
></div>
</div>
{/if}
{#if data.account.statementCloseDay || data.account.paymentDueDay}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{#if data.account.statementCloseDay}Statement closes day {data.account.statementCloseDay}{/if}
{#if data.account.statementCloseDay && data.account.paymentDueDay} · {/if}
{#if data.account.paymentDueDay}Payment due day {data.account.paymentDueDay}{/if}
</p>
{/if}
</div>
{/if}
</header>
{#if form?.error}
<div
class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{form.error}
</div>
{/if}
<div class="flex flex-wrap items-center justify-between gap-3">
<form method="GET" class="flex flex-wrap items-center gap-2">
<input
type="date"
name="from"
value={data.filters.from}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<span class="text-xs text-gray-400">to</span>
<input
type="date"
name="to"
value={data.filters.to}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<select
name="type"
value={data.filters.type}
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
>
<option value="">All types</option>
{#each Object.entries(TYPE_LABELS) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<input
type="search"
name="q"
value={data.filters.q}
placeholder="Search description / reference"
class="rounded-md border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<button
type="submit"
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Apply
</button>
</form>
<div class="flex gap-2">
<a
href={exportHref}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Export CSV
</a>
<button
type="button"
onclick={() => (showRecord = !showRecord)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showRecord ? 'Cancel' : '+ Record Transaction'}
</button>
</div>
</div>
{#if showRecord}
<form
method="POST"
action="?/addManualTransaction"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showRecord = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label for="rt-type" class={labelCls}>Type <span class="text-red-500">*</span></label>
<select id="rt-type" name="type" required class={inputCls}>
<option value="deposit">Deposit (credit)</option>
<option value="adjustment">Adjustment (debit or credit)</option>
</select>
</div>
<div>
<label for="rt-amount" class={labelCls}>
Amount <span class="text-red-500">*</span>
<span class="ml-1 text-xs text-gray-400">(positive = credit, negative = debit)</span>
</label>
<input
id="rt-amount"
name="amount"
type="number"
step="0.01"
required
class={inputCls}
/>
</div>
<div>
<label for="rt-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input
id="rt-date"
name="occurredAt"
type="date"
value={todayIso()}
required
class={inputCls}
/>
</div>
<div></div>
<div class="md:col-span-2">
<label for="rt-description" class={labelCls}>Description</label>
<input id="rt-description" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-2">
<label for="rt-reference" class={labelCls}>Reference</label>
<input id="rt-reference" name="reference" type="text" class={inputCls} />
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (showRecord = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Record
</button>
</div>
</form>
{/if}
{#if data.transactions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">No transactions match the current filters.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="w-full text-left text-sm">
<thead class="bg-gray-50 text-xs uppercase text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th class="px-3 py-2">Date</th>
<th class="px-3 py-2">Type</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2 text-right">Debit</th>
<th class="px-3 py-2 text-right">Credit</th>
<th class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.transactions as txn (txn.id)}
{@const amt = Number(txn.amount)}
{@const isDebit = amt < 0}
<tr class="align-top">
<td class="px-3 py-2 whitespace-nowrap text-gray-700 dark:text-gray-200">
{formatDate(txn.occurredAt)}
</td>
<td class="px-3 py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[txn.type as TxnType]}"
>
{TYPE_LABELS[txn.type as TxnType]}
</span>
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-200">
<div>{txn.description ?? '—'}</div>
{#if txn.reference}
<div class="text-xs text-gray-400">Ref: {txn.reference}</div>
{/if}
{#if txn.counterpartyName}
<div class="text-xs text-gray-400">
Counterparty: {txn.counterpartyName}
</div>
{/if}
{#if txn.fxRate && txn.fxAmount}
<div class="text-xs text-amber-600 dark:text-amber-400">
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
</div>
{/if}
{#if txn.createdByName}
<div class="text-xs text-gray-400">By {txn.createdByName}</div>
{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-red-600 dark:text-red-400">
{#if isDebit}{formatAmount(Math.abs(amt).toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2 text-right font-mono text-emerald-600 dark:text-emerald-400">
{#if !isDebit}{formatAmount(amt.toFixed(2), txn.currency)}{/if}
</td>
<td class="px-3 py-2">
{#if EDITABLE_TYPES.includes(txn.type as TxnType)}
<div class="flex gap-2 text-xs">
<button
type="button"
onclick={() => {
editingId = editingId === txn.id ? null : txn.id;
confirmDeleteId = null;
}}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onclick={() =>
(confirmDeleteId = confirmDeleteId === txn.id ? null : txn.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
{:else}
<span
class="text-xs text-gray-400"
title="Auto-posted from expense/invoice/transfer — cannot edit here"
>
Locked
</span>
{/if}
</td>
</tr>
{#if editingId === txn.id}
<tr class="bg-gray-50 dark:bg-gray-700/50">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/editTransaction"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="grid grid-cols-1 gap-2 md:grid-cols-4"
>
<input type="hidden" name="id" value={txn.id} />
<div>
<label for="et-date-{txn.id}" class={labelCls}>Date</label>
<input
id="et-date-{txn.id}"
name="occurredAt"
type="date"
required
value={formatDate(txn.occurredAt)}
class={inputCls}
/>
</div>
<div>
<label for="et-amt-{txn.id}" class={labelCls}>Amount (signed)</label>
<input
id="et-amt-{txn.id}"
name="amount"
type="number"
step="0.01"
required
value={txn.amount}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="et-desc-{txn.id}" class={labelCls}>Description</label>
<input
id="et-desc-{txn.id}"
name="description"
type="text"
value={txn.description ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4">
<label for="et-ref-{txn.id}" class={labelCls}>Reference</label>
<input
id="et-ref-{txn.id}"
name="reference"
type="text"
value={txn.reference ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if confirmDeleteId === txn.id}
<tr class="bg-red-50 dark:bg-red-900/20">
<td colspan="6" class="px-3 py-3">
<form
method="POST"
action="?/deleteTransaction"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="flex items-center justify-between gap-2"
>
<input type="hidden" name="id" value={txn.id} />
<span class="text-sm text-red-700 dark:text-red-300">
Delete this {txn.type} transaction?
</span>
<div class="flex gap-2">
<button
type="button"
onclick={() => (confirmDeleteId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 text-xs font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{#if totalPages > 1}
<div class="flex items-center justify-between text-sm">
<p class="text-gray-500 dark:text-gray-400">
Page {data.page} of {totalPages} · {data.totalCount} transactions
</p>
<div class="flex gap-2">
{#if data.page > 1}
<a
href={pageHref(data.page - 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
← Prev
</a>
{/if}
{#if data.page < totalPages}
<a
href={pageHref(data.page + 1)}
class="rounded-md border border-gray-300 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Next →
</a>
{/if}
</div>
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,133 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyAccounts, companyAccountTransactions, users } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { csvBuild } from '$lib/utils/csv.js';
import { alias } from 'drizzle-orm/pg-core';
import { and, desc, eq, gte, ilike, isNull, lte, or } from 'drizzle-orm';
function withBom(s: string): string {
return '\uFEFF' + s;
}
export const GET: RequestHandler = async ({ locals, params, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const [account] = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, params.accountId),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!account) error(404, 'Account not found');
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
const typeParam = url.searchParams.get('type');
const qParam = url.searchParams.get('q');
const conditions = [eq(companyAccountTransactions.accountId, params.accountId)];
if (fromParam) conditions.push(gte(companyAccountTransactions.occurredAt, new Date(fromParam)));
if (toParam) {
const toDate = new Date(toParam);
toDate.setHours(23, 59, 59, 999);
conditions.push(lte(companyAccountTransactions.occurredAt, toDate));
}
if (typeParam) {
conditions.push(eq(companyAccountTransactions.type, typeParam as never));
}
if (qParam && qParam.trim()) {
const pattern = `%${qParam.trim()}%`;
conditions.push(
or(
ilike(companyAccountTransactions.description, pattern),
ilike(companyAccountTransactions.reference, pattern)
)!
);
}
const counterparty = alias(companyAccounts, 'counterparty');
const rows = await db
.select({
id: companyAccountTransactions.id,
occurredAt: companyAccountTransactions.occurredAt,
type: companyAccountTransactions.type,
amount: companyAccountTransactions.amount,
currency: companyAccountTransactions.currency,
description: companyAccountTransactions.description,
reference: companyAccountTransactions.reference,
counterpartyName: counterparty.name,
fxRate: companyAccountTransactions.fxRate,
fxAmount: companyAccountTransactions.fxAmount,
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
createdByName: users.displayName,
createdAt: companyAccountTransactions.createdAt
})
.from(companyAccountTransactions)
.leftJoin(counterparty, eq(companyAccountTransactions.counterpartyAccountId, counterparty.id))
.leftJoin(users, eq(companyAccountTransactions.createdBy, users.id))
.where(and(...conditions))
.orderBy(desc(companyAccountTransactions.occurredAt), desc(companyAccountTransactions.createdAt));
const header = [
'id',
'occurredAt',
'type',
'amount',
'currency',
'description',
'reference',
'counterparty',
'fxRate',
'fxAmount',
'sourceExpenseId',
'sourceInvoiceId',
'sourceExternalTransactionId',
'createdBy',
'createdAt'
];
const csvRows: unknown[][] = [header];
for (const r of rows) {
csvRows.push([
r.id,
r.occurredAt.toISOString(),
r.type,
r.amount,
r.currency,
r.description ?? '',
r.reference ?? '',
r.counterpartyName ?? '',
r.fxRate ?? '',
r.fxAmount ?? '',
r.sourceExpenseId ?? '',
r.sourceInvoiceId ?? '',
r.sourceExternalTransactionId ?? '',
r.createdByName ?? '',
r.createdAt.toISOString()
]);
}
const safeName = account.name.replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 60) || 'account';
const filename = `${safeName}-transactions.csv`;
return new Response(withBom(csvBuild(csvRows)), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'private, no-store'
}
});
};