Add accounts list page with CRUD, Accounts nav tab, profile deprecation banner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:50:40 +07:00
parent 57e72e5b6c
commit aea6dbc06e
4 changed files with 1136 additions and 0 deletions
@@ -35,6 +35,7 @@
: []), : []),
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') ...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
? [ ? [
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' },
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `/companies/${data.company.id}/profile`, label: 'Profile' },
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } { href: `/companies/${data.company.id}/documents`, label: 'Documents' }
] ]
@@ -0,0 +1,506 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyAccounts,
companyAccountTransactions,
externalAccounts
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
const ACCOUNT_TYPES = [
'bank',
'credit_card',
'cash',
'mobile_money',
'petty_cash',
'loan',
'other'
] as const;
type AccountType = (typeof ACCOUNT_TYPES)[number];
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'] as const;
type CardBrand = (typeof CARD_BRANDS)[number];
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
function parseIntOrNull(v: FormDataEntryValue | null): number | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
function parseDecimalOrNull(v: FormDataEntryValue | null): string | null {
const s = trimOrNull(v);
if (s === null) return null;
const n = Number(s);
if (!Number.isFinite(n)) return null;
return n.toFixed(2);
}
function parseAccountType(v: FormDataEntryValue | null): AccountType | null {
const s = v?.toString();
if (!s) return null;
return (ACCOUNT_TYPES as readonly string[]).includes(s) ? (s as AccountType) : null;
}
function parseCardBrand(v: FormDataEntryValue | null): CardBrand | null {
const s = v?.toString();
if (!s) return null;
return (CARD_BRANDS as readonly string[]).includes(s) ? (s as CardBrand) : null;
}
type AccountFields = {
accountType: AccountType;
name: string;
currency: string;
notes: string | null;
bankName: string | null;
accountNumber: string | null;
branch: string | null;
swiftBic: string | null;
iban: string | null;
accountHolderName: string | null;
cardBrand: CardBrand | null;
last4: string | null;
cardholderName: string | null;
expiryMonth: number | null;
expiryYear: number | null;
creditLimit: string | null;
statementCloseDay: number | null;
paymentDueDay: number | null;
externalAccountId: string | null;
};
function extractAccountFields(fd: FormData):
| { ok: true; fields: AccountFields }
| { ok: false; error: string } {
const accountType = parseAccountType(fd.get('accountType'));
if (!accountType) return { ok: false, error: 'Account type is required' };
const name = trimOrNull(fd.get('name'));
if (!name) return { ok: false, error: 'Name is required' };
const currency = trimOrNull(fd.get('currency'))?.toUpperCase() ?? 'THB';
if (!/^[A-Z]{3}$/.test(currency)) return { ok: false, error: 'Currency must be a 3-letter code' };
const last4 = trimOrNull(fd.get('last4'));
if (last4 !== null && !/^\d{4}$/.test(last4)) {
return { ok: false, error: 'Last 4 must be exactly 4 digits' };
}
const expiryMonth = parseIntOrNull(fd.get('expiryMonth'));
if (expiryMonth !== null && (expiryMonth < 1 || expiryMonth > 12)) {
return { ok: false, error: 'Expiry month must be 1-12' };
}
const expiryYear = parseIntOrNull(fd.get('expiryYear'));
if (expiryYear !== null && (expiryYear < 2000 || expiryYear > 2100)) {
return { ok: false, error: 'Expiry year is out of range' };
}
const statementCloseDay = parseIntOrNull(fd.get('statementCloseDay'));
if (statementCloseDay !== null && (statementCloseDay < 1 || statementCloseDay > 31)) {
return { ok: false, error: 'Statement close day must be 1-31' };
}
const paymentDueDay = parseIntOrNull(fd.get('paymentDueDay'));
if (paymentDueDay !== null && (paymentDueDay < 1 || paymentDueDay > 31)) {
return { ok: false, error: 'Payment due day must be 1-31' };
}
return {
ok: true,
fields: {
accountType,
name,
currency,
notes: trimOrNull(fd.get('notes')),
bankName: trimOrNull(fd.get('bankName')),
accountNumber: trimOrNull(fd.get('accountNumber')),
branch: trimOrNull(fd.get('branch')),
swiftBic: trimOrNull(fd.get('swiftBic')),
iban: trimOrNull(fd.get('iban')),
accountHolderName: trimOrNull(fd.get('accountHolderName')),
cardBrand: parseCardBrand(fd.get('cardBrand')),
last4,
cardholderName: trimOrNull(fd.get('cardholderName')),
expiryMonth,
expiryYear,
creditLimit: parseDecimalOrNull(fd.get('creditLimit')),
statementCloseDay,
paymentDueDay,
externalAccountId: trimOrNull(fd.get('externalAccountId'))
}
};
}
type OrderPayload = { id: string; sortOrder: number };
function parseOrderPayload(raw: FormDataEntryValue | null): OrderPayload[] | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const out: OrderPayload[] = [];
for (const row of parsed) {
if (!row || typeof row !== 'object') return null;
const r = row as Record<string, unknown>;
if (typeof r.id !== 'string' || typeof r.sortOrder !== 'number') return null;
out.push({ id: r.id, sortOrder: r.sortOrder });
}
return out;
}
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
await parent();
const showArchived = url.searchParams.get('archived') === '1';
const accountsWithBalance = await db
.select({
id: companyAccounts.id,
accountType: companyAccounts.accountType,
name: companyAccounts.name,
currency: companyAccounts.currency,
isActive: companyAccounts.isActive,
isArchived: companyAccounts.isArchived,
notes: companyAccounts.notes,
sortOrder: companyAccounts.sortOrder,
bankName: companyAccounts.bankName,
accountNumber: companyAccounts.accountNumber,
branch: companyAccounts.branch,
swiftBic: companyAccounts.swiftBic,
iban: companyAccounts.iban,
accountHolderName: companyAccounts.accountHolderName,
cardBrand: companyAccounts.cardBrand,
last4: companyAccounts.last4,
cardholderName: companyAccounts.cardholderName,
expiryMonth: companyAccounts.expiryMonth,
expiryYear: companyAccounts.expiryYear,
creditLimit: companyAccounts.creditLimit,
statementCloseDay: companyAccounts.statementCloseDay,
paymentDueDay: companyAccounts.paymentDueDay,
externalAccountId: companyAccounts.externalAccountId,
createdAt: companyAccounts.createdAt,
balance: sql<string>`coalesce((
select sum(${companyAccountTransactions.amount})
from ${companyAccountTransactions}
where ${companyAccountTransactions.accountId} = ${companyAccounts.id}
), '0')::text`
})
.from(companyAccounts)
.where(
and(eq(companyAccounts.companyId, params.companyId), isNull(companyAccounts.deletedAt))
)
.orderBy(asc(companyAccounts.isArchived), asc(companyAccounts.sortOrder), asc(companyAccounts.name));
const visibleAccounts = showArchived
? accountsWithBalance
: accountsWithBalance.filter((a) => !a.isArchived);
const externalAccountsList = await db
.select({
id: externalAccounts.id,
displayName: externalAccounts.displayName,
provider: externalAccounts.provider
})
.from(externalAccounts)
.where(and(eq(externalAccounts.companyId, params.companyId), eq(externalAccounts.isActive, true)));
const canDelete = roles.includes('admin');
return {
accounts: visibleAccounts,
archivedCount: accountsWithBalance.filter((a) => a.isArchived).length,
showArchived,
externalAccounts: externalAccountsList,
canDelete
};
};
async function nextAccountSortOrder(companyId: string): Promise<number> {
const [row] = await db
.select({ max: sql<number>`coalesce(max(${companyAccounts.sortOrder}), -1)::int` })
.from(companyAccounts)
.where(and(eq(companyAccounts.companyId, companyId), isNull(companyAccounts.deletedAt)));
return (row?.max ?? -1) + 1;
}
export const actions: Actions = {
addAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
const fd = await request.formData();
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'addAccount', error: parsed.error });
const f = parsed.fields;
const sortOrder = await nextAccountSortOrder(params.companyId);
const [inserted] = await db
.insert(companyAccounts)
.values({
companyId: params.companyId,
sortOrder,
createdBy: user.id,
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
externalAccountId: f.externalAccountId
})
.returning({ id: companyAccounts.id });
await logCompanyEvent(
params.companyId,
user.id,
'account_created',
`Account "${parsed.fields.name}" created`,
{ accountId: inserted.id, accountType: parsed.fields.accountType }
);
return { success: true, action: 'addAccount' };
},
updateAccount: 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: 'updateAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const parsed = extractAccountFields(fd);
if (!parsed.ok) return fail(400, { action: 'updateAccount', error: parsed.error });
const f = parsed.fields;
await db
.update(companyAccounts)
.set({
accountType: f.accountType,
name: f.name,
currency: f.currency,
notes: f.notes,
bankName: f.bankName,
accountNumber: f.accountNumber,
branch: f.branch,
swiftBic: f.swiftBic,
iban: f.iban,
accountHolderName: f.accountHolderName,
cardBrand: f.cardBrand,
last4: f.last4,
cardholderName: f.cardholderName,
expiryMonth: f.expiryMonth,
expiryYear: f.expiryYear,
creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay,
externalAccountId: f.externalAccountId,
updatedAt: new Date()
})
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${parsed.fields.name}" updated`,
{ accountId: id }
);
return { success: true, action: 'updateAccount' };
},
archiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'archiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_archived',
`Account "${existing.name}" archived`,
{ accountId: id }
);
return { success: true, action: 'archiveAccount' };
},
unarchiveAccount: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'unarchiveAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
await db
.update(companyAccounts)
.set({ isArchived: false, updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_updated',
`Account "${existing.name}" unarchived`,
{ accountId: id }
);
return { success: true, action: 'unarchiveAccount' };
},
deleteAccount: async ({ request, locals, params }) => {
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin',
'manager',
'accountant'
]);
if (!roles.includes('admin')) {
return fail(403, { action: 'deleteAccount', error: 'Only admins can delete accounts' });
}
const fd = await request.formData();
const id = trimOrNull(fd.get('id'));
if (!id) return fail(400, { action: 'deleteAccount', error: 'Account id is required' });
const [existing] = await db
.select({ id: companyAccounts.id, name: companyAccounts.name })
.from(companyAccounts)
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
)
.limit(1);
if (!existing) error(404, 'Account not found');
const [txnCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(companyAccountTransactions)
.where(eq(companyAccountTransactions.accountId, id));
if ((txnCount?.count ?? 0) > 0) {
return fail(409, {
action: 'deleteAccount',
error: 'Cannot delete an account that has transactions. Archive it instead.'
});
}
await db
.update(companyAccounts)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(companyAccounts.id, id));
await logCompanyEvent(
params.companyId,
user.id,
'account_deleted',
`Account "${existing.name}" deleted`,
{ accountId: id }
);
return { success: true, action: 'deleteAccount' };
},
reorderAccounts: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const payload = parseOrderPayload(fd.get('orders'));
if (!payload) return fail(400, { action: 'reorderAccounts', error: 'Invalid order payload' });
await db.transaction(async (tx) => {
for (const { id, sortOrder } of payload) {
await tx
.update(companyAccounts)
.set({ sortOrder, updatedAt: new Date() })
.where(
and(
eq(companyAccounts.id, id),
eq(companyAccounts.companyId, params.companyId),
isNull(companyAccounts.deletedAt)
)
);
}
});
return { success: true, action: 'reorderAccounts' };
}
};
@@ -0,0 +1,620 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
type AccountType =
| 'bank'
| 'credit_card'
| 'cash'
| 'mobile_money'
| 'petty_cash'
| 'loan'
| 'other';
const ACCOUNT_TYPE_LABELS: Record<AccountType, string> = {
bank: 'Bank Account',
credit_card: 'Credit Card',
cash: 'Cash',
mobile_money: 'Mobile Money',
petty_cash: 'Petty Cash',
loan: 'Loan',
other: 'Other'
};
const ACCOUNT_TYPE_BADGE: Record<AccountType, string> = {
bank: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
credit_card: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
cash: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
mobile_money: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
petty_cash: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
loan: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const CARD_BRANDS = ['visa', 'mastercard', 'amex', 'jcb', 'unionpay', 'discover', 'other'];
const ACCOUNT_TYPES = Object.keys(ACCOUNT_TYPE_LABELS) as AccountType[];
let showAddForm = $state(false);
let editingId = $state<string | null>(null);
let confirmDeleteId = $state<string | null>(null);
let addType = $state<AccountType>('bank');
function openAdd() {
showAddForm = !showAddForm;
editingId = null;
confirmDeleteId = 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 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 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 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>Accounts - {data.company.name}</title>
</svelte:head>
{#snippet accountFields(
type: AccountType,
prefix: string,
prefill: {
name?: string;
currency?: string;
notes?: string | null;
bankName?: string | null;
accountNumber?: string | null;
branch?: string | null;
swiftBic?: string | null;
iban?: string | null;
accountHolderName?: string | null;
cardBrand?: string | null;
last4?: string | null;
cardholderName?: string | null;
expiryMonth?: number | null;
expiryYear?: number | null;
creditLimit?: string | null;
statementCloseDay?: number | null;
paymentDueDay?: number | null;
externalAccountId?: string | null;
} = {}
)}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label for="{prefix}-name" class={labelCls}>Name <span class="text-red-500">*</span></label>
<input
id="{prefix}-name"
name="name"
type="text"
required
value={prefill.name ?? ''}
placeholder={type === 'bank'
? 'e.g. KBank Main'
: type === 'credit_card'
? 'e.g. SCB Platinum •••• 4242'
: 'Account name'}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-currency" class={labelCls}>Currency <span class="text-red-500">*</span></label>
<input
id="{prefix}-currency"
name="currency"
type="text"
required
maxlength="3"
value={prefill.currency ?? 'THB'}
placeholder="THB"
class={inputCls}
/>
</div>
<div></div>
{#if type === 'bank'}
<div>
<label for="{prefix}-bankName" class={labelCls}>Bank Name</label>
<input
id="{prefix}-bankName"
name="bankName"
type="text"
value={prefill.bankName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-accountNumber" class={labelCls}>Account Number</label>
<input
id="{prefix}-accountNumber"
name="accountNumber"
type="text"
value={prefill.accountNumber ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-branch" class={labelCls}>Branch</label>
<input
id="{prefix}-branch"
name="branch"
type="text"
value={prefill.branch ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-holder" class={labelCls}>Account Holder</label>
<input
id="{prefix}-holder"
name="accountHolderName"
type="text"
value={prefill.accountHolderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-swift" class={labelCls}>SWIFT/BIC</label>
<input
id="{prefix}-swift"
name="swiftBic"
type="text"
value={prefill.swiftBic ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-iban" class={labelCls}>IBAN</label>
<input
id="{prefix}-iban"
name="iban"
type="text"
value={prefill.iban ?? ''}
class={inputCls}
/>
</div>
{#if data.externalAccounts.length > 0}
<div class="md:col-span-2">
<label for="{prefix}-extAccount" class={labelCls}>Linked Bank Integration</label>
<select
id="{prefix}-extAccount"
name="externalAccountId"
class={inputCls}
value={prefill.externalAccountId ?? ''}
>
<option value="">— none —</option>
{#each data.externalAccounts as ea (ea.id)}
<option value={ea.id}>{ea.displayName} ({ea.provider})</option>
{/each}
</select>
</div>
{/if}
{/if}
{#if type === 'credit_card'}
<div>
<label for="{prefix}-brand" class={labelCls}>Card Brand</label>
<select id="{prefix}-brand" name="cardBrand" class={inputCls} value={prefill.cardBrand ?? 'visa'}>
{#each CARD_BRANDS as b (b)}
<option value={b}>{b.toUpperCase()}</option>
{/each}
</select>
</div>
<div>
<label for="{prefix}-last4" class={labelCls}>Last 4 Digits</label>
<input
id="{prefix}-last4"
name="last4"
type="text"
maxlength="4"
pattern="[0-9]{'{'}4{'}'}"
value={prefill.last4 ?? ''}
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label for="{prefix}-cardholder" class={labelCls}>Cardholder Name</label>
<input
id="{prefix}-cardholder"
name="cardholderName"
type="text"
value={prefill.cardholderName ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expMonth" class={labelCls}>Expiry Month</label>
<input
id="{prefix}-expMonth"
name="expiryMonth"
type="number"
min="1"
max="12"
value={prefill.expiryMonth ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-expYear" class={labelCls}>Expiry Year</label>
<input
id="{prefix}-expYear"
name="expiryYear"
type="number"
min="2000"
max="2100"
value={prefill.expiryYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-limit" class={labelCls}>Credit Limit</label>
<input
id="{prefix}-limit"
name="creditLimit"
type="number"
step="0.01"
min="0"
value={prefill.creditLimit ?? ''}
class={inputCls}
/>
</div>
<div></div>
<div>
<label for="{prefix}-stmtClose" class={labelCls}>Statement Close Day</label>
<input
id="{prefix}-stmtClose"
name="statementCloseDay"
type="number"
min="1"
max="31"
value={prefill.statementCloseDay ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="{prefix}-payDue" class={labelCls}>Payment Due Day</label>
<input
id="{prefix}-payDue"
name="paymentDueDay"
type="number"
min="1"
max="31"
value={prefill.paymentDueDay ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="{prefix}-notes" class={labelCls}>Notes</label>
<textarea id="{prefix}-notes" name="notes" rows="2" class={inputCls}>{prefill.notes ?? ''}</textarea>
</div>
</div>
{/snippet}
<div class="space-y-6">
<header class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Bank accounts, cards, cash, and any other fund source. Balances update automatically from
expenses, invoice payments, transfers, and manual entries.
</p>
</div>
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</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}
{#if showAddForm}
<form
method="POST"
action="?/addAccount"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') {
showAddForm = false;
formElement.reset();
}
}}
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 font-semibold text-gray-900 dark:text-white">New Account</h2>
<div class="mb-4">
<label for="add-accountType" class={labelCls}
>Account Type <span class="text-red-500">*</span></label
>
<select
id="add-accountType"
name="accountType"
required
bind:value={addType}
class={inputCls}
>
{#each ACCOUNT_TYPES as t (t)}
<option value={t}>{ACCOUNT_TYPE_LABELS[t]}</option>
{/each}
</select>
</div>
{@render accountFields(addType, 'add')}
<div class="mt-4 flex justify-end gap-2">
<button
type="button"
onclick={() => (showAddForm = false)}
class="rounded-md border border-gray-300 px-4 py-2 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-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Account
</button>
</div>
</form>
{/if}
{#if data.accounts.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 accounts yet. Click "+ New Account" to add one.
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each data.accounts as acct (acct.id)}
<div
class="flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 {acct.isArchived
? 'opacity-60'
: ''}"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
<a
href={`/companies/${data.company.id}/accounts/${acct.id}`}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
{acct.name}
</a>
</h3>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {ACCOUNT_TYPE_BADGE[
acct.accountType
]}"
>
{ACCOUNT_TYPE_LABELS[acct.accountType]}
</span>
{#if acct.isArchived}
<span
class="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300"
>
Archived
</span>
{/if}
</div>
</div>
</div>
<p class="text-2xl font-bold {balanceClass(acct.balance)}">
{formatAmount(acct.balance, acct.currency)}
</p>
{#if acct.accountType === 'credit_card' && acct.creditLimit}
{@const pct = utilisation(acct.balance, acct.creditLimit)}
<div class="text-xs text-gray-500 dark:text-gray-400">
Limit {formatAmount(acct.creditLimit, acct.currency)}
{#if pct !== null}
· {pct}% used
<div class="mt-1 h-1.5 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}
</div>
{/if}
{#if acct.accountType === 'credit_card' && (acct.statementCloseDay || acct.paymentDueDay)}
<p class="text-xs text-gray-500 dark:text-gray-400">
{#if acct.statementCloseDay}Closes day {acct.statementCloseDay}{/if}
{#if acct.statementCloseDay && acct.paymentDueDay} · {/if}
{#if acct.paymentDueDay}Due day {acct.paymentDueDay}{/if}
</p>
{/if}
{#if acct.accountType === 'bank' && (acct.bankName || acct.accountNumber)}
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{acct.bankName ?? ''}{#if acct.accountNumber} · {acct.accountNumber}{/if}
</p>
{/if}
<div
class="mt-auto flex flex-wrap justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700"
>
<button
type="button"
onclick={() => {
editingId = editingId === acct.id ? null : acct.id;
confirmDeleteId = null;
}}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{#if acct.isArchived}
<form method="POST" action="?/unarchiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Unarchive
</button>
</form>
{:else}
<form method="POST" action="?/archiveAccount" use:enhance>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-300"
>
Archive
</button>
</form>
{/if}
{#if data.canDelete}
<button
type="button"
onclick={() => (confirmDeleteId = confirmDeleteId === acct.id ? null : acct.id)}
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
{/if}
</div>
{#if confirmDeleteId === acct.id}
<form
method="POST"
action="?/deleteAccount"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
confirmDeleteId = null;
}}
class="mt-2 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30"
>
<input type="hidden" name="id" value={acct.id} />
<p class="mb-2 text-red-700 dark:text-red-300">
Delete "{acct.name}"? This only works if the account has zero transactions.
</p>
<div class="flex justify-end 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>
{/if}
{#if editingId === acct.id}
<form
method="POST"
action="?/updateAccount"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingId = null;
}}
class="mt-2 rounded-md bg-gray-50 p-3 dark:bg-gray-700/50"
>
<input type="hidden" name="id" value={acct.id} />
<input type="hidden" name="accountType" value={acct.accountType} />
{@render accountFields(acct.accountType, 'edit-' + acct.id, {
name: acct.name,
currency: acct.currency,
notes: acct.notes,
bankName: acct.bankName,
accountNumber: acct.accountNumber,
branch: acct.branch,
swiftBic: acct.swiftBic,
iban: acct.iban,
accountHolderName: acct.accountHolderName,
cardBrand: acct.cardBrand,
last4: acct.last4,
cardholderName: acct.cardholderName,
expiryMonth: acct.expiryMonth,
expiryYear: acct.expiryYear,
creditLimit: acct.creditLimit,
statementCloseDay: acct.statementCloseDay,
paymentDueDay: acct.paymentDueDay,
externalAccountId: acct.externalAccountId
})}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editingId = null)}
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-600"
>
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"
>
Save
</button>
</div>
</form>
{/if}
</div>
{/each}
</div>
{/if}
{#if data.archivedCount > 0}
<div class="flex justify-center">
<a
href={data.showArchived
? `/companies/${data.company.id}/accounts`
: `/companies/${data.company.id}/accounts?archived=1`}
class="text-xs text-gray-500 underline hover:text-gray-700 dark:text-gray-400"
>
{data.showArchived ? 'Hide' : 'Show'} archived ({data.archivedCount})
</a>
</div>
{/if}
</div>
@@ -97,6 +97,15 @@
</div> </div>
{/if} {/if}
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-900/20 dark:text-amber-200"
>
Bank accounts and cards are now managed in the <a
href={`/companies/${data.company.id}/accounts`}
class="font-medium underline hover:text-amber-900 dark:hover:text-amber-100">Accounts</a
> tab, where they also show rolling balances and transaction history. The sections below will be removed in an upcoming update.
</div>
<!-- ========== Bank Accounts ========== --> <!-- ========== Bank Accounts ========== -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"> <section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">