diff --git a/src/routes/(app)/companies/[companyId]/+layout.svelte b/src/routes/(app)/companies/[companyId]/+layout.svelte index 4fdc373..f4c6a2f 100644 --- a/src/routes/(app)/companies/[companyId]/+layout.svelte +++ b/src/routes/(app)/companies/[companyId]/+layout.svelte @@ -55,7 +55,8 @@ { href: `${baseUrl}/categories`, label: 'Categories', show: true }, { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) }, { href: `${baseUrl}/links`, label: 'Links', show: true }, - { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) } + { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }, + { href: `${baseUrl}/service-accounts`, label: 'Service Accounts', show: has(['admin', 'manager', 'accountant']) } ].filter((t) => t.show) ); diff --git a/src/routes/(app)/companies/[companyId]/service-accounts/+page.server.ts b/src/routes/(app)/companies/[companyId]/service-accounts/+page.server.ts new file mode 100644 index 0000000..39961e5 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/service-accounts/+page.server.ts @@ -0,0 +1,245 @@ +import { error, fail } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { db } from '$lib/server/db/index.js'; +import { companyServiceAccounts } from '$lib/server/db/schema.js'; +import { requireCompanyRoleAny } from '$lib/server/authorization.js'; +import { logCompanyEvent } from '$lib/server/audit.js'; +import { and, asc, eq, isNull } from 'drizzle-orm'; + +const SERVICE_TYPES = [ + 'electricity', + 'water', + 'gas', + 'internet', + 'phone', + 'shipping', + 'insurance', + 'tax_registration', + 'social_security', + 'customs', + 'other' +] as const; + +type ServiceType = (typeof SERVICE_TYPES)[number]; + +function trimOrNull(v: FormDataEntryValue | null): string | null { + const s = v?.toString().trim(); + return s ? s : null; +} + +function parseType(v: FormDataEntryValue | null): ServiceType | null { + const s = v?.toString(); + if (!s) return null; + return (SERVICE_TYPES as readonly string[]).includes(s) ? (s as ServiceType) : null; +} + +function validateUrl(v: string | null): string | null { + if (!v) return null; + if (!v.startsWith('http://') && !v.startsWith('https://')) return null; + return v; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + await parent(); + + const accounts = await db + .select() + .from(companyServiceAccounts) + .where( + and( + eq(companyServiceAccounts.companyId, params.companyId), + isNull(companyServiceAccounts.deletedAt) + ) + ) + .orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName)); + + return { serviceAccounts: accounts }; +}; + +export const actions: Actions = { + create: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const type = parseType(fd.get('type')); + const providerName = trimOrNull(fd.get('providerName')); + const accountNumber = trimOrNull(fd.get('accountNumber')); + const customLabel = trimOrNull(fd.get('customLabel')); + const contactPhone = trimOrNull(fd.get('contactPhone')); + const websiteUrlRaw = trimOrNull(fd.get('websiteUrl')); + const notes = trimOrNull(fd.get('notes')); + + if (!type) return fail(400, { action: 'create', error: 'Service type is required' }); + if (!providerName) return fail(400, { action: 'create', error: 'Provider name is required' }); + if (providerName.length > 200) + return fail(400, { action: 'create', error: 'Provider name too long (max 200)' }); + if (!accountNumber) + return fail(400, { action: 'create', error: 'Account number is required' }); + if (accountNumber.length > 200) + return fail(400, { action: 'create', error: 'Account number too long (max 200)' }); + if (websiteUrlRaw && !validateUrl(websiteUrlRaw)) + return fail(400, { action: 'create', error: 'Website URL must start with http:// or https://' }); + + const [inserted] = await db + .insert(companyServiceAccounts) + .values({ + companyId: params.companyId, + type, + providerName, + accountNumber, + customLabel, + contactPhone, + websiteUrl: websiteUrlRaw, + notes, + createdBy: user.id + }) + .returning({ id: companyServiceAccounts.id }); + + await logCompanyEvent( + params.companyId, + user.id, + 'service_account_created', + `Service account "${providerName}" (${type}) created`, + { accountId: inserted.id, type, providerName } + ); + + return { success: true, action: 'create' }; + }, + + update: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'update', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyServiceAccounts.id }) + .from(companyServiceAccounts) + .where( + and( + eq(companyServiceAccounts.id, id), + eq(companyServiceAccounts.companyId, params.companyId), + isNull(companyServiceAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Service account not found'); + + const type = parseType(fd.get('type')); + const providerName = trimOrNull(fd.get('providerName')); + const accountNumber = trimOrNull(fd.get('accountNumber')); + const customLabel = trimOrNull(fd.get('customLabel')); + const contactPhone = trimOrNull(fd.get('contactPhone')); + const websiteUrlRaw = trimOrNull(fd.get('websiteUrl')); + const notes = trimOrNull(fd.get('notes')); + + if (!type) return fail(400, { action: 'update', error: 'Service type is required' }); + if (!providerName) return fail(400, { action: 'update', error: 'Provider name is required' }); + if (providerName.length > 200) + return fail(400, { action: 'update', error: 'Provider name too long (max 200)' }); + if (!accountNumber) + return fail(400, { action: 'update', error: 'Account number is required' }); + if (accountNumber.length > 200) + return fail(400, { action: 'update', error: 'Account number too long (max 200)' }); + if (websiteUrlRaw && !validateUrl(websiteUrlRaw)) + return fail(400, { action: 'update', error: 'Website URL must start with http:// or https://' }); + + await db + .update(companyServiceAccounts) + .set({ + type, + providerName, + accountNumber, + customLabel, + contactPhone, + websiteUrl: websiteUrlRaw, + notes, + updatedAt: new Date() + }) + .where(eq(companyServiceAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'service_account_updated', + `Service account "${providerName}" updated`, + { accountId: id } + ); + + return { success: true, action: 'update' }; + }, + + delete: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', + 'manager', + 'accountant' + ]); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'delete', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyServiceAccounts.id, providerName: companyServiceAccounts.providerName }) + .from(companyServiceAccounts) + .where( + and( + eq(companyServiceAccounts.id, id), + eq(companyServiceAccounts.companyId, params.companyId), + isNull(companyServiceAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Service account not found'); + + await db + .update(companyServiceAccounts) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(companyServiceAccounts.id, id)); + + await logCompanyEvent( + params.companyId, + user.id, + 'service_account_deleted', + `Service account "${existing.providerName}" deleted`, + { accountId: id } + ); + + return { success: true, action: 'delete' }; + }, + + toggleActive: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const id = trimOrNull(fd.get('id')); + if (!id) return fail(400, { action: 'toggleActive', error: 'Account id is required' }); + + const [existing] = await db + .select({ id: companyServiceAccounts.id, isActive: companyServiceAccounts.isActive }) + .from(companyServiceAccounts) + .where( + and( + eq(companyServiceAccounts.id, id), + eq(companyServiceAccounts.companyId, params.companyId), + isNull(companyServiceAccounts.deletedAt) + ) + ) + .limit(1); + if (!existing) error(404, 'Service account not found'); + + await db + .update(companyServiceAccounts) + .set({ isActive: !existing.isActive, updatedAt: new Date() }) + .where(eq(companyServiceAccounts.id, id)); + + return { success: true, action: 'toggleActive' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/service-accounts/+page.svelte b/src/routes/(app)/companies/[companyId]/service-accounts/+page.svelte new file mode 100644 index 0000000..8418996 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/service-accounts/+page.svelte @@ -0,0 +1,383 @@ + + + + Service Accounts - {data.company.name} + + +{#snippet accountForm( + action: string, + values: { + type?: string; + providerName?: string; + accountNumber?: string; + customLabel?: string; + contactPhone?: string; + websiteUrl?: string; + notes?: string; + } = {}, + accountId?: string +)} +
async ({ result, update, formElement }) => { + await update({ reset: false }); + if (result.type === 'success') { + if (!accountId) { + showAddForm = false; + formElement.reset(); + } else { + editingId = null; + } + } + }} + class="mt-3 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2" + > + {#if accountId} + + {/if} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+{/snippet} + +
+
+

Service Accounts

+

+ Utility accounts, shipping carrier numbers, insurance policies, and government registrations. +

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

Add Service Account

+ +
+ {#if showAddForm} + {@render accountForm('?/create')} + {/if} +
+ + {#if data.serviceAccounts.length > 0} +
+ + {#each typesPresent as t (t)} + + {/each} +
+ {/if} + + {#if filteredAccounts.length === 0} +
+

+ No {typeFilter === 'all' ? '' : TYPE_LABELS[typeFilter]?.toLowerCase() + ' '}accounts yet. +

+
+ {:else} +
+ + + + + + + + + + + + + {#each filteredAccounts as acct (acct.id)} + + + + + + + + + {#if deletingId === acct.id} + + + + {/if} + {#if editingId === acct.id} + + + + {/if} + {/each} + +
TypeProviderAccount #ContactStatusActions
+ + {TYPE_LABELS[acct.type] ?? acct.type} + + +
{acct.providerName}
+ {#if acct.customLabel} +
{acct.customLabel}
+ {/if} +
{acct.accountNumber} + {#if acct.contactPhone} +
{acct.contactPhone}
+ {/if} + {#if acct.websiteUrl} + + Website + + {/if} +
+
async ({ update }) => await update({ reset: false })}> + + +
+
+
+ + +
+
+
async ({ update }) => { + await update({ reset: false }); + deletingId = null; + }} + class="flex items-center justify-between gap-3 text-xs" + > + +

Delete "{acct.providerName}"?

+
+ + +
+
+
+ {@render accountForm( + '?/update', + { + type: acct.type, + providerName: acct.providerName, + accountNumber: acct.accountNumber, + customLabel: acct.customLabel ?? '', + contactPhone: acct.contactPhone ?? '', + websiteUrl: acct.websiteUrl ?? '', + notes: acct.notes ?? '' + }, + acct.id + )} +
+
+ {/if} +