Add service accounts page with CRUD, filter pills, and nav tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 13:52:33 +07:00
parent 493ffa4097
commit 1ce614186d
3 changed files with 630 additions and 1 deletions
@@ -55,7 +55,8 @@
{ href: `${baseUrl}/categories`, label: 'Categories', show: true }, { href: `${baseUrl}/categories`, label: 'Categories', show: true },
{ href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) }, { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
{ href: `${baseUrl}/links`, label: 'Links', show: true }, { 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) ].filter((t) => t.show)
); );
@@ -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' };
}
};
@@ -0,0 +1,383 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let typeFilter = $state<string>('all');
let showAddForm = $state(false);
let editingId = $state<string | null>(null);
let deletingId = $state<string | null>(null);
const TYPE_LABELS: Record<string, string> = {
electricity: 'Electricity',
water: 'Water',
gas: 'Gas',
internet: 'Internet',
phone: 'Phone',
shipping: 'Shipping',
insurance: 'Insurance',
tax_registration: 'Tax Registration',
social_security: 'Social Security',
customs: 'Customs',
other: 'Other'
};
const TYPE_BADGE: Record<string, string> = {
electricity: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
water: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
gas: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
internet: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
phone: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
shipping: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
insurance: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
tax_registration: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
social_security: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
customs: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
};
const ALL_TYPES = Object.keys(TYPE_LABELS);
const filteredAccounts = $derived(
typeFilter === 'all'
? data.serviceAccounts
: data.serviceAccounts.filter((a) => a.type === typeFilter)
);
const typesPresent = $derived([...new Set(data.serviceAccounts.map((a) => a.type))]);
function openAdd() {
showAddForm = !showAddForm;
editingId = null;
deletingId = null;
}
function startEdit(id: string) {
editingId = id;
showAddForm = false;
deletingId = null;
}
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>Service Accounts - {data.company.name}</title>
</svelte:head>
{#snippet accountForm(
action: string,
values: {
type?: string;
providerName?: string;
accountNumber?: string;
customLabel?: string;
contactPhone?: string;
websiteUrl?: string;
notes?: string;
} = {},
accountId?: string
)}
<form
method="POST"
{action}
use:enhance={() => 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}
<input type="hidden" name="id" value={accountId} />
{/if}
<div>
<label class={labelCls}>Type <span class="text-red-500">*</span></label>
<select name="type" required value={values.type ?? ''} class={inputCls}>
<option value="" disabled>Select type</option>
{#each ALL_TYPES as t (t)}
<option value={t}>{TYPE_LABELS[t]}</option>
{/each}
</select>
</div>
<div>
<label class={labelCls}>Provider Name <span class="text-red-500">*</span></label>
<input
name="providerName"
type="text"
required
value={values.providerName ?? ''}
placeholder="e.g. PEA, TRUE, UPS"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Account Number <span class="text-red-500">*</span></label>
<input
name="accountNumber"
type="text"
required
value={values.accountNumber ?? ''}
placeholder="e.g. 12-3456-7890"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Custom Label</label>
<input
name="customLabel"
type="text"
value={values.customLabel ?? ''}
placeholder="e.g. Main office"
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Contact Phone</label>
<input
name="contactPhone"
type="tel"
value={values.contactPhone ?? ''}
class={inputCls}
/>
</div>
<div>
<label class={labelCls}>Website</label>
<input
name="websiteUrl"
type="url"
value={values.websiteUrl ?? ''}
placeholder="https://..."
class={inputCls}
/>
</div>
<div class="md:col-span-2">
<label class={labelCls}>Notes</label>
<textarea name="notes" rows="2" class={inputCls}>{values.notes ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button
type="button"
onclick={() => {
if (accountId) editingId = null;
else showAddForm = false;
}}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
{accountId ? 'Save' : 'Add Account'}
</button>
</div>
</form>
{/snippet}
<div class="space-y-6">
<header>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Service Accounts</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Utility accounts, shipping carrier numbers, insurance policies, and government registrations.
</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}
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<h2 class="font-semibold text-gray-900 dark:text-white">Add Service Account</h2>
<button
type="button"
onclick={openAdd}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showAddForm ? 'Cancel' : '+ New Account'}
</button>
</div>
{#if showAddForm}
{@render accountForm('?/create')}
{/if}
</section>
{#if data.serviceAccounts.length > 0}
<div class="flex flex-wrap gap-2">
<button
type="button"
onclick={() => (typeFilter = 'all')}
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === 'all'
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
All
</button>
{#each typesPresent as t (t)}
<button
type="button"
onclick={() => (typeFilter = t)}
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === t
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
{TYPE_LABELS[t] ?? t}
</button>
{/each}
</div>
{/if}
{#if filteredAccounts.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-sm text-gray-500 dark:text-gray-400">
No {typeFilter === 'all' ? '' : TYPE_LABELS[typeFilter]?.toLowerCase() + ' '}accounts yet.
</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Type</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Provider</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account #</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Contact</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredAccounts as acct (acct.id)}
<tr class="align-top {acct.isActive ? '' : 'opacity-60'}">
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[acct.type] ?? TYPE_BADGE.other}">
{TYPE_LABELS[acct.type] ?? acct.type}
</span>
</td>
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{acct.providerName}</div>
{#if acct.customLabel}
<div class="text-xs text-gray-500 dark:text-gray-400">{acct.customLabel}</div>
{/if}
</td>
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{acct.accountNumber}</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{#if acct.contactPhone}
<div>{acct.contactPhone}</div>
{/if}
{#if acct.websiteUrl}
<a
href={acct.websiteUrl}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Website
</a>
{/if}
</td>
<td class="px-4 py-3">
<form method="POST" action="?/toggleActive" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="id" value={acct.id} />
<button
type="submit"
class="rounded-full px-2 py-0.5 text-xs font-medium {acct.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}"
>
{acct.isActive ? 'Active' : 'Inactive'}
</button>
</form>
</td>
<td class="px-4 py-3 text-right text-xs">
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
onclick={() => startEdit(acct.id)}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
<button
type="button"
onclick={() => (deletingId = deletingId === acct.id ? null : acct.id)}
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete
</button>
</div>
</td>
</tr>
{#if deletingId === acct.id}
<tr>
<td colspan="6" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
<form
method="POST"
action="?/delete"
use:enhance={() => async ({ update }) => {
await update({ reset: false });
deletingId = null;
}}
class="flex items-center justify-between gap-3 text-xs"
>
<input type="hidden" name="id" value={acct.id} />
<p class="text-red-700 dark:text-red-300">Delete "{acct.providerName}"?</p>
<div class="flex gap-2">
<button
type="button"
onclick={() => (deletingId = null)}
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
>
Delete
</button>
</div>
</form>
</td>
</tr>
{/if}
{#if editingId === acct.id}
<tr>
<td colspan="6" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
{@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
)}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>