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,
|
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user