price adjustment and stock adjustments
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
Reference in New Issue
Block a user