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:
@@ -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"
|
||||
>
|
||||
← 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user