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, {
|
accountId: uuid('account_id').references((): any => companyAccounts.id, {
|
||||||
onDelete: 'set null'
|
onDelete: 'set null'
|
||||||
}),
|
}),
|
||||||
|
invoiceId: uuid('invoice_id').references((): any => invoices.id, {
|
||||||
|
onDelete: 'set null'
|
||||||
|
}),
|
||||||
submittedBy: text('submitted_by')
|
submittedBy: text('submitted_by')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
projects,
|
projects,
|
||||||
users,
|
users,
|
||||||
categories,
|
categories,
|
||||||
companyAccounts
|
companyAccounts,
|
||||||
|
invoices,
|
||||||
|
parties
|
||||||
} from '$lib/server/db/schema.js';
|
} 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 { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||||
import { formatCurrency } from '$lib/utils/currency.js';
|
import { formatCurrency } from '$lib/utils/currency.js';
|
||||||
@@ -40,6 +42,7 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
|||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
accountId: expenses.accountId,
|
accountId: expenses.accountId,
|
||||||
accountName: companyAccounts.name,
|
accountName: companyAccounts.name,
|
||||||
|
invoiceId: expenses.invoiceId,
|
||||||
createdAt: expenses.createdAt
|
createdAt: expenses.createdAt
|
||||||
})
|
})
|
||||||
.from(expenses)
|
.from(expenses)
|
||||||
@@ -87,12 +90,33 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
|||||||
.where(eq(categories.companyId, params.companyId))
|
.where(eq(categories.companyId, params.companyId))
|
||||||
.orderBy(asc(categories.name));
|
.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 {
|
return {
|
||||||
expenses: expenseList,
|
expenses: expenseList,
|
||||||
statusFilter: status,
|
statusFilter: status,
|
||||||
accounts: accountsList,
|
accounts: accountsList,
|
||||||
projects: projectList,
|
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 projectId = fd.get('projectId')?.toString().trim() || null;
|
||||||
const categoryId = fd.get('categoryId')?.toString().trim() || null;
|
const categoryId = fd.get('categoryId')?.toString().trim() || null;
|
||||||
const accountId = fd.get('accountId')?.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 expenseDate = fd.get('expenseDate')?.toString().trim();
|
||||||
const description = fd.get('description')?.toString().trim() || null;
|
const description = fd.get('description')?.toString().trim() || null;
|
||||||
|
|
||||||
@@ -144,6 +169,7 @@ export const actions: Actions = {
|
|||||||
projectId: resolvedProjectId,
|
projectId: resolvedProjectId,
|
||||||
categoryId: categoryId || null,
|
categoryId: categoryId || null,
|
||||||
accountId: accountId || null,
|
accountId: accountId || null,
|
||||||
|
invoiceId: invoiceId || null,
|
||||||
submittedBy: user.id,
|
submittedBy: user.id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -322,6 +348,22 @@ export const actions: Actions = {
|
|||||||
{ expenseId, accountId, previousAccountId: expense.accountId }
|
{ 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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -88,6 +88,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="md:col-span-2">
|
||||||
<label for="exp-desc" class={labelCls}>Description</label>
|
<label for="exp-desc" class={labelCls}>Description</label>
|
||||||
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
|
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
|
||||||
@@ -150,6 +159,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user