Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2540a7603e | |||
| 0795d78bdf | |||
| 1c15cbc36e | |||
| f56d4caec8 | |||
| 34aab722b4 | |||
| bbfab9faaa |
@@ -21,3 +21,7 @@ BODY_SIZE_LIMIT=26214400
|
||||
|
||||
# Company Links favicon fetching (set false to disable outbound fetches in offline dev)
|
||||
FAVICON_FETCH_ENABLED=true
|
||||
|
||||
# Paperless-ngx integration (optional — leave blank to disable)
|
||||
PAPERLESS_URL=
|
||||
PAPERLESS_TOKEN=
|
||||
|
||||
@@ -146,6 +146,11 @@ export const expenses = pgTable(
|
||||
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||
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'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
@@ -156,6 +161,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 ───────────────────────────────────────────────
|
||||
|
||||
export const tags = pgTable(
|
||||
@@ -1145,6 +1166,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(
|
||||
'company_addresses',
|
||||
{
|
||||
@@ -1208,6 +1297,10 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'invoice_sent',
|
||||
'invoice_paid',
|
||||
'invoice_voided',
|
||||
'expense_invoice_uploaded',
|
||||
'sale_created',
|
||||
'sale_confirmed',
|
||||
'sale_voided',
|
||||
'integration_connected',
|
||||
'integration_disconnected',
|
||||
'transaction_matched',
|
||||
|
||||
@@ -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}/projects`, label: 'Projects', 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}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) },
|
||||
{ href: `${baseUrl}/budget`, label: 'Budget', show: true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses } from '$lib/server/db/schema.js';
|
||||
import { projects, expenses, sales, saleLineItems } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
@@ -38,5 +38,20 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
.orderBy(sql`${expenses.createdAt} desc`)
|
||||
.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,6 +8,7 @@
|
||||
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 total = $derived(parseFloat(data.company.totalBudget));
|
||||
const income = $derived(parseFloat(data.totalIncome ?? '0'));
|
||||
const remaining = $derived(total - spent);
|
||||
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
|
||||
|
||||
@@ -88,6 +89,16 @@
|
||||
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-emerald-300 bg-white p-4 dark:border-emerald-700 dark:bg-gray-800 lg:col-span-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-500 dark:text-emerald-400">
|
||||
Income (from confirmed sales)
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{formatCurrency(income, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Net of withholding tax</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
companies,
|
||||
users,
|
||||
expenses,
|
||||
companyLog
|
||||
companyLog,
|
||||
sales,
|
||||
saleLineItems
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { requireCompanyRole } from '$lib/server/authorization.js';
|
||||
@@ -17,7 +19,7 @@ import { formatCurrency } from '$lib/utils/currency.js';
|
||||
export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
const { company } = await parent();
|
||||
|
||||
const projectList = await db
|
||||
const projectListRaw = await db
|
||||
.select({
|
||||
id: projects.id,
|
||||
name: projects.name,
|
||||
@@ -30,6 +32,36 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
.groupBy(projects.id)
|
||||
.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
|
||||
.select({
|
||||
id: budgetAllocations.id,
|
||||
@@ -64,8 +96,17 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
.limit(100);
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
categories,
|
||||
companyAccounts,
|
||||
invoices,
|
||||
parties
|
||||
parties,
|
||||
packages,
|
||||
expensePackages
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
@@ -18,6 +20,8 @@ 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';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
await parent();
|
||||
@@ -43,6 +47,9 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
invoiceId: expenses.invoiceId,
|
||||
invoiceFileUrl: expenses.invoiceFileUrl,
|
||||
invoiceFileName: expenses.invoiceFileName,
|
||||
paperlessUrl: expenses.paperlessUrl,
|
||||
createdAt: expenses.createdAt
|
||||
})
|
||||
.from(expenses)
|
||||
@@ -110,13 +117,37 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
)
|
||||
.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 {
|
||||
expenses: expenseList,
|
||||
statusFilter: status,
|
||||
accounts: accountsList,
|
||||
projects: projectList,
|
||||
categories: categoryList,
|
||||
invoices: invoiceList
|
||||
invoices: invoiceList,
|
||||
packages: packageList,
|
||||
expensePackageLinks
|
||||
};
|
||||
};
|
||||
|
||||
@@ -365,5 +396,157 @@ export const actions: Actions = {
|
||||
.where(eq(expenses.id, expenseId));
|
||||
|
||||
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,7 +135,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.expenses as expense}
|
||||
{#each data.expenses as expense (expense.id)}
|
||||
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -172,6 +173,44 @@
|
||||
</p>
|
||||
{/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 class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -253,6 +292,81 @@
|
||||
</button>
|
||||
</form>
|
||||
{/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>
|
||||
{/each}
|
||||
</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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 0–100%' });
|
||||
}
|
||||
|
||||
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">← 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>
|
||||
Reference in New Issue
Block a user