From 0710d63cc154436035d704e04e844823a6b20cc0 Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 17 Apr 2026 15:26:18 +0700 Subject: [PATCH] Add inline expense form on expenses tab with company-wide (General) option Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[companyId]/expenses/+page.server.ts | 89 ++++++++++++++++++- .../[companyId]/expenses/+page.svelte | 83 ++++++++++++++++- 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts b/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts index 69f27a2..d4bebac 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/expenses/+page.server.ts @@ -8,7 +8,7 @@ import { categories, companyAccounts } 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 { logCompanyEvent } from '$lib/server/audit.js'; import { formatCurrency } from '$lib/utils/currency.js'; @@ -75,10 +75,95 @@ export const load: PageServerLoad = async ({ parent, params, url }) => { ) .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 { + 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 = { + 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 }) => { const { user } = await requireCompanyRole(locals, params.companyId, 'manager'); const formData = await request.formData(); diff --git a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte index 97747fc..1589fe4 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte @@ -3,9 +3,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; 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 canApprove = $derived( data.companyRoles.includes('admin') || data.companyRoles.includes('manager') @@ -15,6 +15,12 @@ data.companyRoles.includes('manager') || 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'; @@ -24,8 +30,81 @@

Expenses

+
+ {#if form?.action === 'submitExpense' && form.error} +
{form.error}
+ {/if} + + {#if showAddForm} +
+

Add Expense

+
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"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ Expense will be submitted as pending. A manager can approve or reject it. +

+
+ {/if} +
{#each ['all', 'pending', 'approved', 'rejected'] as status}