diff --git a/src/routes/(app)/companies/[companyId]/profile/+page.server.ts b/src/routes/(app)/companies/[companyId]/profile/+page.server.ts new file mode 100644 index 0000000..519d047 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/profile/+page.server.ts @@ -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`( + 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 }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/profile/+page.svelte b/src/routes/(app)/companies/[companyId]/profile/+page.svelte new file mode 100644 index 0000000..131f20f --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/profile/+page.svelte @@ -0,0 +1,439 @@ + + + + Profile - {data.company.name} + + +
+
+

Company Profile

+

+ Reference data for accounting, payments, and shipping. Visible to admin, manager, and accountant. Editing is admin-only. +

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+
+

Bank Accounts

+ {#if isAdmin} + + {/if} +
+ + {#if showAddBank && isAdmin} +
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"> +
+
+
+
+
+
+
+
+
+ +
+ +
+
+ {/if} + + {#if data.bankAccounts.length === 0} +

No bank accounts on file.

+ {:else} +
+ + + + + + + + + + {#if isAdmin}{/if} + + + + {#each data.bankAccounts as ba} + + + + + + + + {#if isAdmin} + + {/if} + + {#if editBankId === ba.id && isAdmin} + + + + {/if} + {/each} + +
BankAccount NameAccount NumberTypeCurrencyStatusActions
+ {ba.bankName} + {#if ba.branch}({ba.branch}){/if} + {ba.accountName}{maskAccount(ba.accountNumber)}{ba.accountType ?? '—'}{ba.currency} + {#if ba.isPrimary}Primary{/if} + {#if !ba.isActive}Inactive{/if} + +
+ + {#if !ba.isPrimary} +
+ + +
+ {/if} +
{ + if (!confirm('Remove this bank account?')) { cancel(); return; } + return async ({ update }) => await update({ reset: false }); + }} class="inline"> + + +
+
+
+
async ({ update }) => { await update({ reset: false }); editBankId = null; }} + class="grid grid-cols-1 sm:grid-cols-2 gap-3"> + +
Bank Name *
+
Account Name *
+
Account Number *
+
Type
+
Branch
+
Currency
+
SWIFT/BIC
+
IBAN
+
Notes
+ +
+ + +
+
+
+
+ {/if} +
+ + +
+
+

Credit / Debit Cards

+ {#if isAdmin} + + {/if} +
+ +
+ Last 4 digits only. Never enter a full card number — this app does not store full PANs. +
+ + {#if showAddCard && isAdmin} +
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"> +
+ + +
+
+
+
+
+
+
+ + +
+
+
+ +
+
+ {/if} + + {#if data.cards.length === 0} +

No cards on file.

+ {:else} +
+ + + + + + + + + + {#if isAdmin}{/if} + + + + {#each data.cards as c} + + + + + + + + {#if isAdmin} + + {/if} + + {/each} + +
BrandCardCardholderExpiryLinked BankNickname
{BRAND_LABELS[c.brand] ?? c.brand}•••• {c.last4}{c.cardholderName}{formatExpiry(c.expiryMonth, c.expiryYear)}{c.bankAccountLabel ?? '—'}{c.nickname ?? '—'} +
{ + if (!confirm('Remove this card?')) { cancel(); return; } + return async ({ update }) => await update({ reset: false }); + }} class="inline"> + + +
+
+
+ {/if} +
+ + +
+
+

Addresses

+ {#if isAdmin} + + {/if} +
+ + {#if showAddAddress && isAdmin} +
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"> +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ {/if} + + {#each ['legal', 'shipping', 'billing', 'other'] as t} + {#if addressesByType[t].length > 0} +
+

+ {ADDRESS_TYPE_LABELS[t]} +

+
+ {#each addressesByType[t] as a} +
+
+
+ {ADDRESS_TYPE_LABELS[a.type]} + {#if a.isDefault}Default{/if} + {#if a.label}

{a.label}

{/if} + {#if a.recipient}

Attn: {a.recipient}

{/if} +
+ {#if isAdmin} +
+ + {#if !a.isDefault} +
+ + +
+ {/if} +
{ + if (!confirm('Remove this address?')) { cancel(); return; } + return async ({ update }) => await update({ reset: false }); + }}> + + +
+
+ {/if} +
+

{fullAddress(a)}

+ {#if a.contactPerson || a.contactPhone} +

+ {[a.contactPerson, a.contactPhone].filter(Boolean).join(' · ')} +

+ {/if} + + {#if editAddressId === a.id && isAdmin} +
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"> + +
+ Type * + +
+
Label
+
Recipient
+
Address Line 1
+
Address Line 2
+
Subdistrict
+
District
+
Province
+
Postal Code
+
Country
+
Contact Person
+
Contact Phone
+
Notes
+
+ + +
+
+ {/if} +
+ {/each} +
+
+ {/if} + {/each} + + {#if data.addresses.length === 0} +

No addresses on file.

+ {/if} +
+