Link multiple packages to expenses via junction table

Added linkPackage/unlinkPackage actions and a collapsible package
checklist per expense. Linked packages display as clickable cyan chips
on the expense row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 11:56:39 +07:00
parent 34aab722b4
commit f56d4caec8
2 changed files with 125 additions and 3 deletions
@@ -8,7 +8,9 @@ import {
categories, categories,
companyAccounts, companyAccounts,
invoices, invoices,
parties parties,
packages,
expensePackages
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm'; import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js'; import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
@@ -115,13 +117,37 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
) )
.orderBy(asc(invoices.invoiceNumber)); .orderBy(asc(invoices.invoiceNumber));
const packageList = await db
.select({
id: packages.id,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
direction: packages.direction,
status: packages.status
})
.from(packages)
.where(eq(packages.companyId, params.companyId))
.orderBy(sql`${packages.createdAt} desc`);
const expensePackageLinks = await db
.select({
expenseId: expensePackages.expenseId,
packageId: expensePackages.packageId
})
.from(expensePackages)
.innerJoin(expenses, eq(expensePackages.expenseId, expenses.id))
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId));
return { return {
expenses: expenseList, expenses: expenseList,
statusFilter: status, statusFilter: status,
accounts: accountsList, accounts: accountsList,
projects: projectList, projects: projectList,
categories: categoryList, categories: categoryList,
invoices: invoiceList invoices: invoiceList,
packages: packageList,
expensePackageLinks
}; };
}; };
@@ -442,6 +468,56 @@ export const actions: Actions = {
return { success: true, action: 'uploadExpenseInvoice' }; return { success: true, action: 'uploadExpenseInvoice' };
}, },
linkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const packageId = fd.get('packageId')?.toString();
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
// Verify expense belongs 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, expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!exp) return fail(404, { error: 'Expense not found' });
// Verify package belongs to this company
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, 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 expenseId = fd.get('expenseId')?.toString();
const packageId = fd.get('packageId')?.toString();
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
await db
.delete(expensePackages)
.where(
and(eq(expensePackages.expenseId, expenseId), eq(expensePackages.packageId, packageId))
);
return { success: true, action: 'unlinkPackage' };
},
setExpensePaperlessLink: async ({ request, locals, params }) => { setExpensePaperlessLink: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [ const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant' 'admin', 'manager', 'accountant'
@@ -135,7 +135,8 @@
</div> </div>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each data.expenses as expense} {#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">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
@@ -197,6 +198,19 @@
</span> </span>
{/if} {/if}
</p> </p>
{#if linkedPkgIds.length > 0}
<p class="mt-1 flex flex-wrap gap-1 text-xs">
{#each linkedPkgIds as pkgId (pkgId)}
{@const pkg = data.packages.find((p) => p.id === pkgId)}
{#if pkg}
<a href={`/companies/${data.company.id}/packages/${pkg.id}`}
class="rounded-full bg-cyan-100 px-2 py-0.5 font-medium text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300">
📦 {pkg.trackingNumber}
</a>
{/if}
{/each}
</p>
{/if}
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p> <p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
@@ -279,6 +293,38 @@
</form> </form>
{/if} {/if}
{#if canAssignAccount && data.packages.length > 0}
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
Link packages ({linkedPkgIds.length})
</summary>
<div class="mt-2 space-y-1">
{#each data.packages as pkg (pkg.id)}
{@const isLinked = linkedPkgIds.includes(pkg.id)}
<form
method="POST"
action={isLinked ? '?/unlinkPackage' : '?/linkPackage'}
use:enhance
class="flex items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<input type="hidden" name="packageId" value={pkg.id} />
<button type="submit"
class="flex h-4 w-4 items-center justify-center rounded border-2 {isLinked
? 'border-cyan-500 bg-cyan-500 text-white'
: 'border-gray-300 hover:border-cyan-400 dark:border-gray-600'}">
{#if isLinked}{/if}
</button>
<span class="text-gray-700 dark:text-gray-300">
{pkg.trackingNumber}{pkg.carrier} ({pkg.direction})
</span>
</form>
{/each}
</div>
</details>
{/if}
{#if canAssignAccount} {#if canAssignAccount}
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700"> <details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"> <summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">