Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbfd229ba8 | |||
| 1ce614186d | |||
| 493ffa4097 |
@@ -974,6 +974,10 @@ export const recurringBills = pgTable(
|
|||||||
.references(() => companyAccounts.id, { onDelete: 'restrict' }),
|
.references(() => companyAccounts.id, { onDelete: 'restrict' }),
|
||||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||||
|
serviceAccountId: uuid('service_account_id').references(
|
||||||
|
(): any => companyServiceAccounts.id,
|
||||||
|
{ onDelete: 'set null' }
|
||||||
|
),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
cycle: recurringBillCycleEnum('cycle').notNull(),
|
cycle: recurringBillCycleEnum('cycle').notNull(),
|
||||||
@@ -999,6 +1003,47 @@ export const recurringBills = pgTable(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Service Accounts ───────────────────────────────────
|
||||||
|
|
||||||
|
export const serviceAccountTypeEnum = pgEnum('service_account_type', [
|
||||||
|
'electricity',
|
||||||
|
'water',
|
||||||
|
'gas',
|
||||||
|
'internet',
|
||||||
|
'phone',
|
||||||
|
'shipping',
|
||||||
|
'insurance',
|
||||||
|
'tax_registration',
|
||||||
|
'social_security',
|
||||||
|
'customs',
|
||||||
|
'other'
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const companyServiceAccounts = pgTable(
|
||||||
|
'company_service_accounts',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid('company_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||||
|
type: serviceAccountTypeEnum('type').notNull(),
|
||||||
|
providerName: text('provider_name').notNull(),
|
||||||
|
accountNumber: text('account_number').notNull(),
|
||||||
|
customLabel: text('custom_label'),
|
||||||
|
contactPhone: text('contact_phone'),
|
||||||
|
websiteUrl: text('website_url'),
|
||||||
|
notes: text('notes'),
|
||||||
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
|
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('company_service_accounts_company_type_idx').on(table.companyId, table.type)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const companyAddresses = pgTable(
|
export const companyAddresses = pgTable(
|
||||||
'company_addresses',
|
'company_addresses',
|
||||||
{
|
{
|
||||||
@@ -1099,7 +1144,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
|||||||
'recurring_bill_paused',
|
'recurring_bill_paused',
|
||||||
'recurring_bill_resumed',
|
'recurring_bill_resumed',
|
||||||
'recurring_bill_skipped',
|
'recurring_bill_skipped',
|
||||||
'recurring_bill_posted'
|
'recurring_bill_posted',
|
||||||
|
'service_account_created',
|
||||||
|
'service_account_updated',
|
||||||
|
'service_account_deleted'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const companyLog = pgTable(
|
export const companyLog = pgTable(
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db } from '$lib/server/db/index.js';
|
|||||||
import {
|
import {
|
||||||
recurringBills,
|
recurringBills,
|
||||||
companyAccounts,
|
companyAccounts,
|
||||||
|
companyServiceAccounts,
|
||||||
projects,
|
projects,
|
||||||
categories,
|
categories,
|
||||||
parties
|
parties
|
||||||
@@ -60,6 +61,7 @@ type BillFormFields = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
partyId: string | null;
|
partyId: string | null;
|
||||||
|
serviceAccountId: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
@@ -106,6 +108,7 @@ function extractFields(fd: FormData): BillFormFields | string {
|
|||||||
projectId,
|
projectId,
|
||||||
categoryId: trimOrNull(fd.get('categoryId')),
|
categoryId: trimOrNull(fd.get('categoryId')),
|
||||||
partyId: trimOrNull(fd.get('partyId')),
|
partyId: trimOrNull(fd.get('partyId')),
|
||||||
|
serviceAccountId: trimOrNull(fd.get('serviceAccountId')),
|
||||||
description: trimOrNull(fd.get('description')),
|
description: trimOrNull(fd.get('description')),
|
||||||
currency,
|
currency,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -118,7 +121,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
|||||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||||
await parent();
|
await parent();
|
||||||
|
|
||||||
const [billRows, accountRows, projectRows, categoryRows, partyRows] = await Promise.all([
|
const [billRows, accountRows, projectRows, categoryRows, partyRows, serviceAccountRows] =
|
||||||
|
await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: recurringBills.id,
|
id: recurringBills.id,
|
||||||
@@ -144,6 +148,9 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
|||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
partyId: recurringBills.partyId,
|
partyId: recurringBills.partyId,
|
||||||
partyName: parties.name,
|
partyName: parties.name,
|
||||||
|
serviceAccountId: recurringBills.serviceAccountId,
|
||||||
|
serviceAccountProvider: companyServiceAccounts.providerName,
|
||||||
|
serviceAccountNumber: companyServiceAccounts.accountNumber,
|
||||||
createdAt: recurringBills.createdAt,
|
createdAt: recurringBills.createdAt,
|
||||||
updatedAt: recurringBills.updatedAt
|
updatedAt: recurringBills.updatedAt
|
||||||
})
|
})
|
||||||
@@ -152,6 +159,7 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
|||||||
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
|
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
|
||||||
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
|
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
|
||||||
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
|
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
|
||||||
|
.leftJoin(companyServiceAccounts, eq(recurringBills.serviceAccountId, companyServiceAccounts.id))
|
||||||
.where(
|
.where(
|
||||||
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
|
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
|
||||||
)
|
)
|
||||||
@@ -190,7 +198,25 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
|||||||
.select({ id: parties.id, name: parties.name })
|
.select({ id: parties.id, name: parties.name })
|
||||||
.from(parties)
|
.from(parties)
|
||||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||||
.orderBy(asc(parties.name))
|
.orderBy(asc(parties.name)),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: companyServiceAccounts.id,
|
||||||
|
type: companyServiceAccounts.type,
|
||||||
|
providerName: companyServiceAccounts.providerName,
|
||||||
|
accountNumber: companyServiceAccounts.accountNumber,
|
||||||
|
customLabel: companyServiceAccounts.customLabel
|
||||||
|
})
|
||||||
|
.from(companyServiceAccounts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(companyServiceAccounts.companyId, params.companyId),
|
||||||
|
isNull(companyServiceAccounts.deletedAt),
|
||||||
|
eq(companyServiceAccounts.isActive, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -198,7 +224,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
|||||||
accounts: accountRows,
|
accounts: accountRows,
|
||||||
projects: projectRows,
|
projects: projectRows,
|
||||||
categories: categoryRows,
|
categories: categoryRows,
|
||||||
parties: partyRows
|
parties: partyRows,
|
||||||
|
serviceAccounts: serviceAccountRows
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +286,7 @@ export const actions: Actions = {
|
|||||||
accountId: parsed.accountId,
|
accountId: parsed.accountId,
|
||||||
categoryId: parsed.categoryId,
|
categoryId: parsed.categoryId,
|
||||||
partyId: parsed.partyId,
|
partyId: parsed.partyId,
|
||||||
|
serviceAccountId: parsed.serviceAccountId,
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
description: parsed.description,
|
description: parsed.description,
|
||||||
cycle: parsed.cycle,
|
cycle: parsed.cycle,
|
||||||
@@ -342,6 +370,7 @@ export const actions: Actions = {
|
|||||||
accountId: parsed.accountId,
|
accountId: parsed.accountId,
|
||||||
categoryId: parsed.categoryId,
|
categoryId: parsed.categoryId,
|
||||||
partyId: parsed.partyId,
|
partyId: parsed.partyId,
|
||||||
|
serviceAccountId: parsed.serviceAccountId,
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
description: parsed.description,
|
description: parsed.description,
|
||||||
cycle: parsed.cycle,
|
cycle: parsed.cycle,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
partyId?: string;
|
partyId?: string;
|
||||||
|
serviceAccountId?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
@@ -211,6 +212,20 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class={labelCls} for="bill-sa-{billId ?? 'new'}">Service Account</label>
|
||||||
|
<select
|
||||||
|
id="bill-sa-{billId ?? 'new'}"
|
||||||
|
name="serviceAccountId"
|
||||||
|
value={values.serviceAccountId ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each data.serviceAccounts as sa (sa.id)}
|
||||||
|
<option value={sa.id}>[{sa.type}] {sa.providerName} #{sa.accountNumber}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
|
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
|
||||||
<input
|
<input
|
||||||
@@ -379,6 +394,11 @@
|
|||||||
Vendor: {bill.partyName}
|
Vendor: {bill.partyName}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if bill.serviceAccountProvider}
|
||||||
|
<div class="text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
|
{bill.serviceAccountProvider} #{bill.serviceAccountNumber}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if bill.status === 'paused' && bill.pausedAt}
|
{#if bill.status === 'paused' && bill.pausedAt}
|
||||||
<div class="text-xs text-amber-600 dark:text-amber-400">
|
<div class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
Paused since {formatDate(bill.pausedAt)}
|
Paused since {formatDate(bill.pausedAt)}
|
||||||
@@ -526,6 +546,7 @@
|
|||||||
projectId: bill.projectId,
|
projectId: bill.projectId,
|
||||||
categoryId: bill.categoryId ?? '',
|
categoryId: bill.categoryId ?? '',
|
||||||
partyId: bill.partyId ?? '',
|
partyId: bill.partyId ?? '',
|
||||||
|
serviceAccountId: bill.serviceAccountId ?? '',
|
||||||
description: bill.description ?? '',
|
description: bill.description ?? '',
|
||||||
currency: bill.currency,
|
currency: bill.currency,
|
||||||
startDate: bill.startDate,
|
startDate: bill.startDate,
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user