Compare commits

...

20 Commits

Author SHA1 Message Date
grabowski b4338c6814 Link source expense/invoice from account transaction rows
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
Each transaction that was posted from an expense or invoice now shows
an 'Open expense →' or 'Open invoice →' link to the source record's
detail page. The sourceExpenseId/sourceInvoiceId fields were already
loaded — just needed the UI affordance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:10:07 +07:00
grabowski 06ae314b3c Convert project list and detail spent to base currency via FX rate
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 38s
Both the projects list and the project detail stats (totalApproved,
totalPending) now left-join companyAccounts and multiply expense
amounts by fxRateToBase before summing, matching the overview and
budget page fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:03:08 +07:00
grabowski c570019fd8 Convert report amounts to base currency; add expense void action
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 34s
Reports: all three aggregations (byCategory, byProject, byMonth)
left-join companyAccounts and multiply expense amounts by
fxRateToBase before summing, so USD expenses show correctly.

Expenses: new 'voided' status on expenseStatusEnum with voidedAt +
voidReason columns. Void button on the detail page (admin/manager/
accountant) requires a reason, reverses the ledger entry, and writes
an 'expense_voided' audit log entry. Status badge shows
strikethrough red.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:56:03 +07:00
grabowski 8ef2ef7465 Convert per-project spent to base currency via account FX rate
Deploy to LXC / deploy (push) Successful in 2m2s
Validate / validate (push) Successful in 36s
Both the overview and budget page queries now multiply each approved
expense amount by its account's fxRateToBase before summing. A -$434
USD expense on a USD account (rate 34.5) now contributes -14,973 THB
to the total, not -434. Expenses with no account fall back to rate 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:33:51 +07:00
grabowski ef6ba485d3 Auto-detect expense currency from the selected account
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 34s
On create: if accountId is set, use that account's currency; else fall
back to the company's base currency (not hardcoded THB).
On update: if account changes, also update the expense currency to
match, so the ledger entry posts in the right currency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:24:04 +07:00
grabowski e216a393e4 Use MutationObserver to constrain dynamically-rendered date inputs
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 29s
Previous afterNavigate hook missed inputs inside conditional blocks
that appear after user interaction (e.g. the "+ New Expense" form).
Replaced with a MutationObserver on document.body that catches every
<input type="date"> as it's added to the DOM and sets min/max.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:20:50 +07:00
grabowski 6d0fb30545 Constrain date inputs to 4-digit years (fixes yyyyyy-mm-dd display)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
Chrome/Blink renders <input type="date"> with a 6-digit year field
unless min/max attributes restrict the range. Added a root-layout
hook that auto-sets min=1900-01-01, max=2100-12-31 on every date
input on mount and after navigation — no need to edit 19 form files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:16:18 +07:00
grabowski 8376116765 Auto-resolve FX rate on account creation (base=1, else fetch from API)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 37s
Account creation no longer requires the user to enter an FX rate.
On create:
  - If account currency == company base → fxRateToBase = 1
  - Otherwise → fetchRate(accountCurrency, baseCurrency) from the
    fawazahmed0 FX API (same helper the daily scheduler uses)
  - Fallback to 1 if API call fails
The manual override field is still shown on the edit form for admin
corrections, and the daily scheduler keeps it fresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:57:08 +07:00
grabowski 7367aa9572 Add '+ New Package' button to expense detail packages section
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
Visible whenever the user can manage. Always routes to the package
creation page. The link-existing dropdown still shows when other
packages exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:21:57 +07:00
grabowski 7465b498e0 Move invoice upload + package linking to the expense detail page
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 34s
List page expenses now show a "View details →" link that routes to
the detail page. The detail page gains:
  - Invoice file upload (with Paperless push if configured)
  - Paperless URL link field
  - Link / unlink packages to the expense (many-to-many)

Same actions exist on both pages for convenience, but the detail page
is the primary workspace for managing an expense.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:31:47 +07:00
grabowski 7fba11941f Fix double-counting: available = total (expenses already debit accounts)
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 35s
The previous Remaining Budget card subtracted approved expenses from
the account balance sum — but postExpenseTransaction already posts
negative-amount rows to the ledger, so the balance sum already reflects
them. Replaced with:
  - Available Cash (= sum of account balances)
  - Allocated (with % progress bar)
  - Unallocated (cash not assigned to any project)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:22:16 +07:00
grabowski 94e38aca9c Redesign overview: income vs expenses split with net-position card
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 36s
Hero row is now a two-column green/red split showing Income and
Expenses side-by-side, with a full-width Net Position card below that
colours green or red based on the sign. Budget KPIs (Remaining,
Total, Allocated) moved to a secondary row underneath.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:11:13 +07:00
grabowski 00b8b239e0 Add expense detail page with edit (audit-logged) and clickable rows on projects
Deploy to LXC / deploy (push) Successful in 1m56s
Validate / validate (push) Successful in 33s
New expense detail at /companies/[id]/expenses/[expenseId] with full
info, edit form for admin/manager/accountant, and audit log entry on
every edit (`expense_updated`). Project view expense rows are now
clickable and navigate to the detail page. Ledger re-posts
automatically if an approved expense's amount or account changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:05:17 +07:00
grabowski 26945285eb Show base-currency equivalent next to foreign-currency account balances
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 34s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:55:37 +07:00
grabowski 2540a7603e Add Sales tab to primary company nav (admin/manager/accountant)
Deploy to LXC / deploy (push) Successful in 1m57s
Validate / validate (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:49:20 +07:00
grabowski 0795d78bdf Add confirmed-sales income to budget and overview
Budget page load now computes per-project income (net of withholding)
from confirmed sales. Overview has a full-width Income KPI showing
total confirmed net revenue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:48:06 +07:00
grabowski 1c15cbc36e Add sales CRUD with line items, taxes, withholding, and package linking
New routes: /companies/[id]/sales (list + create) and [saleId] (detail).
Per-line tax rate, single withholding % on sale. Computed totals:
subtotal, tax, gross, withholding, net receivable. Status flow:
draft → confirmed → voided. Packages linked via sale_packages junction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:46:05 +07:00
grabowski f56d4caec8 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>
2026-04-20 11:56:39 +07:00
grabowski 34aab722b4 Add expense invoice upload with Paperless push + paperless URL link
Expenses now show Pending Invoice badge when no file/link attached.
Upload action saves file via existing uploads helper, optionally
pushes to Paperless-ngx if PAPERLESS_URL + PAPERLESS_TOKEN env set.
Download endpoint serves attached invoice with attachment disposition.
Paperless URL link provides a zero-integration alternative.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:54:13 +07:00
grabowski bbfab9faaa Add expense invoice fields, sales tables, and Paperless env vars
Expenses now have invoiceFileUrl, invoiceFileName, paperlessUrl,
paperlessDocumentId for supplier invoice attachment.

New expense_packages junction links expenses to multiple packages.

New sales + sale_line_items + sale_packages tables for income tracking
with per-line tax rate and per-sale withholding rate.

Added saleStatusEnum and 4 audit events: expense_invoice_uploaded,
sale_created, sale_confirmed, sale_voided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:51:18 +07:00
24 changed files with 2450 additions and 76 deletions
+4
View File
@@ -21,3 +21,7 @@ BODY_SIZE_LIMIT=26214400
# Company Links favicon fetching (set false to disable outbound fetches in offline dev) # Company Links favicon fetching (set false to disable outbound fetches in offline dev)
FAVICON_FETCH_ENABLED=true FAVICON_FETCH_ENABLED=true
# Paperless-ngx integration (optional — leave blank to disable)
PAPERLESS_URL=
PAPERLESS_TOKEN=
+98 -1
View File
@@ -18,7 +18,7 @@ import {
// ── Enums ────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']); 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 ────────────────────────────────────────────── // ── Users ──────────────────────────────────────────────
@@ -146,6 +146,13 @@ export const expenses = pgTable(
status: expenseStatusEnum('status').notNull().default('pending'), status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }), reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'), rejectionReason: text('rejection_reason'),
// Supplier invoice attachment
invoiceFileUrl: text('invoice_file_url'),
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(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
}, },
@@ -156,6 +163,22 @@ export const expenses = pgTable(
] ]
); );
// ── Expense ↔ Packages junction ────────────────────────
export const expensePackages = pgTable(
'expense_packages',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
packageId: uuid('package_id')
.notNull()
.references((): any => packages.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [primaryKey({ columns: [table.expenseId, table.packageId] })]
);
// ── Tags ─────────────────────────────────────────────── // ── Tags ───────────────────────────────────────────────
export const tags = pgTable( export const tags = pgTable(
@@ -1145,6 +1168,74 @@ export const procedureInstanceSteps = pgTable(
] ]
); );
// ── Sales / Income ─────────────────────────────────────
export const saleStatusEnum = pgEnum('sale_status', ['draft', 'confirmed', 'voided']);
export const sales = pgTable(
'sales',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
invoiceId: uuid('invoice_id').references((): any => invoices.id, { onDelete: 'set null' }),
title: text('title').notNull(),
saleDate: date('sale_date').notNull(),
currency: text('currency').notNull().default('THB'),
withholdingTaxRate: numeric('withholding_tax_rate', { precision: 5, scale: 4 })
.notNull()
.default('0'),
notes: text('notes'),
status: saleStatusEnum('status').notNull().default('draft'),
voidedAt: timestamp('voided_at', { withTimezone: true }),
voidReason: text('void_reason'),
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('sales_company_status_idx').on(table.companyId, table.status),
index('sales_project_idx').on(table.projectId),
index('sales_date_idx').on(table.saleDate)
]
);
export const saleLineItems = pgTable(
'sale_line_items',
{
id: uuid('id').primaryKey().defaultRandom(),
saleId: uuid('sale_id')
.notNull()
.references(() => sales.id, { onDelete: 'cascade' }),
productName: text('product_name').notNull(),
description: text('description'),
quantity: numeric('quantity', { precision: 15, scale: 4 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('sale_line_items_sale_idx').on(table.saleId, table.sortOrder)]
);
export const salePackages = pgTable(
'sale_packages',
{
saleId: uuid('sale_id')
.notNull()
.references(() => sales.id, { onDelete: 'cascade' }),
packageId: uuid('package_id')
.notNull()
.references(() => packages.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [primaryKey({ columns: [table.saleId, table.packageId] })]
);
export const companyAddresses = pgTable( export const companyAddresses = pgTable(
'company_addresses', 'company_addresses',
{ {
@@ -1208,6 +1299,12 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
'invoice_sent', 'invoice_sent',
'invoice_paid', 'invoice_paid',
'invoice_voided', 'invoice_voided',
'expense_invoice_uploaded',
'expense_updated',
'expense_voided',
'sale_created',
'sale_confirmed',
'sale_voided',
'integration_connected', 'integration_connected',
'integration_disconnected', 'integration_disconnected',
'transaction_matched', 'transaction_matched',
+59
View File
@@ -0,0 +1,59 @@
import { env } from '$env/dynamic/private';
const FETCH_TIMEOUT_MS = 20_000;
export function isPaperlessEnabled(): boolean {
return Boolean(env.PAPERLESS_URL && env.PAPERLESS_TOKEN);
}
function baseUrl(): string {
const raw = (env.PAPERLESS_URL ?? '').trim();
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/**
* Upload a File blob to Paperless-ngx.
* Returns the task ID string if accepted; null on failure or if disabled.
*
* Paperless accepts multipart/form-data at /api/documents/post_document/
* and returns a task UUID (string) — the doc ID itself is assigned asynchronously
* after OCR. Callers can store the task ID as a reference.
*/
export async function uploadToPaperless(
file: File,
title?: string
): Promise<{ taskId: string } | null> {
if (!isPaperlessEnabled()) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const form = new FormData();
form.append('document', file, file.name);
if (title) form.append('title', title);
const res = await fetch(`${baseUrl()}/api/documents/post_document/`, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Token ${env.PAPERLESS_TOKEN}`
},
body: form
});
if (!res.ok) {
console.error('[paperless] upload failed', res.status, await res.text().catch(() => ''));
return null;
}
// Paperless returns a quoted task-id string in the body.
const raw = (await res.text()).trim().replace(/^"|"$/g, '');
return { taskId: raw };
} catch (err) {
console.error('[paperless] upload error', err);
return null;
} finally {
clearTimeout(timer);
}
}
@@ -31,6 +31,7 @@
{ href: `${baseUrl}/accounts`, label: 'Accounts', show: has(['admin', 'manager', 'accountant']) }, { href: `${baseUrl}/accounts`, label: 'Accounts', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/projects`, label: 'Projects', show: true }, { href: `${baseUrl}/projects`, label: 'Projects', show: true },
{ href: `${baseUrl}/expenses`, label: 'Expenses', show: true }, { href: `${baseUrl}/expenses`, label: 'Expenses', show: true },
{ href: `${baseUrl}/sales`, label: 'Sales', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/bills`, label: 'Bills', show: has(['admin', 'manager', 'accountant']) }, { href: `${baseUrl}/bills`, label: 'Bills', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) }, { href: `${baseUrl}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) },
{ href: `${baseUrl}/budget`, label: 'Budget', show: true }, { href: `${baseUrl}/budget`, label: 'Budget', show: true },
@@ -1,23 +1,30 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { projects, expenses } 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'; import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {
const { company } = await 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 const projectList = await db
.select({ .select({
id: projects.id, id: projects.id,
name: projects.name, name: projects.name,
allocatedBudget: projects.allocatedBudget, allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive, 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` pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
}) })
.from(projects) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, company.id)) .where(eq(projects.companyId, company.id))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
@@ -38,5 +45,20 @@ export const load: PageServerLoad = async ({ parent }) => {
.orderBy(sql`${expenses.createdAt} desc`) .orderBy(sql`${expenses.createdAt} desc`)
.limit(10); .limit(10);
return { projects: projectList, recentExpenses }; // Total confirmed sales income (net of withholding)
const [incomeRow] = await db
.select({
total: sql<string>`coalesce(sum(
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
* (1 - ${sales.withholdingTaxRate})
), '0')::text`
})
.from(sales)
.where(and(eq(sales.companyId, company.id), eq(sales.status, 'confirmed')));
return {
projects: projectList,
recentExpenses,
totalIncome: incomeRow?.total ?? '0'
};
}; };
@@ -8,10 +8,16 @@
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0)); const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0)); const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
const total = $derived(parseFloat(data.company.totalBudget)); const total = $derived(parseFloat(data.company.totalBudget));
const remaining = $derived(total - spent); const income = $derived(parseFloat(data.totalIncome ?? '0'));
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> = { const toneRing: Record<string, string> = {
green: 'border-green-300 dark:border-green-700', green: 'border-green-300 dark:border-green-700',
@@ -35,45 +41,64 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- KPI row --> <!-- Income vs Expenses (hero split) -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2">
<div class="rounded-lg border-2 {toneRing[tone]} bg-white p-4 dark:bg-gray-800"> <div class="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 dark:border-emerald-700 dark:bg-emerald-900/20">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="flex items-center justify-between">
Remaining <p class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
Income
</p> </p>
<p class="mt-1 text-2xl font-bold {toneText[tone]}"> <svg class="h-5 w-5 text-emerald-500 dark:text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
{formatCurrency(remaining, currency)} <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" />
</p> </svg>
<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>
</div> </div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> <p class="mt-2 text-3xl font-bold text-emerald-700 dark:text-emerald-400">
{remainingPct.toFixed(1)}% of total {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> </p>
</div> </div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"> <div class="rounded-lg border-2 border-red-300 bg-red-50 p-5 dark:border-red-700 dark:bg-red-900/20">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="flex items-center justify-between">
Total Budget <p class="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">
Expenses
</p> </p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white"> <svg class="h-5 w-5 text-red-500 dark:text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
{formatCurrency(total, currency)} <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" />
</p> </svg>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Company-wide</p>
</div> </div>
<p class="mt-2 text-3xl font-bold text-red-700 dark:text-red-400">
<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">
{formatCurrency(spent, currency)} {formatCurrency(spent, currency)}
</p> </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"> <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> </p>
</div> </div>
@@ -84,8 +109,23 @@
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white"> <p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(allocated, currency)} {formatCurrency(allocated, currency)}
</p> </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"> <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-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 {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>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { import {
companies,
companyAccounts, companyAccounts,
companyAccountTransactions, companyAccountTransactions,
externalAccounts externalAccounts
@@ -13,8 +14,26 @@ import {
postTransfer, postTransfer,
type CompanyAccountTxnType type CompanyAccountTxnType
} from '$lib/server/accounts/ledger.js'; } from '$lib/server/accounts/ledger.js';
import { fetchRate } from '$lib/server/fx/index.js';
import { and, asc, eq, isNull, sql } from 'drizzle-orm'; 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; const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number]; type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
@@ -281,6 +300,9 @@ export const actions: Actions = {
const openingBalanceDate = const openingBalanceDate =
parseDate(fd.get('openingBalanceDate')) ?? new Date(); 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 inserted = await db.transaction(async (tx) => {
const [row] = await tx const [row] = await tx
.insert(companyAccounts) .insert(companyAccounts)
@@ -306,7 +328,7 @@ export const actions: Actions = {
creditLimit: f.creditLimit, creditLimit: f.creditLimit,
statementCloseDay: f.statementCloseDay, statementCloseDay: f.statementCloseDay,
paymentDueDay: f.paymentDueDay, paymentDueDay: f.paymentDueDay,
fxRateToBase: f.fxRateToBase, fxRateToBase,
externalAccountId: f.externalAccountId externalAccountId: f.externalAccountId
}) })
.returning({ id: companyAccounts.id }); .returning({ id: companyAccounts.id });
@@ -177,6 +177,7 @@
class={inputCls} class={inputCls}
/> />
</div> </div>
{#if prefix.startsWith('edit-')}
<div> <div>
<label for="{prefix}-fxRate" class={labelCls}>FX Rate to Base</label> <label for="{prefix}-fxRate" class={labelCls}>FX Rate to Base</label>
<input <input
@@ -189,8 +190,13 @@
placeholder="1.0 for THB, 34.5 for USDTHB" placeholder="1.0 for THB, 34.5 for USDTHB"
class={inputCls} 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> </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'} {#if type === 'bank'}
<div> <div>
@@ -767,6 +773,13 @@
<p class="text-2xl font-bold {balanceClass(acct.balance)}"> <p class="text-2xl font-bold {balanceClass(acct.balance)}">
{formatAmount(acct.balance, acct.currency)} {formatAmount(acct.balance, acct.currency)}
</p> </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} {#if acct.accountType === 'credit_card' && acct.creditLimit}
{@const pct = utilisation(acct.balance, acct.creditLimit)} {@const pct = utilisation(acct.balance, acct.creditLimit)}
@@ -347,6 +347,22 @@
Counterparty: {txn.counterpartyName} Counterparty: {txn.counterpartyName}
</div> </div>
{/if} {/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} {#if txn.fxRate && txn.fxAmount}
<div class="text-xs text-amber-600 dark:text-amber-400"> <div class="text-xs text-amber-600 dark:text-amber-400">
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)} FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
@@ -7,7 +7,10 @@ import {
companies, companies,
users, users,
expenses, expenses,
companyLog companyLog,
sales,
saleLineItems,
companyAccounts
} from '$lib/server/db/schema.js'; } from '$lib/server/db/schema.js';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
@@ -17,19 +20,50 @@ import { formatCurrency } from '$lib/utils/currency.js';
export const load: PageServerLoad = async ({ parent, params }) => { export const load: PageServerLoad = async ({ parent, params }) => {
const { company } = await parent(); const { company } = await parent();
const projectList = await db const projectListRaw = await db
.select({ .select({
id: projects.id, id: projects.id,
name: projects.name, name: projects.name,
allocatedBudget: projects.allocatedBudget, 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) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, params.companyId)) .where(eq(projects.companyId, params.companyId))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
// Income per project from confirmed sales (gross - withholding = net receivable)
const incomeRows = await db
.select({
projectId: sales.projectId,
income: sql<string>`coalesce(sum(
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
* (1 - ${sales.withholdingTaxRate})
), '0')::text`
})
.from(sales)
.where(
and(
eq(sales.companyId, params.companyId),
eq(sales.status, 'confirmed')
)
)
.groupBy(sales.projectId);
const incomeByProject = new Map<string | null, string>();
for (const row of incomeRows) {
incomeByProject.set(row.projectId, row.income);
}
const projectList = projectListRaw.map((p) => ({
...p,
income: incomeByProject.get(p.id) ?? '0'
}));
const unassignedIncome = incomeByProject.get(null) ?? '0';
const allocations = await db const allocations = await db
.select({ .select({
id: budgetAllocations.id, id: budgetAllocations.id,
@@ -64,8 +98,17 @@ export const load: PageServerLoad = async ({ parent, params }) => {
.limit(100); .limit(100);
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0); const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
const totalIncome =
projectList.reduce((s, p) => s + parseFloat(p.income), 0) + parseFloat(unassignedIncome);
return { projects: projectList, allocations, totalAllocated, changelog }; return {
projects: projectList,
allocations,
totalAllocated,
totalIncome,
unassignedIncome,
changelog
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -7,8 +7,11 @@ import {
users, users,
categories, categories,
companyAccounts, companyAccounts,
companies,
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';
@@ -18,6 +21,8 @@ import {
postExpenseTransaction, postExpenseTransaction,
removeExpenseTransaction removeExpenseTransaction
} from '$lib/server/accounts/ledger.js'; } 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';
export const load: PageServerLoad = async ({ parent, params, url }) => { export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent(); await parent();
@@ -43,6 +48,9 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
accountId: expenses.accountId, accountId: expenses.accountId,
accountName: companyAccounts.name, accountName: companyAccounts.name,
invoiceId: expenses.invoiceId, invoiceId: expenses.invoiceId,
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName,
paperlessUrl: expenses.paperlessUrl,
createdAt: expenses.createdAt createdAt: expenses.createdAt
}) })
.from(expenses) .from(expenses)
@@ -110,13 +118,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
}; };
}; };
@@ -165,6 +197,29 @@ export const actions: Actions = {
.limit(1); .limit(1);
if (!proj) return fail(400, { action: 'submitExpense', error: 'Project not found' }); 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({ await db.insert(expenses).values({
projectId: resolvedProjectId, projectId: resolvedProjectId,
categoryId: categoryId || null, categoryId: categoryId || null,
@@ -174,7 +229,7 @@ export const actions: Actions = {
title, title,
description, description,
amount: Number(amountStr).toFixed(2), amount: Number(amountStr).toFixed(2),
currency: 'THB', currency: resolvedCurrency,
expenseDate, expenseDate,
status: 'pending' status: 'pending'
}); });
@@ -365,5 +420,157 @@ export const actions: Actions = {
.where(eq(expenses.id, expenseId)); .where(eq(expenses.id, expenseId));
return { success: true }; return { success: true };
},
uploadExpenseInvoice: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const file = fd.get('file') as File | null;
if (!expenseId) return fail(400, { error: 'Expense ID required' });
if (!file || !(file instanceof File) || file.size === 0) {
return fail(400, { action: 'uploadExpenseInvoice', error: 'File is required' });
}
if (file.size > MAX_BYTES) {
return fail(400, {
action: 'uploadExpenseInvoice',
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: 'uploadExpenseInvoice',
error: `File type not allowed: ${mime}`
});
}
// Verify expense belongs to this company
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, 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: 'uploadExpenseInvoice', error: 'Failed to save file' });
}
// Fire-and-forget Paperless push if configured
let paperlessTaskId: string | null = null;
if (isPaperlessEnabled()) {
const paperlessResult = await uploadToPaperless(file, exp.title);
paperlessTaskId = paperlessResult?.taskId ?? null;
}
await db
.update(expenses)
.set({
invoiceFileUrl: saved.storedPath,
invoiceFileName: file.name,
updatedAt: new Date()
})
.where(eq(expenses.id, expenseId));
await logCompanyEvent(
params.companyId,
user.id,
'expense_invoice_uploaded',
`Invoice attached to expense "${exp.title}"`,
{ expenseId, fileName: file.name, paperlessTaskId }
);
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'
]);
const fd = await request.formData();
const expenseId = fd.get('expenseId')?.toString();
const url = fd.get('paperlessUrl')?.toString().trim() || null;
if (!expenseId) return fail(400, { error: 'Expense ID required' });
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
return fail(400, {
action: 'setExpensePaperlessLink',
error: 'URL must start with http:// or https://'
});
}
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, 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, expenseId));
return { success: true, action: 'setExpensePaperlessLink' };
} }
}; };
@@ -135,8 +135,13 @@
</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)}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4"> {@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 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 class="flex items-start justify-between">
<div> <div>
<h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3> <h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3>
@@ -172,6 +177,44 @@
</p> </p>
{/if} {/if}
{/if} {/if}
<p class="mt-1 flex flex-wrap items-center gap-2 text-xs">
{#if expense.invoiceFileUrl}
<a
href={`/companies/${data.company.id}/expenses/${expense.id}/invoice`}
class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300"
>
📄 {expense.invoiceFileName ?? 'Invoice file'}
</a>
{/if}
{#if expense.paperlessUrl}
<a
href={expense.paperlessUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300"
>
🗂 Paperless
</a>
{/if}
{#if !expense.invoiceFileUrl && !expense.paperlessUrl}
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Pending invoice
</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>
<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>
@@ -253,6 +296,81 @@
</button> </button>
</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}
<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">
{expense.invoiceFileUrl || expense.paperlessUrl ? 'Manage invoice' : '+ Attach invoice'}
</summary>
<div class="mt-2 space-y-2">
<form
method="POST"
action="?/uploadExpenseInvoice"
enctype="multipart/form-data"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="inv-file-{expense.id}">File:</label>
<input id="inv-file-{expense.id}" 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-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
Upload
</button>
</form>
<form
method="POST"
action="?/setExpensePaperlessLink"
use:enhance
class="flex flex-wrap items-center gap-2 text-xs"
>
<input type="hidden" name="expenseId" value={expense.id} />
<label class="text-gray-500 dark:text-gray-400" for="pless-{expense.id}">Paperless URL:</label>
<input id="pless-{expense.id}" name="paperlessUrl" type="url"
value={expense.paperlessUrl ?? ''}
placeholder="https://paperless.example.com/documents/123"
class="flex-1 rounded 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-2 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 link
</button>
</form>
</div>
</details>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -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">&larr; 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>
@@ -0,0 +1,43 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, projects } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { readCompanyFile } from '$lib/server/uploads/index.js';
import { and, eq } from 'drizzle-orm';
export const GET: RequestHandler = async ({ locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'user', 'accountant'
]);
const [row] = await db
.select({
invoiceFileUrl: expenses.invoiceFileUrl,
invoiceFileName: expenses.invoiceFileName
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!row || !row.invoiceFileUrl) error(404, 'Invoice file not found');
let buf: Buffer;
try {
buf = await readCompanyFile(row.invoiceFileUrl);
} catch (err) {
console.error('readCompanyFile failed', err);
error(404, 'File missing on disk');
}
const safeName = (row.invoiceFileName ?? 'invoice').replace(/[\r\n"\\]/g, '_');
return new Response(new Blob([buf as BlobPart]), {
headers: {
'Content-Disposition': `attachment; filename="${safeName}"`,
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'
}
});
};
@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; 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'; import { eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => { export const load: PageServerLoad = async ({ parent }) => {
@@ -13,12 +13,13 @@ export const load: PageServerLoad = async ({ parent }) => {
description: projects.description, description: projects.description,
allocatedBudget: projects.allocatedBudget, allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive, 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`, expenseCount: sql<number>`count(${expenses.id})::int`,
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int` pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
}) })
.from(projects) .from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id)) .leftJoin(expenses, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(projects.companyId, company.id)) .where(eq(projects.companyId, company.id))
.groupBy(projects.id) .groupBy(projects.id)
.orderBy(projects.name); .orderBy(projects.name);
@@ -1,7 +1,7 @@
import { error, fail } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; 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 { eq, and, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js'; import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js'; import { logCompanyEvent } from '$lib/server/audit.js';
@@ -41,11 +41,12 @@ export const load: PageServerLoad = async ({ params, parent }) => {
const [stats] = await db const [stats] = await db
.select({ .select({
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' 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} else 0 end), 0)`, 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` count: sql<number>`count(*)::int`
}) })
.from(expenses) .from(expenses)
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where(eq(expenses.projectId, params.projectId)); .where(eq(expenses.projectId, params.projectId));
return { project, expenses: expenseList, stats }; return { project, expenses: expenseList, stats };
@@ -122,8 +122,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.expenses as expense} {#each data.expenses as expense (expense.id)}
<tr class="border-t border-gray-100 dark:border-gray-700"> <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 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 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> <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 type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; 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'; import { eq, and, sql, gte, lte } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent, params, url }) => { 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 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]; 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 // Spending by category
const byCategory = await db const byCategory = await db
.select({ .select({
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`, categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`, categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id)) .leftJoin(categories, eq(expenses.categoryId, categories.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
.select({ .select({
projectName: projects.name, projectName: projects.name,
allocated: projects.allocatedBudget, allocated: projects.allocatedBudget,
spent: sql<string>`sum(${expenses.amount})` spent: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
const byMonth = await db const byMonth = await db
.select({ .select({
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`, month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
total: sql<string>`sum(${expenses.amount})` total: sql<string>`sum(${convertedAmount})::text`
}) })
.from(expenses) .from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id)) .innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
.where( .where(
and( and(
eq(projects.companyId, params.companyId), eq(projects.companyId, params.companyId),
@@ -0,0 +1,110 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { sales, saleLineItems, parties, projects, users } from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
function trimOrNull(v: FormDataEntryValue | null): string | null {
const s = v?.toString().trim();
return s ? s : null;
}
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
await parent();
const status = url.searchParams.get('status') ?? 'all';
const whereClauses = [eq(sales.companyId, params.companyId), isNull(sales.deletedAt)];
if (status !== 'all' && ['draft', 'confirmed', 'voided'].includes(status)) {
whereClauses.push(eq(sales.status, status as 'draft' | 'confirmed' | 'voided'));
}
const salesList = await db
.select({
id: sales.id,
title: sales.title,
saleDate: sales.saleDate,
status: sales.status,
currency: sales.currency,
projectId: sales.projectId,
projectName: projects.name,
partyId: sales.partyId,
partyName: parties.name,
withholdingTaxRate: sales.withholdingTaxRate,
createdByName: users.displayName,
createdAt: sales.createdAt,
grossTotal: sql<string>`coalesce((
select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate}))::text
from sale_line_items
where sale_id = ${sales.id}
), '0')`
})
.from(sales)
.leftJoin(projects, eq(sales.projectId, projects.id))
.leftJoin(parties, eq(sales.partyId, parties.id))
.leftJoin(users, eq(sales.createdBy, users.id))
.where(and(...whereClauses))
.orderBy(desc(sales.saleDate));
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 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));
return {
sales: salesList,
statusFilter: status,
parties: partyList,
projects: projectList
};
};
export const actions: Actions = {
createSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const saleDate = trimOrNull(fd.get('saleDate'));
const projectId = trimOrNull(fd.get('projectId'));
const partyId = trimOrNull(fd.get('partyId'));
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
if (!title) return fail(400, { action: 'createSale', error: 'Title is required' });
if (!saleDate) return fail(400, { action: 'createSale', error: 'Sale date is required' });
if (withholdingTaxRate < 0 || withholdingTaxRate > 1) {
return fail(400, { action: 'createSale', error: 'Withholding rate must be 0100%' });
}
const [inserted] = await db
.insert(sales)
.values({
companyId: params.companyId,
title,
saleDate,
projectId,
partyId,
withholdingTaxRate: withholdingTaxRate.toFixed(4),
createdBy: user.id,
status: 'draft'
})
.returning({ id: sales.id });
await logCompanyEvent(params.companyId, user.id, 'sale_created',
`Sale "${title}" created`, { saleId: inserted.id });
redirect(303, `/companies/${params.companyId}/sales/${inserted.id}`);
}
};
@@ -0,0 +1,142 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddForm = $state(false);
const todayIso = new Date().toISOString().slice(0, 10);
const STATUS_BADGE: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
};
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>Sales - {data.company.name}</title>
</svelte:head>
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Sales</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Revenue events. Confirmed sales contribute to project budget.
</p>
</div>
<button type="button" onclick={() => (showAddForm = !showAddForm)}
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
{showAddForm ? 'Cancel' : '+ New Sale'}
</button>
</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 showAddForm}
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<form method="POST" action="?/createSale" use:enhance class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="md:col-span-2">
<label for="sale-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
<input id="sale-title" name="title" type="text" required class={inputCls} placeholder="e.g. 15 widgets to ABC Corp" />
</div>
<div>
<label for="sale-date" class={labelCls}>Sale Date <span class="text-red-500">*</span></label>
<input id="sale-date" name="saleDate" type="date" required value={todayIso} class={inputCls} />
</div>
<div>
<label for="sale-wht" class={labelCls}>Withholding Tax %</label>
<input id="sale-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100" value="0" class={inputCls} />
</div>
<div>
<label for="sale-project" class={labelCls}>Project</label>
<select id="sale-project" name="projectId" class={inputCls}>
<option value=""></option>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="sale-party" class={labelCls}>Customer</label>
<select id="sale-party" name="partyId" class={inputCls}>
<option value=""></option>
{#each data.parties as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Create Draft
</button>
</div>
</form>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
After creating the sale, add line items on the detail page.
</p>
</section>
{/if}
<!-- Status filter -->
<div class="flex gap-2">
{#each ['all', 'draft', 'confirmed', 'voided'] as s (s)}
<a href="?status={s}"
class="rounded-full px-3 py-1 text-xs font-medium {data.statusFilter === s
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'}">
{s.charAt(0).toUpperCase() + s.slice(1)}
</a>
{/each}
</div>
{#if data.sales.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
<p class="text-sm text-gray-500 dark:text-gray-400">No sales yet.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Title</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Customer</th>
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Gross</th>
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.sales as sale (sale.id)}
<tr class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40"
onclick={() => (window.location.href = `/companies/${data.company.id}/sales/${sale.id}`)}>
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">{sale.title}</div>
{#if sale.projectName}
<div class="text-xs text-gray-500 dark:text-gray-400">Project: {sale.projectName}</div>
{/if}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.saleDate}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.partyName ?? '—'}</td>
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
{formatCurrency(sale.grossTotal, sale.currency)}
</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[sale.status]}">
{sale.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,297 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
sales,
saleLineItems,
salePackages,
parties,
projects,
packages,
invoices
} from '$lib/server/db/schema.js';
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
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', 'accountant']);
await parent();
const [sale] = await db
.select()
.from(sales)
.where(
and(
eq(sales.id, params.saleId),
eq(sales.companyId, params.companyId),
isNull(sales.deletedAt)
)
)
.limit(1);
if (!sale) error(404, 'Sale not found');
const lineItems = await db
.select()
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId))
.orderBy(asc(saleLineItems.sortOrder), asc(saleLineItems.createdAt));
const linkedPkgRows = await db
.select({
packageId: salePackages.packageId,
trackingNumber: packages.trackingNumber,
carrier: packages.carrier,
status: packages.status,
direction: packages.direction
})
.from(salePackages)
.innerJoin(packages, eq(salePackages.packageId, packages.id))
.where(eq(salePackages.saleId, params.saleId));
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(sql`${packages.createdAt} desc`);
const [party] = sale.partyId
? await db
.select({ id: parties.id, name: parties.name })
.from(parties)
.where(eq(parties.id, sale.partyId))
.limit(1)
: [null];
const [project] = sale.projectId
? await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.id, sale.projectId))
.limit(1)
: [null];
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 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 invoiceList = await db
.select({
id: invoices.id,
invoiceNumber: invoices.invoiceNumber,
direction: invoices.direction
})
.from(invoices)
.where(
and(
eq(invoices.companyId, params.companyId),
eq(invoices.direction, 'outgoing'),
ne(invoices.status, 'voided')
)
)
.orderBy(asc(invoices.invoiceNumber));
return {
sale,
lineItems,
linkedPackages: linkedPkgRows,
availablePackages,
party,
project,
parties: partyList,
projects: projectList,
invoices: invoiceList
};
};
export const actions: Actions = {
updateSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const fd = await request.formData();
const title = trimOrNull(fd.get('title'));
const saleDate = trimOrNull(fd.get('saleDate'));
const projectId = trimOrNull(fd.get('projectId'));
const partyId = trimOrNull(fd.get('partyId'));
const invoiceId = trimOrNull(fd.get('invoiceId'));
const notes = trimOrNull(fd.get('notes'));
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
if (!title) return fail(400, { action: 'updateSale', error: 'Title is required' });
if (!saleDate) return fail(400, { action: 'updateSale', error: 'Date is required' });
await db
.update(sales)
.set({
title,
saleDate,
projectId,
partyId,
invoiceId,
notes,
withholdingTaxRate: withholdingTaxRate.toFixed(4),
updatedAt: new Date()
})
.where(
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
);
return { success: true, action: 'updateSale' };
},
addLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const productName = trimOrNull(fd.get('productName'));
const description = trimOrNull(fd.get('description'));
const quantity = fd.get('quantity')?.toString().trim();
const unitPrice = fd.get('unitPrice')?.toString().trim();
const taxPct = fd.get('taxRate')?.toString().trim();
if (!productName) return fail(400, { action: 'addLineItem', error: 'Product name required' });
if (!quantity || Number(quantity) <= 0) return fail(400, { action: 'addLineItem', error: 'Valid quantity required' });
if (!unitPrice || Number(unitPrice) < 0) return fail(400, { action: 'addLineItem', error: 'Valid unit price required' });
const taxRate = taxPct ? Number(taxPct) / 100 : 0;
const [maxRow] = await db
.select({ max: sql<number>`coalesce(max(${saleLineItems.sortOrder}), -1)::int` })
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId));
await db.insert(saleLineItems).values({
saleId: params.saleId,
productName,
description,
quantity: Number(quantity).toFixed(4),
unitPrice: Number(unitPrice).toFixed(2),
taxRate: taxRate.toFixed(4),
sortOrder: (maxRow?.max ?? -1) + 1
});
return { success: true, action: 'addLineItem' };
},
removeLineItem: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const itemId = trimOrNull(fd.get('itemId'));
if (!itemId) return fail(400, { action: 'removeLineItem', error: 'Item id required' });
await db
.delete(saleLineItems)
.where(and(eq(saleLineItems.id, itemId), eq(saleLineItems.saleId, params.saleId)));
return { success: true, action: 'removeLineItem' };
},
confirmSale: async ({ locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
'admin', 'manager', 'accountant'
]);
const [lineCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(saleLineItems)
.where(eq(saleLineItems.saleId, params.saleId));
if (!lineCount || lineCount.count === 0) {
return fail(400, { action: 'confirmSale', error: 'Add at least one line item before confirming' });
}
const [updated] = await db
.update(sales)
.set({ status: 'confirmed', updatedAt: new Date() })
.where(
and(
eq(sales.id, params.saleId),
eq(sales.companyId, params.companyId),
eq(sales.status, 'draft')
)
)
.returning({ title: sales.title });
if (!updated) return fail(400, { action: 'confirmSale', error: 'Sale not found or not in draft' });
await logCompanyEvent(params.companyId, user.id, 'sale_confirmed',
`Sale "${updated.title}" confirmed`, { saleId: params.saleId });
return { success: true, action: 'confirmSale' };
},
voidSale: async ({ request, locals, params }) => {
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
const fd = await request.formData();
const reason = trimOrNull(fd.get('reason'));
if (!reason) return fail(400, { action: 'voidSale', error: 'Void reason is required' });
const [updated] = await db
.update(sales)
.set({
status: 'voided',
voidedAt: new Date(),
voidReason: reason,
updatedAt: new Date()
})
.where(
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
)
.returning({ title: sales.title });
if (!updated) return fail(404, { action: 'voidSale', error: 'Sale not found' });
await logCompanyEvent(params.companyId, user.id, 'sale_voided',
`Sale "${updated.title}" voided: ${reason}`,
{ saleId: params.saleId, reason });
return { success: true, action: 'voidSale' };
},
linkPackage: async ({ request, locals, params }) => {
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
const fd = await request.formData();
const packageId = trimOrNull(fd.get('packageId'));
if (!packageId) return fail(400, { error: 'Package id required' });
await db
.insert(salePackages)
.values({ saleId: params.saleId, 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 packageId = trimOrNull(fd.get('packageId'));
if (!packageId) return fail(400, { error: 'Package id required' });
await db
.delete(salePackages)
.where(and(eq(salePackages.saleId, params.saleId), eq(salePackages.packageId, packageId)));
return { success: true, action: 'unlinkPackage' };
}
};
@@ -0,0 +1,327 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showAddItem = $state(false);
let editingMeta = $state(false);
let showVoidForm = $state(false);
const STATUS_BADGE: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
voided: 'bg-red-200 text-red-800 dark:bg-red-900/50 dark:text-red-300'
};
const isLive = $derived(data.sale.status === 'draft');
const totals = $derived.by(() => {
let subtotal = 0;
let tax = 0;
for (const li of data.lineItems) {
const lineNet = Number(li.quantity) * Number(li.unitPrice);
subtotal += lineNet;
tax += lineNet * Number(li.taxRate);
}
const gross = subtotal + tax;
const withholding = gross * Number(data.sale.withholdingTaxRate);
const net = gross - withholding;
return { subtotal, tax, gross, withholding, net };
});
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.sale.title} - Sales</title>
</svelte:head>
<div class="space-y-6">
<header>
<a href={`/companies/${data.company.id}/sales`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">&larr; Sales</a>
<div class="mt-1 flex items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.sale.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.sale.status]}">
{data.sale.status}
</span>
<span>{data.sale.saleDate}</span>
{#if data.party}<span>Customer: {data.party.name}</span>{/if}
{#if data.project}<span>Project: {data.project.name}</span>{/if}
</div>
</div>
<button type="button" onclick={() => (editingMeta = !editingMeta)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200">
{editingMeta ? 'Cancel' : 'Edit'}
</button>
</div>
{#if data.sale.status === 'voided' && data.sale.voidReason}
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
<span class="text-red-600 dark:text-red-400">{data.sale.voidReason}</span>
</div>
{/if}
</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 editingMeta}
<form method="POST" action="?/updateSale"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editingMeta = 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="s-title" class={labelCls}>Title</label>
<input id="s-title" name="title" type="text" required value={data.sale.title} class={inputCls} />
</div>
<div>
<label for="s-date" class={labelCls}>Date</label>
<input id="s-date" name="saleDate" type="date" required value={data.sale.saleDate} class={inputCls} />
</div>
<div>
<label for="s-wht" class={labelCls}>Withholding %</label>
<input id="s-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100"
value={(Number(data.sale.withholdingTaxRate) * 100).toString()} class={inputCls} />
</div>
<div>
<label for="s-project" class={labelCls}>Project</label>
<select id="s-project" name="projectId" value={data.sale.projectId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.projects as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="s-party" class={labelCls}>Customer</label>
<select id="s-party" name="partyId" value={data.sale.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="s-invoice" class={labelCls}>Outgoing Invoice</label>
<select id="s-invoice" name="invoiceId" value={data.sale.invoiceId ?? ''} class={inputCls}>
<option value=""></option>
{#each data.invoices as inv (inv.id)}
<option value={inv.id}>{inv.invoiceNumber}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<label for="s-notes" class={labelCls}>Notes</label>
<textarea id="s-notes" name="notes" rows="2" class={inputCls}>{data.sale.notes ?? ''}</textarea>
</div>
<div class="md:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
</div>
</form>
{/if}
<!-- Line items -->
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-gray-700">
<h2 class="font-semibold text-gray-900 dark:text-white">Line Items</h2>
{#if isLive}
<button type="button" onclick={() => (showAddItem = !showAddItem)}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
{showAddItem ? 'Cancel' : '+ Add item'}
</button>
{/if}
</div>
{#if showAddItem && isLive}
<form method="POST" action="?/addLineItem"
use:enhance={() => async ({ result, update, formElement }) => {
await update({ reset: false });
if (result.type === 'success') { showAddItem = false; formElement.reset(); }
}}
class="grid grid-cols-1 gap-3 border-b border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-700/30 md:grid-cols-4">
<div class="md:col-span-2">
<label class={labelCls} for="li-product">Product</label>
<input id="li-product" name="productName" type="text" required class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-qty">Quantity</label>
<input id="li-qty" name="quantity" type="number" step="0.0001" min="0.0001" required value="1" class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-price">Unit Price</label>
<input id="li-price" name="unitPrice" type="number" step="0.01" min="0" required class={inputCls} />
</div>
<div>
<label class={labelCls} for="li-tax">Tax %</label>
<input id="li-tax" name="taxRate" type="number" step="0.01" min="0" max="100" value="7" class={inputCls} />
</div>
<div class="md:col-span-3">
<label class={labelCls} for="li-desc">Description</label>
<input id="li-desc" name="description" type="text" class={inputCls} />
</div>
<div class="md:col-span-4 flex justify-end">
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
Add
</button>
</div>
</form>
{/if}
{#if data.lineItems.length === 0}
<p class="p-4 text-sm text-gray-500 dark:text-gray-400">No line items yet.</p>
{:else}
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/50">
<tr>
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Product</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Qty</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Unit</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Tax %</th>
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Line Total</th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.lineItems as li (li.id)}
{@const lineNet = Number(li.quantity) * Number(li.unitPrice)}
{@const lineGross = lineNet * (1 + Number(li.taxRate))}
<tr>
<td class="px-4 py-2">
<div class="font-medium text-gray-900 dark:text-white">{li.productName}</div>
{#if li.description}<div class="text-xs text-gray-500">{li.description}</div>{/if}
</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{Number(li.quantity)}</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{formatCurrency(li.unitPrice, data.sale.currency)}</td>
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{(Number(li.taxRate) * 100).toFixed(1)}%</td>
<td class="px-4 py-2 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(lineGross.toFixed(2), data.sale.currency)}</td>
<td class="px-4 py-2 text-right">
{#if isLive}
<form method="POST" action="?/removeLineItem" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="itemId" value={li.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-700 dark:text-red-400">Remove</button>
</form>
{/if}
</td>
</tr>
{/each}
</tbody>
<tfoot class="bg-gray-50 dark:bg-gray-700/50">
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Subtotal</td>
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.subtotal.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Tax</td>
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.tax.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Gross</td>
<td class="px-4 py-2 text-right font-semibold text-gray-900 dark:text-white">{formatCurrency(totals.gross.toFixed(2), data.sale.currency)}</td>
<td></td>
</tr>
{#if Number(data.sale.withholdingTaxRate) > 0}
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-medium text-red-600 dark:text-red-400">
Withholding ({(Number(data.sale.withholdingTaxRate) * 100).toFixed(2)}%)
</td>
<td class="px-4 py-2 text-right text-red-600 dark:text-red-400">
-{formatCurrency(totals.withholding.toFixed(2), data.sale.currency)}
</td>
<td></td>
</tr>
<tr class="text-sm">
<td colspan="4" class="px-4 py-2 text-right font-bold text-gray-900 dark:text-white">Net Receivable</td>
<td class="px-4 py-2 text-right font-bold text-green-600 dark:text-green-400">
{formatCurrency(totals.net.toFixed(2), data.sale.currency)}
</td>
<td></td>
</tr>
{/if}
</tfoot>
</table>
{/if}
</section>
<!-- Packages -->
<section 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">Linked Packages</h2>
{#if data.linkedPackages.length > 0}
<div class="mb-3 flex flex-wrap gap-2">
{#each data.linkedPackages as pkg (pkg.packageId)}
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-xs font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
📦 {pkg.trackingNumber} ({pkg.carrier})
{#if isLive}
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
<input type="hidden" name="packageId" value={pkg.packageId} />
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
</form>
{/if}
</span>
{/each}
</div>
{/if}
{#if isLive && data.availablePackages.length > 0}
<form method="POST" action="?/linkPackage" use:enhance class="flex items-center gap-2 text-sm">
<select name="packageId" required class={inputCls + ' flex-1'}>
<option value="" disabled selected>Select package to link</option>
{#each data.availablePackages as pkg (pkg.id)}
{#if !data.linkedPackages.find((l) => l.packageId === pkg.id)}
<option value={pkg.id}>{pkg.trackingNumber} {pkg.carrier} ({pkg.direction})</option>
{/if}
{/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>
{/if}
</section>
<!-- Actions -->
{#if isLive}
<div class="flex gap-3">
<form method="POST" action="?/confirmSale" use:enhance>
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
Confirm Sale
</button>
</form>
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-4 py-2 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 Sale
</button>
</div>
{/if}
{#if data.sale.status === 'confirmed'}
<div class="flex">
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
class="rounded-md border border-red-300 px-4 py-2 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 Sale
</button>
</div>
{/if}
{#if showVoidForm}
<form method="POST" action="?/voidSale"
use:enhance={() => async ({ update }) => { await update({ reset: false }); showVoidForm = false; }}
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
<label for="void-reason" class={labelCls}>Void Reason <span class="text-red-500">*</span></label>
<textarea id="void-reason" name="reason" rows="2" required 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}
</div>
+28
View File
@@ -1,7 +1,35 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { onMount } from 'svelte';
let { children } = $props(); 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> </script>
{@render children()} {@render children()}