From 7465b498e08c48939878c99275a08630fb0b2439 Mon Sep 17 00:00:00 2001 From: grabowski Date: Mon, 20 Apr 2026 13:31:47 +0700 Subject: [PATCH] Move invoice upload + package linking to the expense detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List page expenses now show a "View details →" link that routes to the detail page. The detail page gains: - Invoice file upload (with Paperless push if configured) - Paperless URL link field - Link / unlink packages to the expense (many-to-many) Same actions exist on both pages for convenience, but the detail page is the primary workspace for managing an expense. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[companyId]/expenses/+page.svelte | 6 +- .../expenses/[expenseId]/+page.server.ts | 149 +++++++++++++++++- .../expenses/[expenseId]/+page.svelte | 82 ++++++++-- 3 files changed, 223 insertions(+), 14 deletions(-) diff --git a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte index 4fc283d..eaf7a3c 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/expenses/+page.svelte @@ -137,7 +137,11 @@
{#each data.expenses as expense (expense.id)} {@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)} -
+
+ + View details → +

{expense.title}

diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts index b7b21ad..fd87208 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.server.ts @@ -20,6 +20,8 @@ import { postExpenseTransaction, removeExpenseTransaction } from '$lib/server/accounts/ledger.js'; +import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js'; +import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js'; function trimOrNull(v: FormDataEntryValue | null): string | null { const s = v?.toString().trim(); @@ -133,6 +135,17 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => { .innerJoin(packages, eq(expensePackages.packageId, packages.id)) .where(eq(expensePackages.expenseId, params.expenseId)); + const availablePackages = await db + .select({ + id: packages.id, + trackingNumber: packages.trackingNumber, + carrier: packages.carrier, + direction: packages.direction + }) + .from(packages) + .where(eq(packages.companyId, params.companyId)) + .orderBy(packages.createdAt); + return { expense: row, projects: projectList, @@ -140,7 +153,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => { accounts: accountList, parties: partyList, invoices: invoiceList, - linkedPackages + linkedPackages, + availablePackages }; }; @@ -242,5 +256,138 @@ export const actions: Actions = { ); return { success: true, action: 'updateExpense' }; + }, + + uploadInvoice: async ({ request, locals, params }) => { + const { user } = await requireCompanyRoleAny(locals, params.companyId, [ + 'admin', 'manager', 'accountant' + ]); + const fd = await request.formData(); + const file = fd.get('file') as File | null; + + if (!file || !(file instanceof File) || file.size === 0) { + return fail(400, { action: 'uploadInvoice', error: 'File is required' }); + } + if (file.size > MAX_BYTES) { + return fail(400, { + action: 'uploadInvoice', + error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)` + }); + } + const mime = file.type || 'application/octet-stream'; + if (!isAllowedMime(mime)) { + return fail(400, { action: 'uploadInvoice', error: `File type not allowed: ${mime}` }); + } + + const [exp] = await db + .select({ id: expenses.id, title: expenses.title }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!exp) return fail(404, { error: 'Expense not found' }); + + let saved; + try { + saved = await saveCompanyFile(params.companyId, file); + } catch (err) { + console.error('saveCompanyFile failed', err); + return fail(500, { action: 'uploadInvoice', error: 'Failed to save file' }); + } + + if (isPaperlessEnabled()) { + await uploadToPaperless(file, exp.title); + } + + await db + .update(expenses) + .set({ + invoiceFileUrl: saved.storedPath, + invoiceFileName: file.name, + updatedAt: new Date() + }) + .where(eq(expenses.id, params.expenseId)); + + await logCompanyEvent(params.companyId, user.id, 'expense_invoice_uploaded', + `Invoice attached to expense "${exp.title}"`, + { expenseId: params.expenseId, fileName: file.name }); + + return { success: true, action: 'uploadInvoice' }; + }, + + setPaperlessLink: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const url = fd.get('paperlessUrl')?.toString().trim() || null; + + if (url && !url.startsWith('http://') && !url.startsWith('https://')) { + return fail(400, { + action: 'setPaperlessLink', + error: 'URL must start with http:// or https://' + }); + } + + const [exp] = await db + .select({ id: expenses.id }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!exp) return fail(404, { error: 'Expense not found' }); + + await db + .update(expenses) + .set({ paperlessUrl: url, updatedAt: new Date() }) + .where(eq(expenses.id, params.expenseId)); + + return { success: true, action: 'setPaperlessLink' }; + }, + + linkPackage: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const packageId = fd.get('packageId')?.toString(); + if (!packageId) return fail(400, { error: 'Package id required' }); + + // Verify expense and package belong to this company + const [exp] = await db + .select({ id: expenses.id }) + .from(expenses) + .innerJoin(projects, eq(expenses.projectId, projects.id)) + .where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))) + .limit(1); + if (!exp) return fail(404, { error: 'Expense not found' }); + + const [pkg] = await db + .select({ id: packages.id }) + .from(packages) + .where(and(eq(packages.id, packageId), eq(packages.companyId, params.companyId))) + .limit(1); + if (!pkg) return fail(404, { error: 'Package not found' }); + + await db + .insert(expensePackages) + .values({ expenseId: params.expenseId, packageId }) + .onConflictDoNothing(); + + return { success: true, action: 'linkPackage' }; + }, + + unlinkPackage: async ({ request, locals, params }) => { + await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']); + const fd = await request.formData(); + const packageId = fd.get('packageId')?.toString(); + if (!packageId) return fail(400, { error: 'Package id required' }); + + await db + .delete(expensePackages) + .where( + and( + eq(expensePackages.expenseId, params.expenseId), + eq(expensePackages.packageId, packageId) + ) + ); + + return { success: true, action: 'unlinkPackage' }; } }; diff --git a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte index cb963e1..33d7bd4 100644 --- a/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte +++ b/src/routes/(app)/companies/[companyId]/expenses/[expenseId]/+page.svelte @@ -172,8 +172,8 @@
-

Invoice

- - {#if data.linkedPackages.length > 0} -
-

Linked Packages

-
+ + - {/if} + {:else} +

No packages linked yet.

+ {/if} + + {#if canManage && data.availablePackages.length > 0} +
async ({ update, formElement }) => { + await update({ reset: false }); + formElement.reset(); + }} class="flex items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700"> + + +
+ {/if} +
{/if}