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,
|
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">
|
||||||
|
|||||||
Reference in New Issue
Block a user