diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index ec3e62c..77efb21 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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', diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts new file mode 100644 index 0000000..b7b21ad --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts @@ -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' }; + } +}; diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte new file mode 100644 index 0000000..cb963e1 --- /dev/null +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte @@ -0,0 +1,211 @@ + + + + {data.expense.title} - Expense + + +
+
+ ← Expenses +
+
+

{data.expense.title}

+
+ + {data.expense.status} + + {data.expense.expenseDate} + By {data.expense.submitterName ?? data.expense.submitterEmail} +
+
+ {#if canManage && !editing} + + {/if} +
+
+ + {#if form?.error} +
{form.error}
+ {/if} + + {#if editing && canManage} +
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"> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

Edits are audit-logged. If status is approved and amount/account changes, the ledger entry is re-posted.

+
+ {:else} +
+
+

Amount

+

{formatCurrency(data.expense.amount, data.expense.currency)}

+
+
+

Project

+

+ {data.expense.projectName} +

+
+
+

Category

+

{data.expense.categoryName ?? '—'}

+
+
+

Supplier

+

{data.expense.partyName ?? '—'}

+
+
+

Account

+

{data.expense.accountName ?? '—'}

+
+
+

Created

+

{formatDate(data.expense.createdAt)}

+
+ {#if data.expense.description} +
+

Description

+

{data.expense.description}

+
+ {/if} + {#if data.expense.rejectionReason} +
+

Rejected

+

{data.expense.rejectionReason}

+
+ {/if} +
+ + +
+

Invoice

+
+ {#if data.expense.invoiceFileUrl} + + 📄 {data.expense.invoiceFileName ?? 'Invoice file'} + + {/if} + {#if data.expense.paperlessUrl} + + 🗂 Paperless + + {/if} + {#if !data.expense.invoiceFileUrl && !data.expense.paperlessUrl} + + No invoice attached + + {/if} +
+
+ + {#if data.linkedPackages.length > 0} +
+

Linked Packages

+
+ {#each data.linkedPackages as pkg (pkg.id)} + + 📦 {pkg.trackingNumber} — {pkg.carrier} ({pkg.direction}) + + {/each} +
+
+ {/if} + {/if} +
diff --git a/src/routes/(app)/companies/[companyId]/projects/[projectId]/+page.svelte b/src/routes/(app)/companies/[companyId]/projects/[projectId]/+page.svelte index 02112c4..c8b5822 100644 --- a/src/routes/(app)/companies/[companyId]/projects/[projectId]/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/projects/[projectId]/+page.svelte @@ -122,8 +122,9 @@ - {#each data.expenses as expense} - + {#each data.expenses as expense (expense.id)} + (window.location.href = `/companies/${data.company.id}/expenses/${expense.id}`)}> {expense.title} {expense.categoryName ?? '—'} {formatCurrency(expense.amount, expense.currency)}