Move invoice upload + package linking to the expense detail page
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) <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,11 @@
|
||||
<div class="space-y-3">
|
||||
{#each data.expenses as expense (expense.id)}
|
||||
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 transition-colors hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<a href={`/companies/${data.company.id}/expenses/${expense.id}`}
|
||||
class="mb-1 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
View details →
|
||||
</a>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3>
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,8 +172,8 @@
|
||||
|
||||
<!-- Invoice attachments -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-2 font-semibold text-gray-900 dark:text-white">Invoice</h2>
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Invoice</h2>
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-sm">
|
||||
{#if data.expense.invoiceFileUrl}
|
||||
<a href={`/companies/${data.company.id}/expenses/${data.expense.id}/invoice`}
|
||||
class="rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
@@ -192,20 +192,78 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="space-y-3 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<form method="POST" action="?/uploadInvoice" enctype="multipart/form-data" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="inv-file">Upload file:</label>
|
||||
<input id="inv-file" name="file" type="file" accept="application/pdf,image/*" required
|
||||
class="text-xs text-gray-700 dark:text-gray-300" />
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/setPaperlessLink" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="pless-url">Paperless URL:</label>
|
||||
<input id="pless-url" name="paperlessUrl" type="url"
|
||||
value={data.expense.paperlessUrl ?? ''}
|
||||
placeholder="https://paperless.example.com/documents/123"
|
||||
class="flex-1 min-w-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit"
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.linkedPackages.length > 0}
|
||||
<!-- Packages -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-2 font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
|
||||
|
||||
{#if data.linkedPackages.length > 0}
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
{#each data.linkedPackages as pkg (pkg.id)}
|
||||
<a href={`/companies/${data.company.id}/packages/${pkg.id}`}
|
||||
class="rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
📦 {pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
<a href={`/companies/${data.company.id}/packages/${pkg.id}`} class="hover:underline">
|
||||
📦 {pkg.trackingNumber} — {pkg.carrier}
|
||||
</a>
|
||||
{#if canManage}
|
||||
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="packageId" value={pkg.id} />
|
||||
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
|
||||
</form>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">No packages linked yet.</p>
|
||||
{/if}
|
||||
|
||||
{#if canManage && data.availablePackages.length > 0}
|
||||
<form method="POST" action="?/linkPackage" use:enhance={() => 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">
|
||||
<select name="packageId" required class={inputCls + ' flex-1'}>
|
||||
<option value="" disabled selected>Select package to link</option>
|
||||
{#each data.availablePackages as pkg (pkg.id)}
|
||||
{#if !data.linkedPackages.find((l) => l.id === pkg.id)}
|
||||
<option value={pkg.id}>{pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Link
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user