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 @@
+
+
+
+ Utility accounts, shipping carrier numbers, insurance policies, and government registrations. +
++ No {typeFilter === 'all' ? '' : TYPE_LABELS[typeFilter]?.toLowerCase() + ' '}accounts yet. +
+| Type | +Provider | +Account # | +Contact | +Status | +Actions | +
|---|---|---|---|---|---|
| + + {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}
+ |
+ + + | +
+
+
+
+
+ |
+
| + + | +|||||
| + {@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 + )} + | +|||||