Add expense detail page with edit (audit-logged) and clickable rows on projects
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s

New expense detail at /companies/[id]/expenses/[expenseId] with full
info, edit form for admin/manager/accountant, and audit log entry on
every edit (`expense_updated`). Project view expense rows are now
clickable and navigate to the detail page. Ledger re-posts
automatically if an approved expense's amount or account changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 13:05:17 +07:00
parent 26945285eb
commit 00b8b239e0
4 changed files with 461 additions and 2 deletions
+1
View File
@@ -1298,6 +1298,7 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'invoice_paid',
'invoice_voided',
'expense_invoice_uploaded',
'expense_updated',
'sale_created',
'sale_confirmed',
'sale_voided',
@@ -0,0 +1,246 @@
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
expenses,
projects,
users,
categories,
parties,
companyAccounts,
invoices,
packages,
expensePackages
} from '$lib/server/db/schema.js';
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js';
import {
postExpenseTransaction,
removeExpenseTransaction
} from '$lib/server/accounts/ledger.js';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent }) => {
await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'accountant', 'hr'
]);
await parent();
const [row] = await db
.select({
id: expenses.id,
title: expenses.title,
description: expenses.description,
amount: expenses.amount,
currency: expenses.currency,
status: expenses.status,
expenseDate: expenses.expenseDate,
rejectionReason: expenses.rejectionReason,
reviewedAt: expenses.reviewedAt,
createdAt: expenses.createdAt,
updatedAt: expenses.updatedAt,
projectId: expenses.projectId,
projectName: projects.name,
partyId: expenses.partyId,
partyName: parties.name,
categoryId: expenses.categoryId,
categoryName: categories.name,
accountId: expenses.accountId,
accountName: companyAccounts.name,
invoiceId: expenses.invoiceId,
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl,
submitterName: users.displayName,
submitterEmail: users.email
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(parties, eq(expenses.partyId, parties.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!row) error(404, 'Expense not found');
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
.orderBy(asc(projects.name));
const categoryList = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(asc(categories.name));
const accountList = await db
.select({
id: companyAccounts.id,
name: companyAccounts.name,
currency: companyAccounts.currency
})
.from(companyAccounts)
.where(
and(
eq(companyAccounts.companyId, params.companyId),
eq(companyAccounts.isArchived, false),
isNull(companyAccounts.deletedAt)
)
)
.orderBy(companyAccounts.name);
const partyList = await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
.orderBy(asc(parties.name));
const invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(
and(
eq(invoices.companyId, params.companyId),
ne(invoices.status, 'voided'),
ne(invoices.status, 'cancelled')
)
)
.orderBy(asc(invoices.invoiceNumber));
const linkedPackages = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction,
status: packages.status
})
.from(expensePackages)
.innerJoin(packages, eq(expensePackages.packageId, packages.id))
.where(eq(expensePackages.expenseId, params.expenseId));
return {
expense: row,
projects: projectList,
categories: categoryList,
accounts: accountList,
parties: partyList,
invoices: invoiceList,
linkedPackages
};
};
export const actions: Actions = {
updateExpense: async ({ request, locals, params }) => {
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const canManage = roles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant');
if (!canManage) return fail(403, { error: 'Not permitted' });
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const amountStr = fd.get('amount')?.toString().trim();
const expenseDate = trimOrNull(fd.get('expenseDate'));
const description = trimOrNull(fd.get('description'));
const projectId = trimOrNull(fd.get('projectId'));
const categoryId = trimOrNull(fd.get('categoryId'));
const partyId = trimOrNull(fd.get('partyId'));
const accountId = trimOrNull(fd.get('accountId'));
const invoiceId = trimOrNull(fd.get('invoiceId'));
if (!title) return fail(400, { action: 'updateExpense', error: 'Title is required' });
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
return fail(400, { action: 'updateExpense', error: 'Valid positive amount required' });
}
if (!expenseDate) return fail(400, { action: 'updateExpense', error: 'Date is required' });
if (!projectId) return fail(400, { action: 'updateExpense', error: 'Project is required' });
// Verify current expense belongs to this company
const [existing] = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
status: expenses.status,
accountId: expenses.accountId
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))
)
.limit(1);
if (!existing) error(404, 'Expense not found');
// Verify target project belongs to this company
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'updateExpense', error: 'Project not in this company' });
const newAmount = Number(amountStr).toFixed(2);
const amountChanged = newAmount !== existing.amount;
const accountChanged = (accountId ?? null) !== (existing.accountId ?? null);
await db.transaction(async (tx) => {
await tx
.update(expenses)
.set({
title,
description,
amount: newAmount,
expenseDate,
projectId,
categoryId,
partyId,
accountId,
invoiceId,
updatedAt: new Date()
})
.where(eq(expenses.id, params.expenseId));
// Re-post ledger entry if approved and amount or account changed
if (existing.status === 'approved' && (amountChanged || accountChanged)) {
if (accountId) {
await postExpenseTransaction(params.expenseId, accountId, user.id, tx);
} else {
await removeExpenseTransaction(params.expenseId, tx);
}
}
});
await logCompanyEvent(
params.companyId,
user.id,
'expense_updated',
`Expense "${title}" edited (was ${formatCurrency(existing.amount, 'THB')})`,
{
expenseId: params.expenseId,
previousTitle: existing.title,
previousAmount: existing.amount,
newAmount,
amountChanged,
accountChanged
}
);
return { success: true, action: 'updateExpense' };
}
};
@@ -0,0 +1,211 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { formatDate } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let editing = $state(false);
const canManage = $derived(
data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
);
const STATUS_BADGE: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
approved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
};
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>{data.expense.title} - Expense</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/expenses`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Expenses</a>
<div class="mt-1 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.expense.title}</h1>
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.expense.status]}">
{data.expense.status}
</span>
<span>{data.expense.expenseDate}</span>
<span>By {data.expense.submitterName ?? data.expense.submitterEmail}</span>
</div>
</div>
{#if canManage && !editing}
<button type="button" onclick={() => (editing = true)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Edit
</button>
{/if}
</div>
</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}
{#if editing && canManage}
<form method="POST" action="?/updateExpense"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editing = false;
}}
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
<div class="md:col-span-2">
<label for="e-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="e-title" name="title" type="text" required value={data.expense.title} class={inputCls} />
</div>
<div>
<label for="e-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
<input id="e-amount" name="amount" type="number" step="0.01" min="0.01" required value={data.expense.amount} class={inputCls} />
</div>
<div>
<label for="e-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input id="e-date" name="expenseDate" type="date" required value={data.expense.expenseDate} class={inputCls} />
</div>
<div>
<label for="e-project" class={labelCls}>Project <span class="text-red-500">*</span></label>
<select id="e-project" name="projectId" required value={data.expense.projectId} class={inputCls}>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-category" class={labelCls}>Category</label>
<select id="e-category" name="categoryId" value={data.expense.categoryId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.categories as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-party" class={labelCls}>Supplier</label>
<select id="e-party" name="partyId" value={data.expense.partyId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="e-account" class={labelCls}>Account</label>
<select id="e-account" name="accountId" value={data.expense.accountId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.accounts as a (a.id)}
<option value={a.id}>{a.name} ({a.currency})</option>
{/each}
</select>
</div>
<div>
<label for="e-invoice" class={labelCls}>Linked Invoice</label>
<select id="e-invoice" name="invoiceId" value={data.expense.invoiceId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.invoices as inv (inv.id)}
<option value={inv.id}>{inv.invoiceNumber} ({inv.direction})</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="e-desc" class={labelCls}>Description</label>
<textarea id="e-desc" name="description" rows="2" class={inputCls}>{data.expense.description ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editing = 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">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">Save changes</button>
</div>
<p class="md:col-span-2 text-xs text-gray-500 dark:text-gray-400">Edits are audit-logged. If status is approved and amount/account changes, the ledger entry is re-posted.</p>
</form>
{:else}
<div class="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Amount</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{formatCurrency(data.expense.amount, data.expense.currency)}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Project</p>
<p class="text-sm text-gray-900 dark:text-white">
<a href={`/companies/${data.company.id}/projects/${data.expense.projectId}`}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{data.expense.projectName}</a>
</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Category</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.categoryName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Supplier</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.partyName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
<p class="text-sm text-gray-900 dark:text-white">{data.expense.accountName ?? '—'}</p>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Created</p>
<p class="text-sm text-gray-900 dark:text-white">{formatDate(data.expense.createdAt)}</p>
</div>
{#if data.expense.description}
<div class="md:col-span-2">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Description</p>
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.expense.description}</p>
</div>
{/if}
{#if data.expense.rejectionReason}
<div class="md:col-span-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20">
<p class="text-xs font-medium uppercase tracking-wider text-red-500">Rejected</p>
<p class="text-sm text-red-700 dark:text-red-300">{data.expense.rejectionReason}</p>
</div>
{/if}
</div>
<!-- Invoice attachments -->
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-2 font-semibold text-gray-900 dark:text-white">Invoice</h2>
<div class="flex flex-wrap gap-2 text-sm">
{#if data.expense.invoiceFileUrl}
<a href={`/companies/${data.company.id}/expenses/${data.expense.id}/invoice`}
class="rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300">
📄 {data.expense.invoiceFileName ?? 'Invoice file'}
</a>
{/if}
{#if data.expense.paperlessUrl}
<a href={data.expense.paperlessUrl} target="_blank" rel="noopener noreferrer"
class="rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300">
🗂 Paperless
</a>
{/if}
{#if !data.expense.invoiceFileUrl && !data.expense.paperlessUrl}
<span class="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
No invoice attached
</span>
{/if}
</div>
</div>
{#if data.linkedPackages.length > 0}
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-2 font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
<div class="flex flex-wrap gap-2">
{#each data.linkedPackages as pkg (pkg.id)}
<a href={`/companies/${data.company.id}/packages/${pkg.id}`}
class="rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300">
📦 {pkg.trackingNumber}{pkg.carrier} ({pkg.direction})
</a>
{/each}
</div>
</div>
{/if}
{/if}
</div>
@@ -122,8 +122,9 @@
</tr>
</thead>
<tbody>
{#each data.expenses as expense}
<tr class="border-t border-gray-100 dark:border-gray-700">
{#each data.expenses as expense (expense.id)}
<tr class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/40"
onclick={() => (window.location.href = `/companies/${data.company.id}/expenses/${expense.id}`)}>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{expense.title}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{expense.categoryName ?? '—'}</td>
<td class="px-4 py-3 dark:text-white">{formatCurrency(expense.amount, expense.currency)}</td>