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