Compare commits
14 Commits
2540a7603e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b4338c6814 | |||
| 06ae314b3c | |||
| c570019fd8 | |||
| 8ef2ef7465 | |||
| ef6ba485d3 | |||
| e216a393e4 | |||
| 6d0fb30545 | |||
| 8376116765 | |||
| 7367aa9572 | |||
| 7465b498e0 | |||
| 7fba11941f | |||
| 94e38aca9c | |||
| 00b8b239e0 | |||
| 26945285eb |
@@ -18,7 +18,7 @@ import {
|
||||
// ── Enums ──────────────────────────────────────────────
|
||||
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected', 'voided']);
|
||||
|
||||
// ── Users ──────────────────────────────────────────────
|
||||
|
||||
@@ -151,6 +151,8 @@ export const expenses = pgTable(
|
||||
invoiceFileName: text('invoice_file_name'),
|
||||
paperlessUrl: text('paperless_url'),
|
||||
paperlessDocumentId: integer('paperless_document_id'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidReason: text('void_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
@@ -1298,6 +1300,8 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'invoice_paid',
|
||||
'invoice_voided',
|
||||
'expense_invoice_uploaded',
|
||||
'expense_updated',
|
||||
'expense_voided',
|
||||
'sale_created',
|
||||
'sale_confirmed',
|
||||
'sale_voided',
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses, sales, saleLineItems } from '$lib/server/db/schema.js';
|
||||
import {
|
||||
projects,
|
||||
expenses,
|
||||
sales,
|
||||
saleLineItems,
|
||||
companyAccounts
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
const { company } = await parent();
|
||||
|
||||
// Get projects with spent amounts
|
||||
// Get projects with spent amounts (converted to base currency via each expense's account fx rate)
|
||||
const projectList = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
allocatedBudget: projects.allocatedBudget,
|
||||
isActive: projects.isActive,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
|
||||
})
|
||||
.from(projects)
|
||||
.leftJoin(expenses, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, company.id))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
|
||||
@@ -9,10 +9,15 @@
|
||||
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
|
||||
const total = $derived(parseFloat(data.company.totalBudget));
|
||||
const income = $derived(parseFloat(data.totalIncome ?? '0'));
|
||||
const remaining = $derived(total - spent);
|
||||
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
||||
// Total already reflects approved expenses (they post negative txns to the ledger),
|
||||
// so available cash IS the total. Spent stays informational.
|
||||
const available = $derived(total);
|
||||
const unallocated = $derived(total - allocated);
|
||||
const allocatedPct = $derived(total > 0 ? (allocated / total) * 100 : 0);
|
||||
const net = $derived(income - spent);
|
||||
const netPositive = $derived(net >= 0);
|
||||
|
||||
const tone = $derived(remaining < 0 ? 'red' : remainingPct < 20 ? 'amber' : 'green');
|
||||
const tone = $derived(available < 0 ? 'red' : available < Math.abs(allocated) * 0.2 ? 'amber' : 'green');
|
||||
|
||||
const toneRing: Record<string, string> = {
|
||||
green: 'border-green-300 dark:border-green-700',
|
||||
@@ -36,45 +41,64 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- KPI row -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="rounded-lg border-2 {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Remaining
|
||||
<!-- Income vs Expenses (hero split) -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 dark:border-emerald-700 dark:bg-emerald-900/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Income
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold {toneText[tone]}">
|
||||
{formatCurrency(remaining, currency)}
|
||||
</p>
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full transition-all {toneBar[tone]}"
|
||||
style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
|
||||
></div>
|
||||
<svg class="h-5 w-5 text-emerald-500 dark:text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3a1 1 0 01.707.293l5 5a1 1 0 01-1.414 1.414L11 6.414V16a1 1 0 11-2 0V6.414L5.707 9.707a1 1 0 01-1.414-1.414l5-5A1 1 0 0110 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{remainingPct.toFixed(1)}% of total
|
||||
<p class="mt-2 text-3xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{formatCurrency(income, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-emerald-700/70 dark:text-emerald-400/70">
|
||||
Net of withholding · from confirmed sales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Total Budget
|
||||
<div class="rounded-lg border-2 border-red-300 bg-red-50 p-5 dark:border-red-700 dark:bg-red-900/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">
|
||||
Expenses
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(total, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Company-wide</p>
|
||||
<svg class="h-5 w-5 text-red-500 dark:text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 17a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L9 13.586V4a1 1 0 112 0v9.586l3.293-3.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 17z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Spent
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<p class="mt-2 text-3xl font-bold text-red-700 dark:text-red-400">
|
||||
{formatCurrency(spent, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-red-700/70 dark:text-red-400/70">
|
||||
Approved · across {data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Net position -->
|
||||
<div class="rounded-lg border-2 {netPositive ? 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/20' : 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20'} p-5 md:col-span-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider {netPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">
|
||||
Net Position (Income − Expenses)
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold {netPositive ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}">
|
||||
{netPositive ? '+' : ''}{formatCurrency(net, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash KPIs (secondary) -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
<div class="rounded-lg border {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Available Cash
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold {toneText[tone]}">
|
||||
{formatCurrency(available, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Across {data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}
|
||||
Sum of account balances (base currency)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -85,19 +109,24 @@
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(allocated, currency)}
|
||||
</p>
|
||||
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div class="h-full bg-blue-500 transition-all" style="width: {Math.min(allocatedPct, 100)}%"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
|
||||
{allocatedPct.toFixed(1)}% of available
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-emerald-300 bg-white p-4 dark:border-emerald-700 dark:bg-gray-800 lg:col-span-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||||
Income (from confirmed sales)
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Unallocated
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{formatCurrency(income, currency)}
|
||||
<p class="mt-1 text-2xl font-bold {unallocated < 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}">
|
||||
{formatCurrency(unallocated, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Cash not assigned to a project
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Net of withholding tax</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companies,
|
||||
companyAccounts,
|
||||
companyAccountTransactions,
|
||||
externalAccounts
|
||||
@@ -13,8 +14,26 @@ import {
|
||||
postTransfer,
|
||||
type CompanyAccountTxnType
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
import { fetchRate } from '$lib/server/fx/index.js';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
async function resolveFxRate(companyId: string, accountCurrency: string): Promise<string> {
|
||||
const [company] = await db
|
||||
.select({ currency: companies.currency })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.limit(1);
|
||||
const base = company?.currency ?? 'THB';
|
||||
if (accountCurrency.toUpperCase() === base.toUpperCase()) return '1';
|
||||
try {
|
||||
const rate = await fetchRate(accountCurrency, base);
|
||||
if (rate !== null && rate > 0) return rate.toFixed(8);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '1';
|
||||
}
|
||||
|
||||
const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
|
||||
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
|
||||
|
||||
@@ -281,6 +300,9 @@ export const actions: Actions = {
|
||||
const openingBalanceDate =
|
||||
parseDate(fd.get('openingBalanceDate')) ?? new Date();
|
||||
|
||||
// Auto-determine FX rate: 1 for base currency, API rate otherwise
|
||||
const fxRateToBase = await resolveFxRate(params.companyId, f.currency);
|
||||
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(companyAccounts)
|
||||
@@ -306,7 +328,7 @@ export const actions: Actions = {
|
||||
creditLimit: f.creditLimit,
|
||||
statementCloseDay: f.statementCloseDay,
|
||||
paymentDueDay: f.paymentDueDay,
|
||||
fxRateToBase: f.fxRateToBase,
|
||||
fxRateToBase,
|
||||
externalAccountId: f.externalAccountId
|
||||
})
|
||||
.returning({ id: companyAccounts.id });
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{#if prefix.startsWith('edit-')}
|
||||
<div>
|
||||
<label for="{prefix}-fxRate" class={labelCls}>FX Rate to Base</label>
|
||||
<input
|
||||
@@ -189,8 +190,13 @@
|
||||
placeholder="1.0 for THB, 34.5 for USD→THB"
|
||||
class={inputCls}
|
||||
/>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">1.0 if same as company currency</p>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Auto-refreshed daily from FX API. Override here to override.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 self-end pb-2">
|
||||
FX rate: auto-set on create (1 if base currency, else fetched from FX API)
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if type === 'bank'}
|
||||
<div>
|
||||
@@ -767,6 +773,13 @@
|
||||
<p class="text-2xl font-bold {balanceClass(acct.balance)}">
|
||||
{formatAmount(acct.balance, acct.currency)}
|
||||
</p>
|
||||
{#if acct.currency !== data.company.currency && acct.fxRateToBase}
|
||||
{@const baseEquivalent = (Number(acct.balance) * Number(acct.fxRateToBase)).toFixed(2)}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
≈ {formatAmount(baseEquivalent, data.company.currency)}
|
||||
<span class="text-gray-400 dark:text-gray-500">(@ {Number(acct.fxRateToBase)})</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if acct.accountType === 'credit_card' && acct.creditLimit}
|
||||
{@const pct = utilisation(acct.balance, acct.creditLimit)}
|
||||
|
||||
@@ -347,6 +347,22 @@
|
||||
Counterparty: {txn.counterpartyName}
|
||||
</div>
|
||||
{/if}
|
||||
{#if txn.sourceExpenseId}
|
||||
<a
|
||||
href={`/companies/${data.company.id}/expenses/${txn.sourceExpenseId}`}
|
||||
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Open expense →
|
||||
</a>
|
||||
{/if}
|
||||
{#if txn.sourceInvoiceId}
|
||||
<a
|
||||
href={`/companies/${data.company.id}/invoices/${txn.sourceInvoiceId}`}
|
||||
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Open invoice →
|
||||
</a>
|
||||
{/if}
|
||||
{#if txn.fxRate && txn.fxAmount}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400">
|
||||
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
expenses,
|
||||
companyLog,
|
||||
sales,
|
||||
saleLineItems
|
||||
saleLineItems,
|
||||
companyAccounts
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
@@ -24,10 +25,11 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
allocatedBudget: projects.allocatedBudget,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`
|
||||
})
|
||||
.from(projects)
|
||||
.leftJoin(expenses, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, params.companyId))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
users,
|
||||
categories,
|
||||
companyAccounts,
|
||||
companies,
|
||||
invoices,
|
||||
parties,
|
||||
packages,
|
||||
@@ -196,6 +197,29 @@ export const actions: Actions = {
|
||||
.limit(1);
|
||||
if (!proj) return fail(400, { action: 'submitExpense', error: 'Project not found' });
|
||||
|
||||
// Resolve currency: use the selected account's currency, else company base
|
||||
let resolvedCurrency = 'THB';
|
||||
if (accountId) {
|
||||
const [acct] = await db
|
||||
.select({ currency: companyAccounts.currency })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, accountId),
|
||||
eq(companyAccounts.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (acct) resolvedCurrency = acct.currency;
|
||||
} else {
|
||||
const [company] = await db
|
||||
.select({ currency: companies.currency })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, params.companyId))
|
||||
.limit(1);
|
||||
if (company) resolvedCurrency = company.currency;
|
||||
}
|
||||
|
||||
await db.insert(expenses).values({
|
||||
projectId: resolvedProjectId,
|
||||
categoryId: categoryId || null,
|
||||
@@ -205,7 +229,7 @@ export const actions: Actions = {
|
||||
title,
|
||||
description,
|
||||
amount: Number(amountStr).toFixed(2),
|
||||
currency: 'THB',
|
||||
currency: resolvedCurrency,
|
||||
expenseDate,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
@@ -137,7 +137,11 @@
|
||||
<div class="space-y-3">
|
||||
{#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 transition-colors hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<a href={`/companies/${data.company.id}/expenses/${expense.id}`}
|
||||
class="mb-1 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
View details →
|
||||
</a>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
expenses,
|
||||
projects,
|
||||
users,
|
||||
categories,
|
||||
parties,
|
||||
companyAccounts,
|
||||
invoices,
|
||||
packages,
|
||||
expensePackages
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import {
|
||||
postExpenseTransaction,
|
||||
removeExpenseTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js';
|
||||
import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'accountant', 'hr'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
title: expenses.title,
|
||||
description: expenses.description,
|
||||
amount: expenses.amount,
|
||||
currency: expenses.currency,
|
||||
status: expenses.status,
|
||||
expenseDate: expenses.expenseDate,
|
||||
rejectionReason: expenses.rejectionReason,
|
||||
reviewedAt: expenses.reviewedAt,
|
||||
createdAt: expenses.createdAt,
|
||||
updatedAt: expenses.updatedAt,
|
||||
projectId: expenses.projectId,
|
||||
projectName: projects.name,
|
||||
partyId: expenses.partyId,
|
||||
partyName: parties.name,
|
||||
categoryId: expenses.categoryId,
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
invoiceId: expenses.invoiceId,
|
||||
invoiceFileUrl: expenses.invoiceFileUrl,
|
||||
invoiceFileName: expenses.invoiceFileName,
|
||||
paperlessUrl: expenses.paperlessUrl,
|
||||
voidedAt: expenses.voidedAt,
|
||||
voidReason: expenses.voidReason,
|
||||
submitterName: users.displayName,
|
||||
submitterEmail: users.email
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.innerJoin(users, eq(expenses.submittedBy, users.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(parties, eq(expenses.partyId, parties.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) error(404, 'Expense not found');
|
||||
|
||||
const projectList = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name));
|
||||
|
||||
const categoryList = await db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(eq(categories.companyId, params.companyId))
|
||||
.orderBy(asc(categories.name));
|
||||
|
||||
const accountList = await db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
name: companyAccounts.name,
|
||||
currency: companyAccounts.currency
|
||||
})
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
eq(companyAccounts.isArchived, false),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(companyAccounts.name);
|
||||
|
||||
const partyList = await db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name));
|
||||
|
||||
const invoiceList = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, params.companyId),
|
||||
ne(invoices.status, 'voided'),
|
||||
ne(invoices.status, 'cancelled')
|
||||
)
|
||||
)
|
||||
.orderBy(asc(invoices.invoiceNumber));
|
||||
|
||||
const linkedPackages = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction,
|
||||
status: packages.status
|
||||
})
|
||||
.from(expensePackages)
|
||||
.innerJoin(packages, eq(expensePackages.packageId, packages.id))
|
||||
.where(eq(expensePackages.expenseId, params.expenseId));
|
||||
|
||||
const availablePackages = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction
|
||||
})
|
||||
.from(packages)
|
||||
.where(eq(packages.companyId, params.companyId))
|
||||
.orderBy(packages.createdAt);
|
||||
|
||||
return {
|
||||
expense: row,
|
||||
projects: projectList,
|
||||
categories: categoryList,
|
||||
accounts: accountList,
|
||||
parties: partyList,
|
||||
invoices: invoiceList,
|
||||
linkedPackages,
|
||||
availablePackages
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateExpense: async ({ request, locals, params }) => {
|
||||
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant');
|
||||
if (!canManage) return fail(403, { error: 'Not permitted' });
|
||||
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const amountStr = fd.get('amount')?.toString().trim();
|
||||
const expenseDate = trimOrNull(fd.get('expenseDate'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const projectId = trimOrNull(fd.get('projectId'));
|
||||
const categoryId = trimOrNull(fd.get('categoryId'));
|
||||
const partyId = trimOrNull(fd.get('partyId'));
|
||||
const accountId = trimOrNull(fd.get('accountId'));
|
||||
const invoiceId = trimOrNull(fd.get('invoiceId'));
|
||||
|
||||
if (!title) return fail(400, { action: 'updateExpense', error: 'Title is required' });
|
||||
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
|
||||
return fail(400, { action: 'updateExpense', error: 'Valid positive amount required' });
|
||||
}
|
||||
if (!expenseDate) return fail(400, { action: 'updateExpense', error: 'Date is required' });
|
||||
if (!projectId) return fail(400, { action: 'updateExpense', error: 'Project is required' });
|
||||
|
||||
// Verify current expense belongs to this company
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
title: expenses.title,
|
||||
amount: expenses.amount,
|
||||
status: expenses.status,
|
||||
accountId: expenses.accountId
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(
|
||||
and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Expense not found');
|
||||
|
||||
// Verify target project belongs to this company
|
||||
const [proj] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!proj) return fail(400, { action: 'updateExpense', error: 'Project not in this company' });
|
||||
|
||||
const newAmount = Number(amountStr).toFixed(2);
|
||||
const amountChanged = newAmount !== existing.amount;
|
||||
const accountChanged = (accountId ?? null) !== (existing.accountId ?? null);
|
||||
|
||||
// Resolve currency from the (possibly new) account
|
||||
let resolvedCurrency: string | undefined = undefined;
|
||||
if (accountId) {
|
||||
const [acct] = await db
|
||||
.select({ currency: companyAccounts.currency })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, accountId),
|
||||
eq(companyAccounts.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (acct) resolvedCurrency = acct.currency;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
title,
|
||||
description,
|
||||
amount: newAmount,
|
||||
expenseDate,
|
||||
projectId,
|
||||
categoryId,
|
||||
partyId,
|
||||
accountId,
|
||||
invoiceId,
|
||||
...(resolvedCurrency ? { currency: resolvedCurrency } : {}),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
// Re-post ledger entry if approved and amount or account changed
|
||||
if (existing.status === 'approved' && (amountChanged || accountChanged)) {
|
||||
if (accountId) {
|
||||
await postExpenseTransaction(params.expenseId, accountId, user.id, tx);
|
||||
} else {
|
||||
await removeExpenseTransaction(params.expenseId, tx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_updated',
|
||||
`Expense "${title}" edited (was ${formatCurrency(existing.amount, 'THB')})`,
|
||||
{
|
||||
expenseId: params.expenseId,
|
||||
previousTitle: existing.title,
|
||||
previousAmount: existing.amount,
|
||||
newAmount,
|
||||
amountChanged,
|
||||
accountChanged
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateExpense' };
|
||||
},
|
||||
|
||||
uploadInvoice: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const file = fd.get('file') as File | null;
|
||||
|
||||
if (!file || !(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { action: 'uploadInvoice', error: 'File is required' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return fail(400, {
|
||||
action: 'uploadInvoice',
|
||||
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
|
||||
});
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!isAllowedMime(mime)) {
|
||||
return fail(400, { action: 'uploadInvoice', error: `File type not allowed: ${mime}` });
|
||||
}
|
||||
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id, title: expenses.title })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await saveCompanyFile(params.companyId, file);
|
||||
} catch (err) {
|
||||
console.error('saveCompanyFile failed', err);
|
||||
return fail(500, { action: 'uploadInvoice', error: 'Failed to save file' });
|
||||
}
|
||||
|
||||
if (isPaperlessEnabled()) {
|
||||
await uploadToPaperless(file, exp.title);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({
|
||||
invoiceFileUrl: saved.storedPath,
|
||||
invoiceFileName: file.name,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'expense_invoice_uploaded',
|
||||
`Invoice attached to expense "${exp.title}"`,
|
||||
{ expenseId: params.expenseId, fileName: file.name });
|
||||
|
||||
return { success: true, action: 'uploadInvoice' };
|
||||
},
|
||||
|
||||
setPaperlessLink: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const url = fd.get('paperlessUrl')?.toString().trim() || null;
|
||||
|
||||
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return fail(400, {
|
||||
action: 'setPaperlessLink',
|
||||
error: 'URL must start with http:// or https://'
|
||||
});
|
||||
}
|
||||
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ paperlessUrl: url, updatedAt: new Date() })
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
return { success: true, action: 'setPaperlessLink' };
|
||||
},
|
||||
|
||||
linkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
// Verify expense and package belong 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, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
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: params.expenseId, packageId })
|
||||
.onConflictDoNothing();
|
||||
|
||||
return { success: true, action: 'linkPackage' };
|
||||
},
|
||||
|
||||
voidExpense: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const reason = fd.get('reason')?.toString().trim();
|
||||
if (!reason) return fail(400, { action: 'voidExpense', error: 'Void reason is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: expenses.id, title: expenses.title, status: expenses.status })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!existing) return fail(404, { error: 'Expense not found' });
|
||||
if (existing.status === 'voided') return fail(400, { error: 'Expense is already voided' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'voided',
|
||||
voidedAt: new Date(),
|
||||
voidReason: reason,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
// Reverse any ledger post for this expense
|
||||
await removeExpenseTransaction(params.expenseId, tx);
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_voided',
|
||||
`Expense "${existing.title}" voided: ${reason}`,
|
||||
{ expenseId: params.expenseId, reason, previousStatus: existing.status }
|
||||
);
|
||||
|
||||
return { success: true, action: 'voidExpense' };
|
||||
},
|
||||
|
||||
unlinkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
await db
|
||||
.delete(expensePackages)
|
||||
.where(
|
||||
and(
|
||||
eq(expensePackages.expenseId, params.expenseId),
|
||||
eq(expensePackages.packageId, packageId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'unlinkPackage' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
|
||||
const canManage = $derived(
|
||||
data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
|
||||
);
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
approved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
|
||||
};
|
||||
|
||||
let showVoidForm = $state(false);
|
||||
const canVoid = $derived(canManage && data.expense.status !== 'voided');
|
||||
|
||||
const inputCls = 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.expense.title} - Expense</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<a href={`/companies/${data.company.id}/expenses`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Expenses</a>
|
||||
<div class="mt-1 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.expense.title}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.expense.status]}">
|
||||
{data.expense.status}
|
||||
</span>
|
||||
<span>{data.expense.expenseDate}</span>
|
||||
<span>By {data.expense.submitterName ?? data.expense.submitterEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if canManage && !editing}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => (editing = true)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Edit
|
||||
</button>
|
||||
{#if canVoid}
|
||||
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
|
||||
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Void
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.expense.status === 'voided' && data.expense.voidReason}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/20">
|
||||
<span class="text-sm font-medium text-red-700 dark:text-red-300">Voided:</span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{data.expense.voidReason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showVoidForm && canVoid}
|
||||
<form method="POST" action="?/voidExpense"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') showVoidForm = false;
|
||||
}}
|
||||
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
|
||||
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
|
||||
Void this expense? This reverses any ledger entry. Cannot be undone.
|
||||
</p>
|
||||
<label for="void-reason" class={labelCls}>Reason <span class="text-red-500">*</span></label>
|
||||
<textarea id="void-reason" name="reason" rows="2" required
|
||||
placeholder="e.g. Duplicate, wrong supplier, incorrect amount"
|
||||
class={inputCls}></textarea>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showVoidForm = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if editing && canManage}
|
||||
<form method="POST" action="?/updateExpense"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editing = false;
|
||||
}}
|
||||
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="e-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="e-title" name="title" type="text" required value={data.expense.title} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
|
||||
<input id="e-amount" name="amount" type="number" step="0.01" min="0.01" required value={data.expense.amount} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
|
||||
<input id="e-date" name="expenseDate" type="date" required value={data.expense.expenseDate} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-project" class={labelCls}>Project <span class="text-red-500">*</span></label>
|
||||
<select id="e-project" name="projectId" required value={data.expense.projectId} class={inputCls}>
|
||||
{#each data.projects as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-category" class={labelCls}>Category</label>
|
||||
<select id="e-category" name="categoryId" value={data.expense.categoryId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.categories as c (c.id)}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-party" class={labelCls}>Supplier</label>
|
||||
<select id="e-party" name="partyId" value={data.expense.partyId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.parties as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-account" class={labelCls}>Account</label>
|
||||
<select id="e-account" name="accountId" value={data.expense.accountId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.accounts as a (a.id)}
|
||||
<option value={a.id}>{a.name} ({a.currency})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-invoice" class={labelCls}>Linked Invoice</label>
|
||||
<select id="e-invoice" name="invoiceId" value={data.expense.invoiceId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.invoices as inv (inv.id)}
|
||||
<option value={inv.id}>{inv.invoiceNumber} ({inv.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="e-desc" class={labelCls}>Description</label>
|
||||
<textarea id="e-desc" name="description" rows="2" class={inputCls}>{data.expense.description ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editing = false)}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save changes</button>
|
||||
</div>
|
||||
<p class="md:col-span-2 text-xs text-gray-500 dark:text-gray-400">Edits are audit-logged. If status is approved and amount/account changes, the ledger entry is re-posted.</p>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Amount</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{formatCurrency(data.expense.amount, data.expense.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Project</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">
|
||||
<a href={`/companies/${data.company.id}/projects/${data.expense.projectId}`}
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{data.expense.projectName}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Category</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.categoryName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Supplier</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.partyName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.accountName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Created</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{formatDate(data.expense.createdAt)}</p>
|
||||
</div>
|
||||
{#if data.expense.description}
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Description</p>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.expense.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.expense.rejectionReason}
|
||||
<div class="md:col-span-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-red-500">Rejected</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{data.expense.rejectionReason}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Invoice attachments -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Invoice</h2>
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-sm">
|
||||
{#if data.expense.invoiceFileUrl}
|
||||
<a href={`/companies/${data.company.id}/expenses/${data.expense.id}/invoice`}
|
||||
class="rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
📄 {data.expense.invoiceFileName ?? 'Invoice file'}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.expense.paperlessUrl}
|
||||
<a href={data.expense.paperlessUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
🗂 Paperless
|
||||
</a>
|
||||
{/if}
|
||||
{#if !data.expense.invoiceFileUrl && !data.expense.paperlessUrl}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
No invoice attached
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="space-y-3 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<form method="POST" action="?/uploadInvoice" enctype="multipart/form-data" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="inv-file">Upload file:</label>
|
||||
<input id="inv-file" name="file" type="file" accept="application/pdf,image/*" required
|
||||
class="text-xs text-gray-700 dark:text-gray-300" />
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/setPaperlessLink" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="pless-url">Paperless URL:</label>
|
||||
<input id="pless-url" name="paperlessUrl" type="url"
|
||||
value={data.expense.paperlessUrl ?? ''}
|
||||
placeholder="https://paperless.example.com/documents/123"
|
||||
class="flex-1 min-w-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit"
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Packages -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
|
||||
{#if canManage}
|
||||
<a href={`/companies/${data.company.id}/packages/new`}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700">
|
||||
+ New Package
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.linkedPackages.length > 0}
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
{#each data.linkedPackages as pkg (pkg.id)}
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
<a href={`/companies/${data.company.id}/packages/${pkg.id}`} class="hover:underline">
|
||||
📦 {pkg.trackingNumber} — {pkg.carrier}
|
||||
</a>
|
||||
{#if canManage}
|
||||
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="packageId" value={pkg.id} />
|
||||
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
|
||||
</form>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">No packages linked yet.</p>
|
||||
{/if}
|
||||
|
||||
{#if canManage}
|
||||
{@const selectable = data.availablePackages.filter((p) => !data.linkedPackages.find((l) => l.id === p.id))}
|
||||
{#if selectable.length > 0}
|
||||
<form method="POST" action="?/linkPackage" use:enhance={() => async ({ update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
formElement.reset();
|
||||
}} class="flex items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700">
|
||||
<select name="packageId" required class={inputCls + ' flex-1'}>
|
||||
<option value="" disabled selected>Select an existing package</option>
|
||||
{#each selectable as pkg (pkg.id)}
|
||||
<option value={pkg.id}>{pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Link
|
||||
</button>
|
||||
</form>
|
||||
{:else if data.availablePackages.length === 0}
|
||||
<p class="border-t border-gray-100 pt-3 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
No packages exist yet. Use "+ New Package" above to create one.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses } from '$lib/server/db/schema.js';
|
||||
import { projects, expenses, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
@@ -13,12 +13,13 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
description: projects.description,
|
||||
allocatedBudget: projects.allocatedBudget,
|
||||
isActive: projects.isActive,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
expenseCount: sql<number>`count(${expenses.id})::int`,
|
||||
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
|
||||
})
|
||||
.from(projects)
|
||||
.leftJoin(expenses, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, company.id))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses, users, categories } from '$lib/server/db/schema.js';
|
||||
import { projects, expenses, users, categories, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
@@ -41,11 +41,12 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
|
||||
const [stats] = await db
|
||||
.select({
|
||||
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
|
||||
totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} else 0 end), 0)`,
|
||||
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
count: sql<number>`count(*)::int`
|
||||
})
|
||||
.from(expenses)
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(expenses.projectId, params.projectId));
|
||||
|
||||
return { project, expenses: expenseList, stats };
|
||||
|
||||
@@ -122,8 +122,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.expenses as expense}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||
{#each data.expenses as expense (expense.id)}
|
||||
<tr class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/40"
|
||||
onclick={() => (window.location.href = `/companies/${data.company.id}/expenses/${expense.id}`)}>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{expense.title}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{expense.categoryName ?? '—'}</td>
|
||||
<td class="px-4 py-3 dark:text-white">{formatCurrency(expense.amount, expense.currency)}</td>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, projects, categories } from '$lib/server/db/schema.js';
|
||||
import { expenses, projects, categories, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
@@ -9,16 +9,20 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
const from = url.searchParams.get('from') || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0];
|
||||
const to = url.searchParams.get('to') || new Date().toISOString().split('T')[0];
|
||||
|
||||
// All amounts converted to company base currency via account FX rate.
|
||||
const convertedAmount = sql<string>`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`;
|
||||
|
||||
// Spending by category
|
||||
const byCategory = await db
|
||||
.select({
|
||||
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
|
||||
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
|
||||
total: sql<string>`sum(${expenses.amount})`
|
||||
total: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
@@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
.select({
|
||||
projectName: projects.name,
|
||||
allocated: projects.allocatedBudget,
|
||||
spent: sql<string>`sum(${expenses.amount})`
|
||||
spent: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
@@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
const byMonth = await db
|
||||
.select({
|
||||
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
|
||||
total: sql<string>`sum(${expenses.amount})`
|
||||
total: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Constrain date inputs so browsers don't render yyyyyy-mm-dd.
|
||||
// Uses a MutationObserver so dynamically-rendered inputs get constrained too.
|
||||
function constrainEl(el: Element) {
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
if (el.type !== 'date') return;
|
||||
if (!el.hasAttribute('min')) el.setAttribute('min', '1900-01-01');
|
||||
if (!el.hasAttribute('max')) el.setAttribute('max', '2100-12-31');
|
||||
}
|
||||
function constrainRoot(root: ParentNode) {
|
||||
root.querySelectorAll<HTMLInputElement>('input[type="date"]').forEach(constrainEl);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
constrainRoot(document);
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
constrainEl(node as Element);
|
||||
if ((node as Element).querySelectorAll) constrainRoot(node as Element);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
Reference in New Issue
Block a user