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