Add company Profile page with bank accounts, cards, and addresses

New /companies/[id]/profile route. Single page, three sections:

Bank Accounts:
- Table view with masked account numbers (••••1234)
- Add form (always-on inline) with bank, account holder, number,
  type, branch, SWIFT/BIC, IBAN, currency, primary toggle, notes
- Inline edit row (admin only) and Set Primary / Remove actions
- Audit log: bank_account_added/updated/removed

Cards:
- Add form gates input to last 4 digits (maxlength=4 + regex)
- Amber warning: "Last 4 digits only — never enter full PAN"
- Optional link to a bank account
- Brand select (Visa/Mastercard/Amex/JCB/UnionPay/Discover/Other)
- Audit log: card_added/removed (no edit — remove + re-add)

Addresses:
- Type enum (Legal/Shipping/Billing/Other) with type-coloured badges
- Grouped by type, default flag scoped per type
- Full Thai address fields plus contact person/phone
- Inline edit per row, Set Default per type, Remove
- Audit log: address_added/updated/removed

Read access: admin/manager/accountant. Edit: admin only.
All forms use enhance with reset:false to preserve state on error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:31:24 +07:00
parent 92a07685b0
commit 7d58a1a1c6
2 changed files with 895 additions and 0 deletions
@@ -0,0 +1,456 @@
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 { 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 };
};
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();
const type = fd.get('type')?.toString() as AddressType | undefined;
if (!type || !ALL_ADDRESS_TYPES.includes(type)) {
return fail(400, { error: 'Address type is required' });
}
const isDefault = fd.get('isDefault') === 'on';
if (isDefault) {
await db
.update(companyAddresses)
.set({ isDefault: false })
.where(
and(
eq(companyAddresses.companyId, params.companyId),
eq(companyAddresses.type, type)
)
);
}
const label = trimOrNull(fd.get('label'));
await db.insert(companyAddresses).values({
companyId: params.companyId,
type,
label,
recipient: trimOrNull(fd.get('recipient')),
addressLine1: trimOrNull(fd.get('addressLine1')),
addressLine2: trimOrNull(fd.get('addressLine2')),
subdistrict: trimOrNull(fd.get('subdistrict')),
district: trimOrNull(fd.get('district')),
province: trimOrNull(fd.get('province')),
postalCode: trimOrNull(fd.get('postalCode')),
country: trimOrNull(fd.get('country')) ?? 'Thailand',
contactPerson: trimOrNull(fd.get('contactPerson')),
contactPhone: trimOrNull(fd.get('contactPhone')),
isDefault,
notes: trimOrNull(fd.get('notes'))
});
await logCompanyEvent(
params.companyId,
user.id,
'address_added',
`${type} address${label ? ` "${label}"` : ''} added`
);
return { success: true };
},
updateAddress: 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 type = fd.get('type')?.toString() as AddressType | undefined;
if (!type || !ALL_ADDRESS_TYPES.includes(type)) {
return fail(400, { error: 'Address type is required' });
}
await db
.update(companyAddresses)
.set({
type,
label: trimOrNull(fd.get('label')),
recipient: trimOrNull(fd.get('recipient')),
addressLine1: trimOrNull(fd.get('addressLine1')),
addressLine2: trimOrNull(fd.get('addressLine2')),
subdistrict: trimOrNull(fd.get('subdistrict')),
district: trimOrNull(fd.get('district')),
province: trimOrNull(fd.get('province')),
postalCode: trimOrNull(fd.get('postalCode')),
country: trimOrNull(fd.get('country')) ?? 'Thailand',
contactPerson: trimOrNull(fd.get('contactPerson')),
contactPhone: trimOrNull(fd.get('contactPhone')),
notes: trimOrNull(fd.get('notes')),
updatedAt: new Date()
})
.where(
and(eq(companyAddresses.id, id), eq(companyAddresses.companyId, params.companyId))
);
await logCompanyEvent(
params.companyId,
user.id,
'address_updated',
`${type} address updated`
);
return { success: true };
},
setDefaultAddress: 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' });
const [target] = await db
.select({ type: companyAddresses.type })
.from(companyAddresses)
.where(
and(eq(companyAddresses.id, id), eq(companyAddresses.companyId, params.companyId))
)
.limit(1);
if (!target) return fail(404, { error: 'Address not found' });
await db
.update(companyAddresses)
.set({ isDefault: false })
.where(
and(
eq(companyAddresses.companyId, params.companyId),
eq(companyAddresses.type, target.type)
)
);
await db
.update(companyAddresses)
.set({ isDefault: true, updatedAt: new Date() })
.where(
and(eq(companyAddresses.id, id), eq(companyAddresses.companyId, params.companyId))
);
return { success: true };
},
removeAddress: 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 [a] = await db
.select({ type: companyAddresses.type, label: companyAddresses.label })
.from(companyAddresses)
.where(
and(eq(companyAddresses.id, id), eq(companyAddresses.companyId, params.companyId))
)
.limit(1);
await db
.delete(companyAddresses)
.where(
and(eq(companyAddresses.id, id), eq(companyAddresses.companyId, params.companyId))
);
if (a) {
await logCompanyEvent(
params.companyId,
user.id,
'address_removed',
`${a.type} address${a.label ? ` "${a.label}"` : ''} removed`
);
}
return { success: true };
}
};
@@ -0,0 +1,439 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
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',
billing: 'Billing',
other: 'Other'
};
const ADDRESS_TYPE_BADGE: Record<string, string> = {
legal: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
shipping: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
billing: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
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 {
return [
a.addressLine1,
a.addressLine2,
[a.subdistrict, a.district].filter(Boolean).join(', '),
[a.province, a.postalCode].filter(Boolean).join(' '),
a.country
]
.filter((s): s is string => !!s && s.trim().length > 0)
.join(' • ');
}
const addressesByType = $derived.by(() => {
const groups: Record<string, typeof data.addresses> = {
legal: [],
shipping: [],
billing: [],
other: []
};
for (const a of data.addresses) groups[a.type].push(a);
return groups;
});
const enhanceNoReset = () => async ({ update }: { update: (o?: { reset?: boolean }) => Promise<void> }) => {
await update({ reset: false });
};
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>Profile - {data.company.name}</title>
</svelte:head>
<div class="space-y-8">
<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.
</p>
</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}
<!-- ========== 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">
{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">
<div>
<label for="addr-type" class={labelCls}>Type *</label>
<select id="addr-type" name="type" required class={inputCls}>
{#each Object.entries(ADDRESS_TYPE_LABELS) as [val, label]}<option value={val}>{label}</option>{/each}
</select>
</div>
<div><label for="addr-label" class={labelCls}>Label</label><input id="addr-label" name="label" placeholder="e.g. Bangkok HQ" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="addr-recipient" class={labelCls}>Recipient</label><input id="addr-recipient" name="recipient" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="addr-line1" class={labelCls}>Address Line 1</label><input id="addr-line1" name="addressLine1" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="addr-line2" class={labelCls}>Address Line 2</label><input id="addr-line2" name="addressLine2" class={inputCls} /></div>
<div><label for="addr-subdistrict" class={labelCls}>Subdistrict (Tambon)</label><input id="addr-subdistrict" name="subdistrict" class={inputCls} /></div>
<div><label for="addr-district" class={labelCls}>District (Amphoe)</label><input id="addr-district" name="district" class={inputCls} /></div>
<div><label for="addr-province" class={labelCls}>Province (Changwat)</label><input id="addr-province" name="province" class={inputCls} /></div>
<div><label for="addr-postal" class={labelCls}>Postal Code</label><input id="addr-postal" name="postalCode" maxlength="10" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="addr-country" class={labelCls}>Country</label><input id="addr-country" name="country" value="Thailand" class={inputCls} /></div>
<div><label for="addr-contactPerson" class={labelCls}>Contact Person</label><input id="addr-contactPerson" name="contactPerson" class={inputCls} /></div>
<div><label for="addr-contactPhone" class={labelCls}>Contact Phone</label><input id="addr-contactPhone" name="contactPhone" type="tel" class={inputCls} /></div>
<div class="sm:col-span-2"><label for="addr-notes" class={labelCls}>Notes</label><textarea id="addr-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="isDefault" class="rounded" /> Set as default for this type
</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}
{#each ['legal', 'shipping', 'billing', 'other'] as t}
{#if addressesByType[t].length > 0}
<div class="mb-4">
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{ADDRESS_TYPE_LABELS[t]}
</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each addressesByType[t] as a}
<div class="rounded-md border border-gray-200 p-3 text-sm dark:border-gray-700">
<div class="mb-1 flex items-start justify-between gap-2">
<div>
<span class="rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide {ADDRESS_TYPE_BADGE[a.type]}">{ADDRESS_TYPE_LABELS[a.type]}</span>
{#if a.isDefault}<span class="ml-1 rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-300">Default</span>{/if}
{#if a.label}<p class="mt-1 font-medium text-gray-900 dark:text-white">{a.label}</p>{/if}
{#if a.recipient}<p class="text-xs text-gray-500 dark:text-gray-400">Attn: {a.recipient}</p>{/if}
</div>
{#if isAdmin}
<div class="flex shrink-0 flex-col gap-1 text-xs">
<button type="button" onclick={() => (editAddressId = editAddressId === a.id ? null : a.id)}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400">{editAddressId === a.id ? 'Close' : 'Edit'}</button>
{#if !a.isDefault}
<form method="POST" action="?/setDefaultAddress" use:enhance={enhanceNoReset}>
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="text-gray-600 hover:text-gray-800 dark:text-gray-300">Set Default</button>
</form>
{/if}
<form method="POST" action="?/removeAddress"
use:enhance={({ cancel }) => {
if (!confirm('Remove this address?')) { cancel(); return; }
return async ({ update }) => await update({ reset: false });
}}>
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400">Remove</button>
</form>
</div>
{/if}
</div>
<p class="mt-2 text-gray-700 dark:text-gray-300">{fullAddress(a)}</p>
{#if a.contactPerson || a.contactPhone}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{[a.contactPerson, a.contactPhone].filter(Boolean).join(' · ')}
</p>
{/if}
{#if editAddressId === a.id && isAdmin}
<form method="POST" action="?/updateAddress"
use:enhance={() => async ({ update }) => { await update({ reset: false }); editAddressId = null; }}
class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
<input type="hidden" name="id" value={a.id} />
<div>
<span class={labelCls}>Type *</span>
<select name="type" required class={inputCls}>
{#each Object.entries(ADDRESS_TYPE_LABELS) as [val, label]}<option value={val} selected={a.type === val}>{label}</option>{/each}
</select>
</div>
<div><span class={labelCls}>Label</span><input name="label" value={a.label ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Recipient</span><input name="recipient" value={a.recipient ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Address Line 1</span><input name="addressLine1" value={a.addressLine1 ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Address Line 2</span><input name="addressLine2" value={a.addressLine2 ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Subdistrict</span><input name="subdistrict" value={a.subdistrict ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>District</span><input name="district" value={a.district ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Province</span><input name="province" value={a.province ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Postal Code</span><input name="postalCode" maxlength="10" value={a.postalCode ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Country</span><input name="country" value={a.country} class={inputCls} /></div>
<div><span class={labelCls}>Contact Person</span><input name="contactPerson" value={a.contactPerson ?? ''} class={inputCls} /></div>
<div><span class={labelCls}>Contact Phone</span><input name="contactPhone" type="tel" value={a.contactPhone ?? ''} class={inputCls} /></div>
<div class="sm:col-span-2"><span class={labelCls}>Notes</span><textarea name="notes" rows="2" class={inputCls}>{a.notes ?? ''}</textarea></div>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editAddressId = 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>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{/each}
{#if data.addresses.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No addresses on file.</p>
{/if}
</section>
</div>