Add invoice linking on expenses: optional FK, dropdown on add form, clickable chip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,9 @@ export const expenses = pgTable(
|
||||
accountId: uuid('account_id').references((): any => companyAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
invoiceId: uuid('invoice_id').references((): any => invoices.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
projects,
|
||||
users,
|
||||
categories,
|
||||
companyAccounts
|
||||
companyAccounts,
|
||||
invoices,
|
||||
parties
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { asc, eq, and, sql, isNull } from 'drizzle-orm';
|
||||
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
@@ -40,6 +42,7 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
invoiceId: expenses.invoiceId,
|
||||
createdAt: expenses.createdAt
|
||||
})
|
||||
.from(expenses)
|
||||
@@ -87,12 +90,33 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
.where(eq(categories.companyId, params.companyId))
|
||||
.orderBy(asc(categories.name));
|
||||
|
||||
const invoiceList = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction,
|
||||
partyName: parties.name,
|
||||
total: invoices.total,
|
||||
currency: invoices.currency
|
||||
})
|
||||
.from(invoices)
|
||||
.innerJoin(parties, eq(invoices.partyId, parties.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, params.companyId),
|
||||
ne(invoices.status, 'voided'),
|
||||
ne(invoices.status, 'cancelled')
|
||||
)
|
||||
)
|
||||
.orderBy(asc(invoices.invoiceNumber));
|
||||
|
||||
return {
|
||||
expenses: expenseList,
|
||||
statusFilter: status,
|
||||
accounts: accountsList,
|
||||
projects: projectList,
|
||||
categories: categoryList
|
||||
categories: categoryList,
|
||||
invoices: invoiceList
|
||||
};
|
||||
};
|
||||
|
||||
@@ -122,6 +146,7 @@ export const actions: Actions = {
|
||||
const projectId = fd.get('projectId')?.toString().trim() || null;
|
||||
const categoryId = fd.get('categoryId')?.toString().trim() || null;
|
||||
const accountId = fd.get('accountId')?.toString().trim() || null;
|
||||
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
|
||||
const expenseDate = fd.get('expenseDate')?.toString().trim();
|
||||
const description = fd.get('description')?.toString().trim() || null;
|
||||
|
||||
@@ -144,6 +169,7 @@ export const actions: Actions = {
|
||||
projectId: resolvedProjectId,
|
||||
categoryId: categoryId || null,
|
||||
accountId: accountId || null,
|
||||
invoiceId: invoiceId || null,
|
||||
submittedBy: user.id,
|
||||
title,
|
||||
description,
|
||||
@@ -322,6 +348,22 @@ export const actions: Actions = {
|
||||
{ expenseId, accountId, previousAccountId: expense.accountId }
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
linkInvoice: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Expense ID required' });
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ invoiceId, updatedAt: new Date() })
|
||||
.where(eq(expenses.id, expenseId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,6 +88,15 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-invoice" class={labelCls}>Invoice</label>
|
||||
<select id="exp-invoice" name="invoiceId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.invoices as inv (inv.id)}
|
||||
<option value={inv.id}>{inv.invoiceNumber} — {inv.partyName} ({inv.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="exp-desc" class={labelCls}>Description</label>
|
||||
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
|
||||
@@ -150,6 +159,19 @@
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if expense.invoiceId}
|
||||
{@const inv = data.invoices.find((i) => i.id === expense.invoiceId)}
|
||||
{#if inv}
|
||||
<p class="mt-1 text-xs">
|
||||
<a
|
||||
href={`/companies/${data.company.id}/invoices/${inv.id}`}
|
||||
class="rounded-full bg-indigo-100 px-2 py-0.5 font-medium text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/40 dark:text-indigo-300"
|
||||
>
|
||||
Invoice: {inv.invoiceNumber}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
|
||||
Reference in New Issue
Block a user