price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
+62
View File
@@ -0,0 +1,62 @@
// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10), plus price adjustments (§6).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ApprovalConfig,
CreatePriceAdjustmentRequest,
ItemPriceHistory,
PendingApprovals,
PriceAdjustment,
PriceAdjustmentStatus,
PriceAdjustmentSummary,
UpdateApprovalConfigRequest,
} from "@/types/approvals"
export const approvalsApi = {
/** What you can approve, plus your own requests that are waiting for others. */
pending(): Promise<PendingApprovals> {
return apiRequest<PendingApprovals>("/approvals/pending")
},
getConfig(): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config")
},
/** Needs settings.approvals. */
updateConfig(request: UpdateApprovalConfigRequest): Promise<ApprovalConfig> {
return apiRequest<ApprovalConfig>("/approvals/config", { method: "PUT", body: request })
},
}
export const priceAdjustmentsApi = {
list(params: { page?: number; pageSize?: number; q?: string; status?: PriceAdjustmentStatus; itemId?: number } = {}): Promise<PagedResponse<PriceAdjustmentSummary>> {
return apiRequest<PagedResponse<PriceAdjustmentSummary>>(`/price-adjustments${buildQuery(params)}`)
},
get(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}`)
},
/** 409 PRICE_ADJUSTMENT_ITEM_PENDING when an item already has a pending request. */
create(request: CreatePriceAdjustmentRequest): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>("/price-adjustments", { method: "POST", body: request })
},
/** The final approval applies the prices; 409 PRICE_ADJUSTMENT_STALE if an item was repriced meanwhile. */
approve(priceAdjustmentId: number, comment?: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(priceAdjustmentId: number, reason: string): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(priceAdjustmentId: number): Promise<PriceAdjustment> {
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/cancel`, { method: "POST" })
},
itemHistory(itemId: number): Promise<ItemPriceHistory[]> {
return apiRequest<ItemPriceHistory[]>(`/items/${itemId}/price-history`)
},
}
@@ -0,0 +1,43 @@
// Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3). create() posts GL (Debit bank /
// Credit cash) and is rejected above the cash in hand; cancel() posts the reversing entry.
import { apiRequest, buildQuery } from "@/lib/api-client"
import {
BankDeposit,
BankDepositList,
BankDepositListFilterParams,
CashInHand,
CashInHandDaily,
CreateBankDepositRequest,
} from "@/types/bank-deposits"
export const bankDepositsApi = {
list(params: BankDepositListFilterParams = {}): Promise<BankDepositList> {
return apiRequest<BankDepositList>(`/bank-deposits${buildQuery(params)}`)
},
get(bankDepositId: number): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}`)
},
/** 400 BANK_DEPOSIT_EXCEEDS_CASH_IN_HAND when the amount is more than can still be deposited for that day. */
create(request: CreateBankDepositRequest): Promise<BankDeposit> {
return apiRequest<BankDeposit>("/bank-deposits", { method: "POST", body: request })
},
/** Posted only; puts the cash back in hand. */
cancel(bankDepositId: number, reason: string): Promise<BankDeposit> {
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}/cancel`, { method: "POST", body: { reason } })
},
/** `date` omitted ⇒ today in the business time zone. `branchId` is ignored for branch users. */
cashInHand(params: { date?: string; branchId?: number | null } = {}): Promise<CashInHand> {
return apiRequest<CashInHand>(`/bank-deposits/cash-in-hand${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
/** At most 92 days. */
cashInHandDaily(params: { from: string; to: string; branchId?: number | null }): Promise<CashInHandDaily> {
return apiRequest<CashInHandDaily>(
`/bank-deposits/cash-in-hand/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
@@ -0,0 +1,17 @@
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Read-only: one day's sales,
// payments by method, expenses and income, and the same day-wise over a range (≤ 92 days).
import { apiRequest, buildQuery } from "@/lib/api-client"
import { DayEndDailyReport, DayEndReport } from "@/types/sales"
export const dayEndReportApi = {
/** `date` omitted ⇒ today in the report's time zone. `branchId` is ignored for non-admins. */
getDay(params: { date?: string; branchId?: number | null } = {}): Promise<DayEndReport> {
return apiRequest<DayEndReport>(`/day-end-report${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
},
getDaily(params: { from: string; to: string; branchId?: number | null }): Promise<DayEndDailyReport> {
return apiRequest<DayEndDailyReport>(
`/day-end-report/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
)
},
}
+93
View File
@@ -0,0 +1,93 @@
// One typed client method per Expense endpoint (docs/EXPENSE_MODULE_PLAN.md §6). Draft
// expenses are editable via update(); post() accrues to GL, pay()/postAndPay() settle it.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreateExpensePaymentRequest,
CreateExpenseRequest,
CreateExpenseTitleRequest,
Expense,
ExpenseCategory,
ExpenseListFilterParams,
ExpensePayment,
ExpenseSummary,
ExpenseSummaryReport,
ExpenseTitle,
UpdateExpenseCategoryRequest,
UpdateExpenseRequest,
UpdateExpenseTitleRequest,
} from "@/types/expenses"
export const expenseSetupApi = {
/** Categories with their titles, in display order. Inactive rows only when asked for. */
list(includeInactive = false): Promise<ExpenseCategory[]> {
return apiRequest<ExpenseCategory[]>(`/expense-categories${buildQuery({ includeInactive: includeInactive || undefined })}`)
},
/** Categories are fixed (no create); in practice only the active flag changes — they're all built-in. */
updateCategory(expenseCategoryId: number, request: UpdateExpenseCategoryRequest): Promise<ExpenseCategory> {
return apiRequest<ExpenseCategory>(`/expense-categories/${expenseCategoryId}`, { method: "PUT", body: request })
},
createTitle(expenseCategoryId: number, request: CreateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-categories/${expenseCategoryId}/titles`, { method: "POST", body: request })
},
/** 409 EXPENSE_SYSTEM_ROW_READONLY when renaming a built-in title (GL code and status stay editable). */
updateTitle(expenseTitleId: number, request: UpdateExpenseTitleRequest): Promise<ExpenseTitle> {
return apiRequest<ExpenseTitle>(`/expense-titles/${expenseTitleId}`, { method: "PUT", body: request })
},
}
export const expensesApi = {
list(params: ExpenseListFilterParams = {}): Promise<PagedResponse<ExpenseSummary>> {
return apiRequest<PagedResponse<ExpenseSummary>>(`/expenses${buildQuery(params)}`)
},
/** Posted expenses dated in [from, to] (default: this month), plus the unpaid/overdue position. */
summary(params: { from?: string; to?: string } = {}): Promise<ExpenseSummaryReport> {
return apiRequest<ExpenseSummaryReport>(`/expenses/summary${buildQuery(params)}`)
},
get(expenseId: number): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`)
},
create(request: CreateExpenseRequest): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>("/expenses", { method: "POST", body: request })
},
/** Draft only (409 EXPENSE_NOT_DRAFT otherwise). Echo the ETag from get()/create() as ifMatch. */
update(expenseId: number, request: UpdateExpenseRequest, ifMatch: string): Promise<ApiResult<Expense>> {
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`, { method: "PUT", body: request, ifMatch })
},
/** Draft only. */
remove(expenseId: number): Promise<void> {
return apiRequest<void>(`/expenses/${expenseId}`, { method: "DELETE" })
},
/** Accrue to GL: Debit the title's expense account / Credit Accrued Expenses. */
post(expenseId: number): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post`, { method: "POST" })
},
/** Draft only; posts and pays in one transaction — full or partial (the rest stays as the balance). */
postAndPay(expenseId: number, request: CreateExpensePaymentRequest): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/post-and-pay`, { method: "POST", body: request })
},
/** Draft, or Posted with no payments (posts a reversing GL entry). */
cancel(expenseId: number, reason: string): Promise<Expense> {
return apiRequest<Expense>(`/expenses/${expenseId}/cancel`, { method: "POST", body: { reason } })
},
/** Posted only; total capped at the balance. Returns one row per payment line. */
pay(expenseId: number, request: CreateExpensePaymentRequest): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`, { method: "POST", body: request })
},
listPayments(expenseId: number): Promise<ExpensePayment[]> {
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`)
},
}
+6
View File
@@ -8,6 +8,7 @@ import {
ItemImage,
ItemListItem,
ItemOverview,
ItemPurchasePrice,
ItemReorderSetting,
ItemScanResult,
StockNature,
@@ -95,6 +96,11 @@ export const itemsApi = {
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
},
/** Latest posted GRN receipts of the item (newest first) — what it was bought at. */
purchasePrices(itemId: number, take = 5): Promise<ItemPurchasePrice[]> {
return apiRequest<ItemPurchasePrice[]>(`/items/${itemId}/purchase-prices${buildQuery({ take })}`)
},
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
method: "PUT",
@@ -1,8 +1,9 @@
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
// Auto-posts on create with a mandatory reason code (FR-STK-07).
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). Creating
// submits a request with a mandatory reason code; it moves stock once approved
// (docs/DUAL_APPROVAL_PLAN.md §5), or at once when approvals are switched off.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
export interface ListAdjustmentsParams {
page?: number
@@ -11,6 +12,7 @@ export interface ListAdjustmentsParams {
warehouseId?: number
branchId?: number
reasonCodeId?: number
status?: AdjustmentStatus
sort?: string
}
@@ -30,4 +32,18 @@ export const stockAdjustmentsApi = {
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
},
/** Next approval; the final one posts (409 STOCK_NEGATIVE_BLOCKED if stock no longer allows it). */
approve(adjustmentId: number, comment?: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/approve`, { method: "POST", body: { comment } })
},
reject(adjustmentId: number, reason: string): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/reject`, { method: "POST", body: { reason } })
},
/** Requester only, while pending. */
cancel(adjustmentId: number): Promise<StockAdjustment> {
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/cancel`, { method: "POST" })
},
}
+7 -3
View File
@@ -7,7 +7,7 @@
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { stockApi } from "@/lib/api/stock"
import { StockAdjustment } from "@/types/stock"
import { AdjustmentStatus, StockAdjustment } from "@/types/stock"
/**
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
@@ -37,8 +37,10 @@ export interface WastageRecord {
reasonCodeId: number
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
qty: number
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted (0 until posted). */
value: number
/** Posted, or still waiting for approval (rejected/withdrawn requests are left out). */
status: AdjustmentStatus
}
export interface ListWastageParams {
@@ -81,7 +83,8 @@ export const wastageApi = {
page: params.page,
pageSize: params.pageSize ?? 50,
})
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
// A rejected or withdrawn request never wrote anything off.
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
const detailed = await Promise.all(
summaries.map(async (summary) => {
@@ -107,6 +110,7 @@ export const wastageApi = {
reasonCodeId: adjustment.reasonCodeId,
qty: Math.abs(line.qtyDelta),
value: Math.round(value * 100) / 100,
status: adjustment.status,
}
})
}),
+12
View File
@@ -15,6 +15,18 @@ export function formatAmount(value: number | null | undefined, zeroDash = false)
return value < 0 ? `(${formatted})` : formatted
}
/** Like {@link formatAmount} but a negative keeps a leading minus instead of parentheses: `-1234.5` -> "-1,234.50". */
export function formatSignedAmount(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
return value < 0 ? `-${AMOUNT_FORMATTER.format(Math.abs(value))}` : AMOUNT_FORMATTER.format(value)
}
/** A profit figure as words + amount: `1200` -> { label: "Profit", amount: "1,200.00" }, `-300` -> { label: "Loss", amount: "300.00" }. */
export function profitOrLoss(value: number): { label: "Profit" | "Loss"; amount: string; isLoss: boolean } {
const isLoss = value < 0
return { label: isLoss ? "Loss" : "Profit", amount: formatAmount(Math.abs(value)), isLoss }
}
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
export function formatReportDate(value: string | null | undefined): string {
if (!value) return "—"
@@ -0,0 +1,428 @@
// Day-End Sales Report PDFs (docs/DAY_END_SALES_REPORT_PLAN.md §6) — built in the browser with
// jsPDF (the backend has no PDF library). The table is drawn directly rather than through
// jspdf-autotable, so no extra dependency; the chart is rendered on an offscreen canvas in light
// colours, so the printout never depends on the on-screen theme.
import type { jsPDF as JsPdf } from "jspdf"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { maskAccountNumber } from "@/types/bank-deposits"
import { DayEndDailyReport, DayEndReport, DayEndReportDay } from "@/types/sales"
const PAGE = { width: 210, height: 297, margin: 14 }
const BOTTOM = PAGE.height - 16
type Column = { header: string; width: number; align?: "left" | "right" }
async function newDoc(): Promise<JsPdf> {
const { jsPDF } = await import("jspdf")
return new jsPDF({ unit: "mm", format: "a4" })
}
/** Title block; returns the y where content starts. */
function header(doc: JsPdf, title: string, subtitle: string, branchLabel: string): number {
doc.setFont("helvetica", "bold")
doc.setFontSize(16)
doc.text(title, PAGE.margin, 20)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.setTextColor(90)
doc.text(subtitle, PAGE.margin, 27)
doc.text(`Branch: ${branchLabel}`, PAGE.margin, 32)
doc.text(`Generated ${new Date().toLocaleString()}`, PAGE.width - PAGE.margin, 32, { align: "right" })
doc.setTextColor(0)
doc.setDrawColor(200)
doc.line(PAGE.margin, 35, PAGE.width - PAGE.margin, 35)
return 42
}
function footer(doc: JsPdf) {
const pages = doc.getNumberOfPages()
for (let i = 1; i <= pages; i++) {
doc.setPage(i)
doc.setFontSize(8)
doc.setTextColor(120)
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
doc.setTextColor(0)
}
}
/** Label/value pairs in a grid of boxes. */
function summaryGrid(doc: JsPdf, y: number, items: [string, string][], perRow = 4): number {
const gap = 3
const w = (PAGE.width - PAGE.margin * 2 - gap * (perRow - 1)) / perRow
const h = 14
items.forEach(([label, value], i) => {
const x = PAGE.margin + (i % perRow) * (w + gap)
const top = y + Math.floor(i / perRow) * (h + gap)
doc.setDrawColor(215)
doc.roundedRect(x, top, w, h, 1.5, 1.5)
doc.setFontSize(8)
doc.setTextColor(100)
doc.text(label, x + 2.5, top + 5)
doc.setFontSize(11)
doc.setTextColor(0)
doc.setFont("helvetica", "bold")
doc.text(value, x + 2.5, top + 11)
doc.setFont("helvetica", "normal")
})
return y + Math.ceil(items.length / perRow) * (h + gap) + 2
}
function note(doc: JsPdf, y: number, text: string): number {
doc.setFontSize(9)
doc.setTextColor(110)
doc.text(text, PAGE.margin, y + 4)
doc.setTextColor(0)
return y + 8
}
function sectionTitle(doc: JsPdf, y: number, text: string): number {
doc.setFont("helvetica", "bold")
doc.setFontSize(11)
doc.text(text, PAGE.margin, y)
doc.setFont("helvetica", "normal")
return y + 4
}
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
function fit(doc: JsPdf, text: string, maxWidth: number): string {
if (doc.getTextWidth(text) <= maxWidth) return text
let cut = text
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
return `${cut}...`
}
/** A table that breaks across pages, repeating its header; `totals` is a bold last row. */
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][], totals?: string[]): number {
const rowH = 6.5
const drawHeader = (top: number) => {
doc.setFillColor(238, 238, 236)
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
doc.setFont("helvetica", "bold")
drawRow(top, columns.map((c) => c.header))
doc.setFont("helvetica", "normal")
return top + rowH
}
const drawRow = (top: number, cells: string[]) => {
doc.setFontSize(8.5)
let x = PAGE.margin
columns.forEach((col, i) => {
const text = fit(doc, cells[i] ?? "", col.width - 4)
if (col.align === "right") doc.text(text, x + col.width - 2, top + 4.5, { align: "right" })
else doc.text(text, x + 2, top + 4.5)
x += col.width
})
}
let top = drawHeader(y)
const all = totals ? [...rows, totals] : rows
all.forEach((cells, idx) => {
if (top + rowH > BOTTOM) {
doc.addPage()
top = drawHeader(20)
}
const isTotals = totals !== undefined && idx === all.length - 1
if (isTotals) {
doc.setDrawColor(150)
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
doc.setFont("helvetica", "bold")
} else if (idx % 2 === 1) {
doc.setFillColor(248, 248, 246)
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
}
drawRow(top, cells)
doc.setFont("helvetica", "normal")
top += rowH
})
return top + 4
}
/** The income/sales/expenses line chart as a PNG, drawn offscreen in the light palette. */
async function chartImage(days: DayEndReportDay[]): Promise<string> {
const { Chart, CategoryScale, LinearScale, PointElement, LineElement, Legend } = await import("chart.js")
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Legend)
const canvas = document.createElement("canvas")
canvas.width = 1600
canvas.height = 640
const series = (label: string, data: number[], color: string) => ({
label, data, borderColor: color, backgroundColor: color, borderWidth: 3,
pointRadius: days.length > 45 ? 0 : 4, tension: 0.25, fill: false,
})
const chart = new Chart(canvas, {
type: "line",
data: {
labels: days.map((d) => formatReportDate(d.date)),
datasets: [
series("Income", days.map((d) => d.income), "#2a78d6"),
series("Sales", days.map((d) => d.totalSales), "#eb6834"),
series("Expenses", days.map((d) => d.expenseTotal), "#1baf7a"),
series("Profit / loss", days.map((d) => d.netProfit), "#eda100"),
],
},
options: {
responsive: false,
animation: false,
devicePixelRatio: 1,
plugins: { legend: { position: "top", align: "start", labels: { font: { size: 22 }, usePointStyle: true } } },
scales: {
x: { grid: { display: false }, ticks: { font: { size: 18 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: "rgba(0,0,0,0.08)" }, ticks: { font: { size: 18 }, callback: (v) => formatAmount(Number(v)) } },
},
},
})
const png = chart.toBase64Image("image/png", 1)
chart.destroy()
return png
}
const amount = (n: number) => formatAmount(n)
/** Income with a minus sign when negative, marked as a loss: "Loss -300.00". */
const incomeText = (n: number) => (n < 0 ? `Loss ${formatSignedAmount(n)}` : formatSignedAmount(n))
/** "Profit 1,200.00" / "Loss 300.00" — a loss is labelled, not just negative. */
const profitText = (n: number) => {
const p = profitOrLoss(n)
return `${p.label} ${p.amount}`
}
function totalsGrid(t: DayEndReportDay): [string, string][] {
const net = profitOrLoss(t.netProfit)
return [
["Total sales", amount(t.totalSales)],
["Paid", amount(t.paidAmount)],
["Due", amount(t.dueAmount)],
["Expenses", amount(t.expenseTotal)],
["Paid by cash", amount(t.paidByCash)],
["Paid by card", amount(t.paidByCard)],
[t.income < 0 ? "Income (loss)" : "Income", formatSignedAmount(t.income)],
[`Net ${net.label.toLowerCase()}`, net.amount],
]
}
const DAILY_COLUMNS: Column[] = [
{ header: "Date", width: 21 },
{ header: "Sales", width: 20, align: "right" },
{ header: "Paid", width: 19, align: "right" },
{ header: "Due", width: 17, align: "right" },
{ header: "Cash", width: 18, align: "right" },
{ header: "Card", width: 18, align: "right" },
{ header: "Expenses", width: 19, align: "right" },
{ header: "Income", width: 20, align: "right" },
{ header: "Profit / loss", width: 30, align: "right" },
]
const dailyCells = (d: DayEndReportDay, label: string) => [
label, amount(d.totalSales), amount(d.paidAmount), amount(d.dueAmount),
amount(d.paidByCash), amount(d.paidByCard), amount(d.expenseTotal), incomeText(d.income), profitText(d.netProfit),
]
const CASH_COLUMNS: Column[] = [
{ header: "Date", width: 30 },
{ header: "Opening cash", width: 38, align: "right" },
{ header: "Net cash", width: 38, align: "right" },
{ header: "Deposited to bank", width: 38, align: "right" },
{ header: "Cash in hand", width: 38, align: "right" },
]
const cashCells = (d: DayEndReportDay, label: string) => [
label, amount(d.openingCashInHand ?? 0), amount(d.netCash), amount(d.cashDeposited ?? 0), amount(d.closingCashInHand ?? 0),
]
/** Sales − cost = gross, − expenses = net profit/loss, as a right-aligned statement. */
function profitStatement(doc: JsPdf, y: number, s: DayEndReportDay): number {
const gross = profitOrLoss(s.grossProfit)
const net = profitOrLoss(s.netProfit)
const left = PAGE.width - PAGE.margin - 95
const right = PAGE.width - PAGE.margin
const rows: [string, string][] = [
["Total sales", amount(s.totalSales)],
["Less: cost of goods sold", `(${amount(s.costOfGoodsSold)})`],
[`Gross ${gross.label.toLowerCase()}`, gross.amount],
["Less: expenses", `(${amount(s.expenseTotal)})`],
]
doc.setFontSize(10)
rows.forEach(([label, value]) => {
doc.text(label, left, y)
doc.text(value, right, y, { align: "right" })
y += 6
})
doc.setDrawColor(150)
doc.line(left, y - 3.5, right, y - 3.5)
doc.setFont("helvetica", "bold")
doc.setFontSize(12)
if (net.isLoss) doc.setTextColor(200, 40, 40)
else doc.setTextColor(0, 120, 0)
doc.text(`Net ${net.label.toLowerCase()}`, left, y + 2)
doc.text(net.amount, right, y + 2, { align: "right" })
doc.setTextColor(0)
doc.setFont("helvetica", "normal")
return y + 10
}
/** Day-wise income for a range: totals, the chart, then the daily table (with a totals row). */
export async function downloadDailyIncomePdf(report: DayEndDailyReport, branchLabel: string) {
const doc = await newDoc()
let y = header(
doc,
"Day-wise Income Report",
`${formatReportDate(report.from)} to ${formatReportDate(report.to)} (${report.days.length} days)`,
branchLabel
)
y = summaryGrid(doc, y, totalsGrid(report.totals))
y = sectionTitle(doc, y + 2, "Daily income")
const chartW = PAGE.width - PAGE.margin * 2
const chartH = chartW * (640 / 1600)
doc.addImage(await chartImage(report.days), "PNG", PAGE.margin, y, chartW, chartH)
y += chartH + 6
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y, "Day-wise breakdown")
y = table(
doc,
y,
DAILY_COLUMNS,
report.days.map((d) => dailyCells(d, formatReportDate(d.date))),
dailyCells(report.totals, "Total")
)
// Its own table: the breakdown above already fills the page width.
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, "Cash in hand and bank deposits")
const t = report.totals
table(
doc,
y,
CASH_COLUMNS,
report.days.map((d) => cashCells(d, formatReportDate(d.date))),
// Opening and closing are balances: the range opens with its first day and closes with its last.
["Total", amount(t.openingCashInHand ?? 0), amount(t.netCash), amount(t.cashDeposited ?? 0), amount(t.closingCashInHand ?? 0)]
)
footer(doc)
doc.save(`daily-income_${report.from}_${report.to}.pdf`)
}
/** One business day: its figures, sale counts by payment method, the sales and expense lists, then profit/loss. */
export async function downloadDayEndReportPdf(report: DayEndReport, branchLabel: string) {
const s = report.summary
const sales = report.sales ?? []
const expenses = report.expenses ?? []
const deposits = report.deposits ?? []
const doc = await newDoc()
let y = header(doc, "Day-End Sales Report", formatReportDate(s.date), branchLabel)
y = sectionTitle(doc, y, "Sales")
y = summaryGrid(doc, y + 1, [
["Total sales", amount(s.totalSales)],
["Paid amount", amount(s.paidAmount)],
["Due amount", amount(s.dueAmount)],
["Paid by cash", amount(s.paidByCash)],
["Paid by card", amount(s.paidByCard)],
["Paid other ways", amount(s.paidByOther)],
["Earlier invoices collected", amount(s.earlierInvoiceCollections)],
])
y = sectionTitle(doc, y + 2, "Cash in hand")
y = summaryGrid(doc, y + 1, [
["Opening cash in hand", amount(s.openingCashInHand ?? 0)],
["Net cash today", amount(s.netCash)],
["Deposited to bank", amount(s.cashDeposited ?? 0)],
["Remaining cash in hand", amount(s.closingCashInHand ?? 0)],
])
if (deposits.length > 0) {
y = table(
doc,
y,
[
{ header: "Deposit", width: 32 },
{ header: "Bank account", width: 62 },
{ header: "Slip no.", width: 30 },
{ header: "Deposited on", width: 28 },
{ header: "Amount", width: 30, align: "right" },
],
deposits.map((d) => [
d.docNo,
[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · "),
d.depositSlipNo ?? "-",
formatReportDate(d.depositDate),
amount(d.amount),
]),
["Total", "", "", "", amount(s.cashDeposited ?? 0)]
)
}
y = sectionTitle(doc, y + 2, "Sales count")
y = summaryGrid(doc, y + 1, [
["All sales", String(s.salesCount)],
["Cash sales", String(s.cashSalesCount)],
["Card sales", String(s.cardSalesCount)],
["Mixed / other / unpaid", `${s.mixedSalesCount} / ${s.otherSalesCount} / ${s.unpaidSalesCount}`],
])
y = sectionTitle(doc, y + 2, `Sales of the day (${sales.length})`)
if (sales.length === 0) {
y = note(doc, y, "No posted sales on this day.")
} else {
y = table(
doc,
y,
[
{ header: "Time", width: 13 },
{ header: "No.", width: 28 },
{ header: "Customer", width: 37 },
{ header: "Paid by", width: 20 },
{ header: "Total", width: 22, align: "right" },
{ header: "Due", width: 20, align: "right" },
{ header: "Cost", width: 20, align: "right" },
{ header: "Profit", width: 22, align: "right" },
],
sales.map((r) => [
new Date(r.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
r.docNo, r.customerName, r.paidBy, amount(r.total), amount(r.due), amount(r.cost), amount(r.grossProfit),
]),
["", "Total", "", "", amount(s.totalSales), amount(s.dueAmount), amount(s.costOfGoodsSold), amount(s.grossProfit)]
)
}
if (y + 20 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, `Expenses of the day (${expenses.length})`)
if (expenses.length === 0) {
y = note(doc, y, "No posted expenses on this day.")
} else {
y = table(
doc,
y,
[
{ header: "Doc no.", width: 34 },
{ header: "Category", width: 30 },
{ header: "Title", width: 40 },
{ header: "Payee", width: 46 },
{ header: "Amount", width: 32, align: "right" },
],
expenses.map((e) => [e.docNo, e.categoryName, e.titleName, e.payee ?? "-", amount(e.amount)]),
["Total", "", "", "", amount(s.expenseTotal)]
)
}
if (y + 44 > BOTTOM) {
doc.addPage()
y = 20
}
y = sectionTitle(doc, y + 2, "Profit / loss")
profitStatement(doc, y + 4, s)
footer(doc)
doc.save(`day-end-report_${s.date}.pdf`)
}