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:
@@ -8,7 +8,9 @@ import {
|
||||
categories,
|
||||
companyAccounts,
|
||||
invoices,
|
||||
parties
|
||||
parties,
|
||||
packages,
|
||||
expensePackages
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
@@ -115,13 +117,37 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
)
|
||||
.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 {
|
||||
expenses: expenseList,
|
||||
statusFilter: status,
|
||||
accounts: accountsList,
|
||||
projects: projectList,
|
||||
categories: categoryList,
|
||||
invoices: invoiceList
|
||||
invoices: invoiceList,
|
||||
packages: packageList,
|
||||
expensePackageLinks
|
||||
};
|
||||
};
|
||||
|
||||
@@ -442,6 +468,56 @@ export const actions: Actions = {
|
||||
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 }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
|
||||
@@ -135,7 +135,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -197,6 +198,19 @@
|
||||
</span>
|
||||
{/if}
|
||||
</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 class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -279,6 +293,38 @@
|
||||
</form>
|
||||
{/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}
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user