Reconciliation link, account CSVs in export, drop legacy bank/card tables
Validate / validate (push) Successful in 31s
Validate / validate (push) Successful in 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* One-shot legacy migration: copy rows from companyBankAccounts and companyCards
|
||||
* into the unified companyAccounts table.
|
||||
*
|
||||
* Usage:
|
||||
* tsx src/lib/server/accounts/migrate-legacy.ts
|
||||
*
|
||||
* Safe to re-run: skips any company that already has a companyAccounts row matching
|
||||
* the same legacy identifier (bank: accountNumber; card: last4 + cardholderName).
|
||||
*
|
||||
* Note: companyCards.bankAccountId linkage is dropped — each card becomes a standalone
|
||||
* companyAccounts row. Revisit later if card→funding-bank linkage needs preserving.
|
||||
*/
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companyAccounts,
|
||||
companyBankAccounts,
|
||||
companyCards
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
async function migrateBankAccounts(): Promise<number> {
|
||||
const rows = await db.select().from(companyBankAccounts);
|
||||
let inserted = 0;
|
||||
for (const r of rows) {
|
||||
// Skip if a row with same companyId + accountNumber already exists
|
||||
const existing = await db
|
||||
.select({ id: companyAccounts.id })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, r.companyId),
|
||||
eq(companyAccounts.accountType, 'bank'),
|
||||
eq(companyAccounts.accountNumber, r.accountNumber)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue;
|
||||
|
||||
await db.insert(companyAccounts).values({
|
||||
companyId: r.companyId,
|
||||
accountType: 'bank',
|
||||
name: r.bankName + (r.accountNumber ? ` •••• ${r.accountNumber.slice(-4)}` : ''),
|
||||
currency: r.currency,
|
||||
isActive: r.isActive,
|
||||
notes: r.notes,
|
||||
bankName: r.bankName,
|
||||
accountNumber: r.accountNumber,
|
||||
branch: r.branch,
|
||||
swiftBic: r.swiftBic,
|
||||
iban: r.iban,
|
||||
accountHolderName: r.accountName,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function migrateCards(): Promise<number> {
|
||||
const rows = await db.select().from(companyCards);
|
||||
let inserted = 0;
|
||||
for (const r of rows) {
|
||||
const existing = await db
|
||||
.select({ id: companyAccounts.id })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, r.companyId),
|
||||
eq(companyAccounts.accountType, 'credit_card'),
|
||||
eq(companyAccounts.last4, r.last4)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) continue;
|
||||
|
||||
const displayName =
|
||||
r.nickname ?? `${r.cardholderName} •••• ${r.last4}`;
|
||||
|
||||
await db.insert(companyAccounts).values({
|
||||
companyId: r.companyId,
|
||||
accountType: 'credit_card',
|
||||
name: displayName,
|
||||
currency: 'THB',
|
||||
isActive: r.isActive,
|
||||
notes: r.notes,
|
||||
cardBrand: r.brand,
|
||||
last4: r.last4,
|
||||
cardholderName: r.cardholderName,
|
||||
expiryMonth: r.expiryMonth,
|
||||
expiryYear: r.expiryYear,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Migrating legacy bank accounts and cards to companyAccounts…');
|
||||
const bankCount = await migrateBankAccounts();
|
||||
console.log(` bank accounts migrated: ${bankCount}`);
|
||||
const cardCount = await migrateCards();
|
||||
console.log(` cards migrated: ${cardCount}`);
|
||||
console.log('Done. Legacy tables left in place; Phase 6 will drop them.');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -944,54 +944,6 @@ export const companyAccountTransactions = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const companyBankAccounts = pgTable(
|
||||
'company_bank_accounts',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
bankName: text('bank_name').notNull(),
|
||||
accountName: text('account_name').notNull(),
|
||||
accountNumber: text('account_number').notNull(),
|
||||
accountType: text('account_type'),
|
||||
branch: text('branch'),
|
||||
swiftBic: text('swift_bic'),
|
||||
iban: text('iban'),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
isPrimary: boolean('is_primary').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('company_bank_accounts_company_idx').on(table.companyId)]
|
||||
);
|
||||
|
||||
export const companyCards = pgTable(
|
||||
'company_cards',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
brand: cardBrandEnum('brand').notNull(),
|
||||
last4: varchar('last4', { length: 4 }).notNull(),
|
||||
cardholderName: text('cardholder_name').notNull(),
|
||||
expiryMonth: integer('expiry_month'),
|
||||
expiryYear: integer('expiry_year'),
|
||||
nickname: text('nickname'),
|
||||
bankAccountId: uuid('bank_account_id').references(() => companyBankAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('company_cards_company_idx').on(table.companyId)]
|
||||
);
|
||||
|
||||
export const companyAddresses = pgTable(
|
||||
'company_addresses',
|
||||
{
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
externalAccounts,
|
||||
externalTransactions,
|
||||
users,
|
||||
companyBankAccounts,
|
||||
companyCards,
|
||||
companyAddresses,
|
||||
companyAccounts,
|
||||
companyAccountTransactions,
|
||||
companyDocuments,
|
||||
companyDocumentVersions
|
||||
} from '../db/schema.js';
|
||||
@@ -73,8 +73,8 @@ export async function buildFinancialExport(
|
||||
``,
|
||||
`Files:`,
|
||||
` company.csv — company record`,
|
||||
` company_bank_accounts.csv — company bank accounts`,
|
||||
` company_cards.csv — company credit/debit cards (last 4 only)`,
|
||||
` company_accounts.csv — unified ledger accounts (bank, card, cash, etc.)`,
|
||||
` company_account_transactions.csv — ledger transactions in the selected year`,
|
||||
` company_addresses.csv — legal/shipping/billing/other addresses`,
|
||||
` company_documents.csv — uploaded document metadata (files not bundled)`,
|
||||
` projects.csv — all projects (active + inactive)`,
|
||||
@@ -119,70 +119,93 @@ export async function buildFinancialExport(
|
||||
)
|
||||
);
|
||||
|
||||
// ── company_bank_accounts.csv ──────────────────────
|
||||
// ── company_accounts.csv ───────────────────────────
|
||||
{
|
||||
const bankRows = await db
|
||||
const acctRows = await db
|
||||
.select()
|
||||
.from(companyBankAccounts)
|
||||
.where(eq(companyBankAccounts.companyId, companyId))
|
||||
.orderBy(asc(companyBankAccounts.bankName));
|
||||
.from(companyAccounts)
|
||||
.where(eq(companyAccounts.companyId, companyId))
|
||||
.orderBy(asc(companyAccounts.accountType), asc(companyAccounts.name));
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
'id', 'bankName', 'accountName', 'accountNumber', 'accountType', 'branch',
|
||||
'swiftBic', 'iban', 'currency', 'isPrimary', 'isActive', 'notes',
|
||||
'createdAt', 'updatedAt'
|
||||
'id', 'accountType', 'name', 'currency', 'isActive', 'isArchived',
|
||||
'bankName', 'accountNumber', 'branch', 'swiftBic', 'iban', 'accountHolderName',
|
||||
'cardBrand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
|
||||
'creditLimit', 'statementCloseDay', 'paymentDueDay',
|
||||
'externalAccountId', 'notes', 'deletedAt', 'createdAt', 'updatedAt'
|
||||
]
|
||||
];
|
||||
for (const b of bankRows) {
|
||||
for (const a of acctRows) {
|
||||
rows.push([
|
||||
b.id, b.bankName, b.accountName, b.accountNumber, b.accountType ?? '',
|
||||
b.branch ?? '', b.swiftBic ?? '', b.iban ?? '', b.currency,
|
||||
b.isPrimary, b.isActive, b.notes ?? '',
|
||||
b.createdAt.toISOString(), b.updatedAt.toISOString()
|
||||
a.id, a.accountType, a.name, a.currency, a.isActive, a.isArchived,
|
||||
a.bankName ?? '', a.accountNumber ?? '', a.branch ?? '', a.swiftBic ?? '',
|
||||
a.iban ?? '', a.accountHolderName ?? '',
|
||||
a.cardBrand ?? '', a.last4 ?? '', a.cardholderName ?? '',
|
||||
a.expiryMonth ?? '', a.expiryYear ?? '',
|
||||
a.creditLimit ?? '', a.statementCloseDay ?? '', a.paymentDueDay ?? '',
|
||||
a.externalAccountId ?? '', a.notes ?? '',
|
||||
a.deletedAt ? a.deletedAt.toISOString() : '',
|
||||
a.createdAt.toISOString(), a.updatedAt.toISOString()
|
||||
]);
|
||||
}
|
||||
zip.file('company_bank_accounts.csv', withBom(csvBuild(rows)));
|
||||
zip.file('company_accounts.csv', withBom(csvBuild(rows)));
|
||||
}
|
||||
|
||||
// ── company_cards.csv ──────────────────────────────
|
||||
// ── company_account_transactions.csv ───────────────
|
||||
{
|
||||
const cardRows = await db
|
||||
const yearStartDate = new Date(`${year}-01-01T00:00:00Z`);
|
||||
const yearEndDate = new Date(`${year}-12-31T23:59:59.999Z`);
|
||||
const txRows = await db
|
||||
.select({
|
||||
id: companyCards.id,
|
||||
brand: companyCards.brand,
|
||||
last4: companyCards.last4,
|
||||
cardholderName: companyCards.cardholderName,
|
||||
expiryMonth: companyCards.expiryMonth,
|
||||
expiryYear: companyCards.expiryYear,
|
||||
nickname: companyCards.nickname,
|
||||
bankAccountId: companyCards.bankAccountId,
|
||||
bankAccountName: companyBankAccounts.bankName,
|
||||
isActive: companyCards.isActive,
|
||||
notes: companyCards.notes,
|
||||
createdAt: companyCards.createdAt,
|
||||
updatedAt: companyCards.updatedAt
|
||||
id: companyAccountTransactions.id,
|
||||
accountId: companyAccountTransactions.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
type: companyAccountTransactions.type,
|
||||
amount: companyAccountTransactions.amount,
|
||||
currency: companyAccountTransactions.currency,
|
||||
occurredAt: companyAccountTransactions.occurredAt,
|
||||
description: companyAccountTransactions.description,
|
||||
reference: companyAccountTransactions.reference,
|
||||
counterpartyAccountId: companyAccountTransactions.counterpartyAccountId,
|
||||
sourceExpenseId: companyAccountTransactions.sourceExpenseId,
|
||||
sourceInvoiceId: companyAccountTransactions.sourceInvoiceId,
|
||||
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
|
||||
fxRate: companyAccountTransactions.fxRate,
|
||||
fxAmount: companyAccountTransactions.fxAmount,
|
||||
createdAt: companyAccountTransactions.createdAt
|
||||
})
|
||||
.from(companyCards)
|
||||
.leftJoin(companyBankAccounts, eq(companyCards.bankAccountId, companyBankAccounts.id))
|
||||
.where(eq(companyCards.companyId, companyId))
|
||||
.orderBy(asc(companyCards.brand));
|
||||
.from(companyAccountTransactions)
|
||||
.innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccountTransactions.companyId, companyId),
|
||||
sql`${companyAccountTransactions.occurredAt} >= ${yearStartDate}`,
|
||||
sql`${companyAccountTransactions.occurredAt} <= ${yearEndDate}`
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(companyAccountTransactions.occurredAt),
|
||||
asc(companyAccountTransactions.createdAt)
|
||||
);
|
||||
const rows: unknown[][] = [
|
||||
[
|
||||
'id', 'brand', 'last4', 'cardholderName', 'expiryMonth', 'expiryYear',
|
||||
'nickname', 'bankAccountId', 'bankAccountName', 'isActive', 'notes',
|
||||
'createdAt', 'updatedAt'
|
||||
'id', 'accountId', 'accountName', 'type', 'amount', 'currency',
|
||||
'occurredAt', 'description', 'reference',
|
||||
'counterpartyAccountId', 'sourceExpenseId', 'sourceInvoiceId',
|
||||
'sourceExternalTransactionId', 'fxRate', 'fxAmount', 'createdAt'
|
||||
]
|
||||
];
|
||||
for (const c of cardRows) {
|
||||
for (const t of txRows) {
|
||||
rows.push([
|
||||
c.id, c.brand, c.last4, c.cardholderName,
|
||||
c.expiryMonth ?? '', c.expiryYear ?? '',
|
||||
c.nickname ?? '', c.bankAccountId ?? '', c.bankAccountName ?? '',
|
||||
c.isActive, c.notes ?? '',
|
||||
c.createdAt.toISOString(), c.updatedAt.toISOString()
|
||||
t.id, t.accountId, t.accountName, t.type, t.amount, t.currency,
|
||||
t.occurredAt.toISOString(), t.description ?? '', t.reference ?? '',
|
||||
t.counterpartyAccountId ?? '', t.sourceExpenseId ?? '',
|
||||
t.sourceInvoiceId ?? '', t.sourceExternalTransactionId ?? '',
|
||||
t.fxRate ?? '', t.fxAmount ?? '',
|
||||
t.createdAt.toISOString()
|
||||
]);
|
||||
}
|
||||
zip.file('company_cards.csv', withBom(csvBuild(rows)));
|
||||
zip.file('company_account_transactions.csv', withBom(csvBuild(rows)));
|
||||
}
|
||||
|
||||
// ── company_addresses.csv ──────────────────────────
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { externalAccounts, externalTransactions, expenses, projects } from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull, isNotNull, desc } from 'drizzle-orm';
|
||||
import {
|
||||
externalAccounts,
|
||||
externalTransactions,
|
||||
expenses,
|
||||
projects,
|
||||
companyAccounts,
|
||||
companyAccountTransactions
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull, isNotNull, desc, inArray } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { postReconciliationTransaction } from '$lib/server/accounts/ledger.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, url }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
@@ -82,11 +90,53 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
|
||||
.orderBy(desc(expenses.createdAt))
|
||||
.limit(200);
|
||||
|
||||
const accountsList = await db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
name: companyAccounts.name,
|
||||
currency: companyAccounts.currency,
|
||||
accountType: companyAccounts.accountType
|
||||
})
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
eq(companyAccounts.isArchived, false),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(companyAccounts.name);
|
||||
|
||||
// Determine which external transactions are already posted to a ledger account
|
||||
const txIds = transactions.map((t) => t.id);
|
||||
const postedMap: Record<string, { accountId: string; accountName: string | null }> = {};
|
||||
if (txIds.length > 0) {
|
||||
const postedRows = await db
|
||||
.select({
|
||||
sourceExternalTransactionId: companyAccountTransactions.sourceExternalTransactionId,
|
||||
accountId: companyAccountTransactions.accountId,
|
||||
accountName: companyAccounts.name
|
||||
})
|
||||
.from(companyAccountTransactions)
|
||||
.leftJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
|
||||
.where(inArray(companyAccountTransactions.sourceExternalTransactionId, txIds));
|
||||
for (const row of postedRows) {
|
||||
if (row.sourceExternalTransactionId) {
|
||||
postedMap[row.sourceExternalTransactionId] = {
|
||||
accountId: row.accountId,
|
||||
accountName: row.accountName
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
transactions,
|
||||
matchedExpenseTitles,
|
||||
matchableExpenses,
|
||||
matchedFilter: matched
|
||||
matchedFilter: matched,
|
||||
accounts: accountsList,
|
||||
postedMap
|
||||
};
|
||||
};
|
||||
|
||||
@@ -144,6 +194,72 @@ export const actions: Actions = {
|
||||
.set({ matchedExpenseId: null })
|
||||
.where(eq(externalTransactions.id, txId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
postToAccount: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const formData = await request.formData();
|
||||
const txId = formData.get('txId')?.toString();
|
||||
const accountId = formData.get('accountId')?.toString();
|
||||
|
||||
if (!txId) return fail(400, { error: 'Transaction ID is required' });
|
||||
if (!accountId) return fail(400, { error: 'Account is required' });
|
||||
|
||||
const [tx] = await db
|
||||
.select({ id: externalTransactions.id })
|
||||
.from(externalTransactions)
|
||||
.where(
|
||||
and(eq(externalTransactions.id, txId), eq(externalTransactions.companyId, params.companyId))
|
||||
)
|
||||
.limit(1);
|
||||
if (!tx) return fail(404, { error: 'Transaction not found' });
|
||||
|
||||
const [acct] = await db
|
||||
.select({ id: companyAccounts.id })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, accountId),
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!acct) return fail(400, { error: 'Invalid account' });
|
||||
|
||||
await postReconciliationTransaction(txId, accountId, params.companyId, user.id);
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'account_reconciled',
|
||||
`External transaction posted to account`,
|
||||
{ externalTransactionId: txId, accountId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
unpostFromAccount: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const formData = await request.formData();
|
||||
const txId = formData.get('txId')?.toString();
|
||||
|
||||
if (!txId) return fail(400, { error: 'Transaction ID is required' });
|
||||
|
||||
await db
|
||||
.delete(companyAccountTransactions)
|
||||
.where(eq(companyAccountTransactions.sourceExternalTransactionId, txId));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'account_reconciled',
|
||||
`Reconciliation reversed for external transaction`,
|
||||
{ externalTransactionId: txId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Description</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Counterparty</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Matched Expense</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-400">Ledger Account</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -152,6 +153,44 @@
|
||||
</form>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if data.postedMap[tx.id]}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate max-w-[140px] text-xs text-blue-700 dark:text-blue-300 font-medium" title={data.postedMap[tx.id].accountName ?? ''}>
|
||||
{data.postedMap[tx.id].accountName ?? 'Account'}
|
||||
</span>
|
||||
<form method="POST" action="?/unpostFromAccount" use:enhance>
|
||||
<input type="hidden" name="txId" value={tx.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 hover:text-red-600 dark:hover:text-red-400 whitespace-nowrap"
|
||||
>
|
||||
Unpost
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else if data.accounts.length === 0}
|
||||
<span class="text-xs text-gray-400">No accounts</span>
|
||||
{:else}
|
||||
<form method="POST" action="?/postToAccount" use:enhance>
|
||||
<input type="hidden" name="txId" value={tx.id} />
|
||||
<select
|
||||
name="accountId"
|
||||
onchange={(e) => {
|
||||
if ((e.target as HTMLSelectElement).value) {
|
||||
(e.target as HTMLSelectElement).closest('form')?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
class="rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-1.5 py-1 text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Post to account…</option>
|
||||
{#each data.accounts as acct}
|
||||
<option value={acct.id}>{acct.name} ({acct.currency})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</form>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
|
||||
@@ -1,300 +1,33 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companyBankAccounts,
|
||||
companyCards,
|
||||
companyAddresses
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { companyAddresses } from '$lib/server/db/schema.js';
|
||||
import { eq, and, desc, asc } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
|
||||
const ALL_ADDRESS_TYPES = ['legal', 'shipping', 'billing', 'other'] as const;
|
||||
const ALL_CARD_BRANDS = [
|
||||
'visa',
|
||||
'mastercard',
|
||||
'amex',
|
||||
'jcb',
|
||||
'unionpay',
|
||||
'discover',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
type AddressType = (typeof ALL_ADDRESS_TYPES)[number];
|
||||
type CardBrand = (typeof ALL_CARD_BRANDS)[number];
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseInt0(v: FormDataEntryValue | null): number | null {
|
||||
const s = v?.toString().trim();
|
||||
if (!s) return null;
|
||||
const n = parseInt(s, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const bankAccounts = await db
|
||||
.select()
|
||||
.from(companyBankAccounts)
|
||||
.where(eq(companyBankAccounts.companyId, params.companyId))
|
||||
.orderBy(desc(companyBankAccounts.isPrimary), asc(companyBankAccounts.bankName));
|
||||
|
||||
const cards = await db
|
||||
.select({
|
||||
id: companyCards.id,
|
||||
brand: companyCards.brand,
|
||||
last4: companyCards.last4,
|
||||
cardholderName: companyCards.cardholderName,
|
||||
expiryMonth: companyCards.expiryMonth,
|
||||
expiryYear: companyCards.expiryYear,
|
||||
nickname: companyCards.nickname,
|
||||
isActive: companyCards.isActive,
|
||||
notes: companyCards.notes,
|
||||
bankAccountId: companyCards.bankAccountId,
|
||||
bankAccountLabel: sql<string | null>`(
|
||||
SELECT ${companyBankAccounts.bankName} || ' · ' || RIGHT(${companyBankAccounts.accountNumber}, 4)
|
||||
FROM ${companyBankAccounts}
|
||||
WHERE ${companyBankAccounts.id} = ${companyCards.bankAccountId}
|
||||
)`,
|
||||
createdAt: companyCards.createdAt
|
||||
})
|
||||
.from(companyCards)
|
||||
.where(eq(companyCards.companyId, params.companyId))
|
||||
.orderBy(asc(companyCards.brand));
|
||||
|
||||
const addresses = await db
|
||||
.select()
|
||||
.from(companyAddresses)
|
||||
.where(eq(companyAddresses.companyId, params.companyId))
|
||||
.orderBy(asc(companyAddresses.type), desc(companyAddresses.isDefault));
|
||||
|
||||
return { bankAccounts, cards, addresses };
|
||||
return { addresses };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addBankAccount: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
|
||||
const bankName = trimOrNull(fd.get('bankName'));
|
||||
const accountName = trimOrNull(fd.get('accountName'));
|
||||
const accountNumber = trimOrNull(fd.get('accountNumber'));
|
||||
|
||||
if (!bankName) return fail(400, { error: 'Bank name is required' });
|
||||
if (!accountName) return fail(400, { error: 'Account name is required' });
|
||||
if (!accountNumber) return fail(400, { error: 'Account number is required' });
|
||||
|
||||
const isPrimary = fd.get('isPrimary') === 'on';
|
||||
|
||||
// If marking primary, demote others first
|
||||
if (isPrimary) {
|
||||
await db
|
||||
.update(companyBankAccounts)
|
||||
.set({ isPrimary: false })
|
||||
.where(eq(companyBankAccounts.companyId, params.companyId));
|
||||
}
|
||||
|
||||
await db.insert(companyBankAccounts).values({
|
||||
companyId: params.companyId,
|
||||
bankName,
|
||||
accountName,
|
||||
accountNumber,
|
||||
accountType: trimOrNull(fd.get('accountType')),
|
||||
branch: trimOrNull(fd.get('branch')),
|
||||
swiftBic: trimOrNull(fd.get('swiftBic')),
|
||||
iban: trimOrNull(fd.get('iban')),
|
||||
currency: trimOrNull(fd.get('currency')) ?? 'THB',
|
||||
isPrimary,
|
||||
notes: trimOrNull(fd.get('notes'))
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'bank_account_added',
|
||||
`Bank account "${bankName}" added`
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateBankAccount: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
const id = fd.get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Missing ID' });
|
||||
|
||||
const bankName = trimOrNull(fd.get('bankName'));
|
||||
const accountName = trimOrNull(fd.get('accountName'));
|
||||
const accountNumber = trimOrNull(fd.get('accountNumber'));
|
||||
if (!bankName || !accountName || !accountNumber) {
|
||||
return fail(400, { error: 'Bank name, account name, and account number are required' });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(companyBankAccounts)
|
||||
.set({
|
||||
bankName,
|
||||
accountName,
|
||||
accountNumber,
|
||||
accountType: trimOrNull(fd.get('accountType')),
|
||||
branch: trimOrNull(fd.get('branch')),
|
||||
swiftBic: trimOrNull(fd.get('swiftBic')),
|
||||
iban: trimOrNull(fd.get('iban')),
|
||||
currency: trimOrNull(fd.get('currency')) ?? 'THB',
|
||||
isActive: fd.get('isActive') === 'on',
|
||||
notes: trimOrNull(fd.get('notes')),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(companyBankAccounts.id, id),
|
||||
eq(companyBankAccounts.companyId, params.companyId)
|
||||
)
|
||||
);
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'bank_account_updated',
|
||||
`Bank account "${bankName}" updated`
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
setPrimaryBankAccount: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
const id = fd.get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Missing ID' });
|
||||
|
||||
await db
|
||||
.update(companyBankAccounts)
|
||||
.set({ isPrimary: false })
|
||||
.where(eq(companyBankAccounts.companyId, params.companyId));
|
||||
|
||||
await db
|
||||
.update(companyBankAccounts)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(companyBankAccounts.id, id),
|
||||
eq(companyBankAccounts.companyId, params.companyId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
removeBankAccount: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
const id = fd.get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Missing ID' });
|
||||
|
||||
const [ba] = await db
|
||||
.select({ bankName: companyBankAccounts.bankName })
|
||||
.from(companyBankAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyBankAccounts.id, id),
|
||||
eq(companyBankAccounts.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.delete(companyBankAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyBankAccounts.id, id),
|
||||
eq(companyBankAccounts.companyId, params.companyId)
|
||||
)
|
||||
);
|
||||
|
||||
if (ba) {
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'bank_account_removed',
|
||||
`Bank account "${ba.bankName}" removed`
|
||||
);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
addCard: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
|
||||
const brand = fd.get('brand')?.toString() as CardBrand | undefined;
|
||||
const last4 = fd.get('last4')?.toString().trim();
|
||||
const cardholderName = trimOrNull(fd.get('cardholderName'));
|
||||
|
||||
if (!brand || !ALL_CARD_BRANDS.includes(brand)) {
|
||||
return fail(400, { error: 'Card brand is required' });
|
||||
}
|
||||
if (!last4 || !/^\d{4}$/.test(last4)) {
|
||||
return fail(400, { error: 'Last 4 digits must be exactly 4 numbers' });
|
||||
}
|
||||
if (!cardholderName) return fail(400, { error: 'Cardholder name is required' });
|
||||
|
||||
const bankAccountId = trimOrNull(fd.get('bankAccountId'));
|
||||
|
||||
await db.insert(companyCards).values({
|
||||
companyId: params.companyId,
|
||||
brand,
|
||||
last4,
|
||||
cardholderName,
|
||||
expiryMonth: parseInt0(fd.get('expiryMonth')),
|
||||
expiryYear: parseInt0(fd.get('expiryYear')),
|
||||
nickname: trimOrNull(fd.get('nickname')),
|
||||
bankAccountId,
|
||||
notes: trimOrNull(fd.get('notes'))
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'card_added',
|
||||
`Card ${brand.toUpperCase()} •••• ${last4} added`
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
removeCard: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
const id = fd.get('id')?.toString();
|
||||
if (!id) return fail(400, { error: 'Missing ID' });
|
||||
|
||||
const [c] = await db
|
||||
.select({ brand: companyCards.brand, last4: companyCards.last4 })
|
||||
.from(companyCards)
|
||||
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
await db
|
||||
.delete(companyCards)
|
||||
.where(and(eq(companyCards.id, id), eq(companyCards.companyId, params.companyId)));
|
||||
|
||||
if (c) {
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'card_removed',
|
||||
`Card ${c.brand.toUpperCase()} •••• ${c.last4} removed`
|
||||
);
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
addAddress: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
|
||||
const fd = await request.formData();
|
||||
|
||||
@@ -6,22 +6,9 @@
|
||||
|
||||
const isAdmin = $derived(data.companyRoles.includes('admin'));
|
||||
|
||||
let showAddBank = $state(false);
|
||||
let editBankId = $state<string | null>(null);
|
||||
let showAddCard = $state(false);
|
||||
let showAddAddress = $state(false);
|
||||
let editAddressId = $state<string | null>(null);
|
||||
|
||||
const BRAND_LABELS: Record<string, string> = {
|
||||
visa: 'Visa',
|
||||
mastercard: 'Mastercard',
|
||||
amex: 'American Express',
|
||||
jcb: 'JCB',
|
||||
unionpay: 'UnionPay',
|
||||
discover: 'Discover',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
const ADDRESS_TYPE_LABELS: Record<string, string> = {
|
||||
legal: 'Legal',
|
||||
shipping: 'Shipping',
|
||||
@@ -36,18 +23,7 @@
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
function maskAccount(n: string): string {
|
||||
if (!n) return '';
|
||||
if (n.length <= 4) return n;
|
||||
return '••••' + n.slice(-4);
|
||||
}
|
||||
|
||||
function formatExpiry(m: number | null, y: number | null): string {
|
||||
if (!m || !y) return '—';
|
||||
return `${String(m).padStart(2, '0')}/${String(y).slice(-2)}`;
|
||||
}
|
||||
|
||||
function fullAddress(a: typeof data.addresses[number]): string {
|
||||
function fullAddress(a: (typeof data.addresses)[number]): string {
|
||||
return [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
@@ -87,7 +63,10 @@
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Company Profile</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Reference data for accounting, payments, and shipping. Visible to admin, manager, and accountant. Editing is admin-only.
|
||||
Legal and shipping addresses. For bank accounts, cards, cash, and anything with a balance, use the <a
|
||||
href={`/companies/${data.company.id}/accounts`}
|
||||
class="font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400">Accounts</a
|
||||
> tab.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -97,243 +76,30 @@
|
||||
</div>
|
||||
{/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 ========== -->
|
||||
<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">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Bank Accounts</h2>
|
||||
{#if isAdmin}
|
||||
<button onclick={() => (showAddBank = !showAddBank)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddBank ? 'Cancel' : '+ Add Bank Account'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddBank && isAdmin}
|
||||
<form method="POST" action="?/addBankAccount"
|
||||
use:enhance={() => async ({ update }) => { await update(); showAddBank = false; }}
|
||||
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
|
||||
<div><label for="bankName" class={labelCls}>Bank Name *</label><input id="bankName" name="bankName" required class={inputCls} /></div>
|
||||
<div><label for="accountName" class={labelCls}>Account Name *</label><input id="accountName" name="accountName" required class={inputCls} /></div>
|
||||
<div><label for="accountNumber" class={labelCls}>Account Number *</label><input id="accountNumber" name="accountNumber" required class={inputCls} /></div>
|
||||
<div><label for="accountType" class={labelCls}>Type</label><input id="accountType" name="accountType" placeholder="savings / current" class={inputCls} /></div>
|
||||
<div><label for="branch" class={labelCls}>Branch</label><input id="branch" name="branch" class={inputCls} /></div>
|
||||
<div><label for="currency" class={labelCls}>Currency</label><input id="currency" name="currency" value="THB" maxlength="3" class={inputCls} /></div>
|
||||
<div><label for="swiftBic" class={labelCls}>SWIFT/BIC</label><input id="swiftBic" name="swiftBic" class={inputCls} /></div>
|
||||
<div><label for="iban" class={labelCls}>IBAN</label><input id="iban" name="iban" class={inputCls} /></div>
|
||||
<div class="sm:col-span-2"><label for="ba-notes" class={labelCls}>Notes</label><textarea id="ba-notes" name="notes" rows="2" class={inputCls}></textarea></div>
|
||||
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="isPrimary" class="rounded" /> Mark as primary account
|
||||
</label>
|
||||
<div class="sm:col-span-2 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.bankAccounts.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No bank accounts on file.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr class="text-left text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-2 font-medium">Bank</th>
|
||||
<th class="px-3 py-2 font-medium">Account Name</th>
|
||||
<th class="px-3 py-2 font-medium">Account Number</th>
|
||||
<th class="px-3 py-2 font-medium">Type</th>
|
||||
<th class="px-3 py-2 font-medium">Currency</th>
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
{#if isAdmin}<th class="px-3 py-2 font-medium">Actions</th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.bankAccounts as ba}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">
|
||||
{ba.bankName}
|
||||
{#if ba.branch}<span class="ml-1 text-xs text-gray-400">({ba.branch})</span>{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.accountName}</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">{maskAccount(ba.accountNumber)}</td>
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{ba.accountType ?? '—'}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{ba.currency}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if ba.isPrimary}<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Primary</span>{/if}
|
||||
{#if !ba.isActive}<span class="ml-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-400">Inactive</span>{/if}
|
||||
</td>
|
||||
{#if isAdmin}
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex gap-2 text-xs">
|
||||
<button type="button" onclick={() => (editBankId = editBankId === ba.id ? null : ba.id)}
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{editBankId === ba.id ? 'Close' : 'Edit'}</button>
|
||||
{#if !ba.isPrimary}
|
||||
<form method="POST" action="?/setPrimaryBankAccount" use:enhance={enhanceNoReset} class="inline">
|
||||
<input type="hidden" name="id" value={ba.id} />
|
||||
<button type="submit" class="text-gray-600 hover:text-gray-800 dark:text-gray-300">Set Primary</button>
|
||||
</form>
|
||||
{/if}
|
||||
<form method="POST" action="?/removeBankAccount"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Remove this bank account?')) { cancel(); return; }
|
||||
return async ({ update }) => await update({ reset: false });
|
||||
}} class="inline">
|
||||
<input type="hidden" name="id" value={ba.id} />
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{#if editBankId === ba.id && isAdmin}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<td colspan="7" class="px-3 py-3">
|
||||
<form method="POST" action="?/updateBankAccount"
|
||||
use:enhance={() => async ({ update }) => { await update({ reset: false }); editBankId = null; }}
|
||||
class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input type="hidden" name="id" value={ba.id} />
|
||||
<div><span class={labelCls}>Bank Name *</span><input name="bankName" required value={ba.bankName} class={inputCls} /></div>
|
||||
<div><span class={labelCls}>Account Name *</span><input name="accountName" required value={ba.accountName} class={inputCls} /></div>
|
||||
<div><span class={labelCls}>Account Number *</span><input name="accountNumber" required value={ba.accountNumber} class={inputCls + ' font-mono'} /></div>
|
||||
<div><span class={labelCls}>Type</span><input name="accountType" value={ba.accountType ?? ''} class={inputCls} /></div>
|
||||
<div><span class={labelCls}>Branch</span><input name="branch" value={ba.branch ?? ''} class={inputCls} /></div>
|
||||
<div><span class={labelCls}>Currency</span><input name="currency" value={ba.currency} maxlength="3" class={inputCls} /></div>
|
||||
<div><span class={labelCls}>SWIFT/BIC</span><input name="swiftBic" value={ba.swiftBic ?? ''} class={inputCls} /></div>
|
||||
<div><span class={labelCls}>IBAN</span><input name="iban" value={ba.iban ?? ''} class={inputCls} /></div>
|
||||
<div class="sm:col-span-2"><span class={labelCls}>Notes</span><textarea name="notes" rows="2" class={inputCls}>{ba.notes ?? ''}</textarea></div>
|
||||
<label class="sm:col-span-2 flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="isActive" checked={ba.isActive} class="rounded" /> Active
|
||||
</label>
|
||||
<div class="sm:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editBankId = null)} class="rounded-md px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 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">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ========== Cards ========== -->
|
||||
<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">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Credit / Debit Cards</h2>
|
||||
{#if isAdmin}
|
||||
<button onclick={() => (showAddCard = !showAddCard)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddCard ? 'Cancel' : '+ Add Card'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||||
<strong>Last 4 digits only.</strong> Never enter a full card number — this app does not store full PANs.
|
||||
</div>
|
||||
|
||||
{#if showAddCard && isAdmin}
|
||||
<form method="POST" action="?/addCard"
|
||||
use:enhance={() => async ({ update }) => { await update(); showAddCard = false; }}
|
||||
class="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
|
||||
<div>
|
||||
<label for="brand" class={labelCls}>Brand *</label>
|
||||
<select id="brand" name="brand" required class={inputCls}>
|
||||
{#each Object.entries(BRAND_LABELS) as [val, label]}<option value={val}>{label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="last4" class={labelCls}>Last 4 *</label><input id="last4" name="last4" required maxlength="4" minlength="4" pattern="[0-9]{'{'}4{'}'}" inputmode="numeric" placeholder="1234" class={inputCls + ' font-mono'} /></div>
|
||||
<div><label for="cardholderName" class={labelCls}>Cardholder *</label><input id="cardholderName" name="cardholderName" required class={inputCls} /></div>
|
||||
<div><label for="expiryMonth" class={labelCls}>Expiry Month</label><input id="expiryMonth" name="expiryMonth" type="number" min="1" max="12" placeholder="MM" class={inputCls} /></div>
|
||||
<div><label for="expiryYear" class={labelCls}>Expiry Year</label><input id="expiryYear" name="expiryYear" type="number" min="2024" max="2099" placeholder="YYYY" class={inputCls} /></div>
|
||||
<div><label for="nickname" class={labelCls}>Nickname</label><input id="nickname" name="nickname" placeholder="e.g. Ops Visa" class={inputCls} /></div>
|
||||
<div class="sm:col-span-3">
|
||||
<label for="bankAccountId" class={labelCls}>Linked Bank Account</label>
|
||||
<select id="bankAccountId" name="bankAccountId" class={inputCls}>
|
||||
<option value="">— None —</option>
|
||||
{#each data.bankAccounts as ba}<option value={ba.id}>{ba.bankName} · {maskAccount(ba.accountNumber)}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-3"><label for="card-notes" class={labelCls}>Notes</label><textarea id="card-notes" name="notes" rows="2" class={inputCls}></textarea></div>
|
||||
<div class="sm:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.cards.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No cards on file.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<tr class="text-left text-gray-500 dark:text-gray-400">
|
||||
<th class="px-3 py-2 font-medium">Brand</th>
|
||||
<th class="px-3 py-2 font-medium">Card</th>
|
||||
<th class="px-3 py-2 font-medium">Cardholder</th>
|
||||
<th class="px-3 py-2 font-medium">Expiry</th>
|
||||
<th class="px-3 py-2 font-medium">Linked Bank</th>
|
||||
<th class="px-3 py-2 font-medium">Nickname</th>
|
||||
{#if isAdmin}<th class="px-3 py-2 font-medium"></th>{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.cards as c}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="px-3 py-2 font-medium text-gray-900 dark:text-white">{BRAND_LABELS[c.brand] ?? c.brand}</td>
|
||||
<td class="px-3 py-2 font-mono text-gray-700 dark:text-gray-300">•••• {c.last4}</td>
|
||||
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">{c.cardholderName}</td>
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{formatExpiry(c.expiryMonth, c.expiryYear)}</td>
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.bankAccountLabel ?? '—'}</td>
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400">{c.nickname ?? '—'}</td>
|
||||
{#if isAdmin}
|
||||
<td class="px-3 py-2 text-right">
|
||||
<form method="POST" action="?/removeCard"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (!confirm('Remove this card?')) { cancel(); return; }
|
||||
return async ({ update }) => await update({ reset: false });
|
||||
}} class="inline">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ========== Addresses ========== -->
|
||||
<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">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Addresses</h2>
|
||||
{#if isAdmin}
|
||||
<button onclick={() => (showAddAddress = !showAddAddress)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
<button
|
||||
onclick={() => (showAddAddress = !showAddAddress)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{showAddAddress ? 'Cancel' : '+ Add Address'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddAddress && isAdmin}
|
||||
<form method="POST" action="?/addAddress"
|
||||
use:enhance={() => async ({ update }) => { await update(); showAddAddress = false; }}
|
||||
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addAddress"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update();
|
||||
showAddAddress = false;
|
||||
}}
|
||||
class="mb-4 grid grid-cols-1 sm:grid-cols-2 gap-3 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-700/50 dark:bg-blue-900/20"
|
||||
>
|
||||
<div>
|
||||
<label for="addr-type" class={labelCls}>Type *</label>
|
||||
<select id="addr-type" name="type" required class={inputCls}>
|
||||
|
||||
Reference in New Issue
Block a user