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:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user