Add inline expense form on expenses tab with company-wide (General) option
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 37s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:26:18 +07:00
parent 5ff4f07ff4
commit 0710d63cc1
2 changed files with 168 additions and 4 deletions
@@ -8,7 +8,7 @@ import {
categories, categories,
companyAccounts companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { eq, and, sql, isNull } from 'drizzle-orm'; import { asc, eq, and, sql, isNull } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
@@ -75,10 +75,95 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
) )
.orderBy(companyAccounts.name); .orderBy(companyAccounts.name);
return { expenses: expenseList, statusFilter: status, accounts: accountsList }; 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));
return {
expenses: expenseList,
statusFilter: status,
accounts: accountsList,
projects: projectList,
categories: categoryList
};
}; };
async function ensureGeneralProject(companyId: string): Promise<string> {
const [existing] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.companyId, companyId), eq(projects.name, 'General')))
.limit(1);
if (existing) return existing.id;
const [created] = await db
.insert(projects)
.values({ companyId, name: 'General', description: 'Company-wide expenses' })
.returning({ id: projects.id });
return created.id;
}
export const actions: Actions = { export const actions: Actions = {
submitExpense: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'hr', 'accountant'
]);
const fd = await request.formData();
const title = fd.get('title')?.toString().trim();
const amountStr = fd.get('amount')?.toString().trim();
const projectId = fd.get('projectId')?.toString().trim() || null;
const categoryId = fd.get('categoryId')?.toString().trim() || null;
const accountId = fd.get('accountId')?.toString().trim() || null;
const expenseDate = fd.get('expenseDate')?.toString().trim();
const description = fd.get('description')?.toString().trim() || null;
if (!title) return fail(400, { action: 'submitExpense', error: 'Title is required' });
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
return fail(400, { action: 'submitExpense', error: 'Valid positive amount is required' });
}
if (!expenseDate) return fail(400, { action: 'submitExpense', error: 'Date is required' });
const resolvedProjectId = projectId || (await ensureGeneralProject(params.companyId));
const [proj] = await db
.select({ id: projects.id })
.from(projects)
.where(and(eq(projects.id, resolvedProjectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!proj) return fail(400, { action: 'submitExpense', error: 'Project not found' });
await db.insert(expenses).values({
projectId: resolvedProjectId,
categoryId: categoryId || null,
accountId: accountId || null,
submittedBy: user.id,
title,
description,
amount: Number(amountStr).toFixed(2),
currency: 'THB',
expenseDate,
status: 'pending'
});
await logCompanyEvent(
params.companyId,
user.id,
'expense_submitted',
`Expense "${title}" submitted for ${formatCurrency(amountStr, 'THB')}`,
{ projectId: resolvedProjectId }
);
return { success: true, action: 'submitExpense' };
},
approve: async ({ request, locals, params }) => { approve: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData(); const formData = await request.formData();
@@ -3,9 +3,9 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData } from './$types'; import type { PageData, ActionData } from './$types';
let { data } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const canApprove = $derived( const canApprove = $derived(
data.companyRoles.includes('admin') || data.companyRoles.includes('manager') data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
@@ -15,6 +15,12 @@
data.companyRoles.includes('manager') || data.companyRoles.includes('manager') ||
data.companyRoles.includes('accountant') data.companyRoles.includes('accountant')
); );
let showAddForm = $state(false);
const todayIso = new Date().toISOString().slice(0, 10);
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> </script>
<svelte:head> <svelte:head>
@@ -24,8 +30,81 @@
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Expenses</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Expenses</h2>
<button type="button" onclick={() => (showAddForm = !showAddForm)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddForm ? 'Cancel' : '+ New Expense'}
</button>
</div> </div>
{#if form?.action === 'submitExpense' && form.error}
<div class="mb-4 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 showAddForm}
<section class="mb-6 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<h3 class="mb-3 font-semibold text-gray-900 dark:text-white">Add Expense</h3>
<form method="POST" action="?/submitExpense"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') { showAddForm = false; formElement.reset(); }
}}
class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label for="exp-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="exp-title" name="title" type="text" required class={inputCls} placeholder="e.g. Office supplies" />
</div>
<div>
<label for="exp-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
<input id="exp-amount" name="amount" type="number" step="0.01" min="0.01" required class={inputCls} />
</div>
<div>
<label for="exp-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
<input id="exp-date" name="expenseDate" type="date" required value={todayIso} class={inputCls} />
</div>
<div>
<label for="exp-project" class={labelCls}>Project</label>
<select id="exp-project" name="projectId" class={inputCls}>
<option value="">Company-wide (General)</option>
{#each data.projects as proj (proj.id)}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
</div>
<div>
<label for="exp-category" class={labelCls}>Category</label>
<select id="exp-category" name="categoryId" class={inputCls}>
<option value=""></option>
{#each data.categories as cat (cat.id)}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label for="exp-account" class={labelCls}>Account</label>
<select id="exp-account" name="accountId" class={inputCls}>
<option value=""></option>
{#each data.accounts as acct (acct.id)}
<option value={acct.id}>{acct.name} ({acct.currency})</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="exp-desc" class={labelCls}>Description</label>
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
</div>
<div class="md:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (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-700">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">Submit Expense</button>
</div>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Expense will be submitted as pending. A manager can approve or reject it.
</p>
</section>
{/if}
<!-- Status filter --> <!-- Status filter -->
<div class="mb-4 flex gap-2"> <div class="mb-4 flex gap-2">
{#each ['all', 'pending', 'approved', 'rejected'] as status} {#each ['all', 'pending', 'approved', 'rejected'] as status}