Add inline expense form on expenses tab with company-wide (General) option
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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 = {
|
||||
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();
|
||||
|
||||
@@ -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';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -24,8 +30,81 @@
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
{#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 -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['all', 'pending', 'approved', 'rejected'] as status}
|
||||
|
||||
Reference in New Issue
Block a user