Spaces:
Sleeping
Sleeping
| import jsPDF from 'jspdf'; | |
| import autoTable from 'jspdf-autotable'; | |
| import { Party, Transaction } from '../types'; | |
| // --- CONFIGURATION --- | |
| const BG_COLOR: [number, number, number] = [240, 234, 214]; // Beige | |
| const BORDER_COLOR: [number, number, number] = [0, 0, 0]; // Black | |
| // Helper: Currency Format | |
| const FORMAT_CURRENCY = (amount: number) => { | |
| return new Intl.NumberFormat('en-IN', { | |
| minimumFractionDigits: 2, | |
| maximumFractionDigits: 2 | |
| }).format(amount); | |
| }; | |
| // Helper: Date Format | |
| const FORMAT_DATE = (dateStr: string) => { | |
| if (!dateStr) return ""; | |
| const dateObj = PARSE_DATE(dateStr) || new Date(dateStr); | |
| return `${dateObj.getDate().toString().padStart(2, '0')}/${(dateObj.getMonth() + 1).toString().padStart(2, '0')}/${dateObj.getFullYear().toString().slice(-2)}`; | |
| }; | |
| const PARSE_DATE = (value?: string): Date | undefined => { | |
| if (!value) return undefined; | |
| const trimmed = String(value).trim(); | |
| if (!trimmed) return undefined; | |
| const iso = trimmed.match(/^\d{4}-\d{2}-\d{2}(?:[T\s].*)?$/); | |
| if (iso) { | |
| const d = new Date(trimmed); | |
| return isNaN(d.getTime()) ? undefined : d; | |
| } | |
| const slash = trimmed.match(/^\d{1,2}\/\d{1,2}\/(\d{2}|\d{4})$/); | |
| if (slash) { | |
| const [aStr, bStr, yyStr] = trimmed.split('/'); | |
| const a = Number(aStr); | |
| const b = Number(bStr); | |
| const yy = Number(yyStr); | |
| const year = yyStr.length === 2 ? 2000 + yy : yy; | |
| if (!a || !b || !year) return undefined; | |
| // Prefer en-IN DD/MM/YYYY, but support MM/DD/YYYY when clearly indicated. | |
| // - If first part > 12 -> it's DD/MM | |
| // - If second part > 12 -> it's MM/DD | |
| // - If both <= 12 -> default DD/MM | |
| const day = a > 12 ? a : b > 12 ? b : a; | |
| const month = a > 12 ? b : b > 12 ? a : b; | |
| const d = new Date(year, month - 1, day); | |
| return isNaN(d.getTime()) ? undefined : d; | |
| } | |
| const d = new Date(trimmed); | |
| return isNaN(d.getTime()) ? undefined : d; | |
| }; | |
| const TO_ISO_DATE = (value?: string): string | undefined => { | |
| const d = PARSE_DATE(value); | |
| return d ? d.toISOString() : undefined; | |
| }; | |
| const EXTRACT_DATE_FROM_TEXT = (text?: string): string | undefined => { | |
| if (!text) return undefined; | |
| const iso = text.match(/\b\d{4}-\d{2}-\d{2}\b/); | |
| if (iso?.[0]) return TO_ISO_DATE(iso[0]); | |
| const mdyOrDmy = text.match(/\b\d{1,2}\/\d{1,2}\/\d{4}\b/); | |
| if (!mdyOrDmy?.[0]) return undefined; | |
| return TO_ISO_DATE(mdyOrDmy[0]); | |
| }; | |
| export const generateLedgerPDF = (party: Party, transactions: Transaction[]) => { | |
| const doc = new jsPDF(); | |
| // --- 1. PAGE CONFIGURATION --- | |
| const PAGE_WIDTH = 210; | |
| const PAGE_HEIGHT = 297; | |
| const MARGIN = 14; | |
| const CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2); | |
| // --- 2. SETUP & BACKGROUND --- | |
| doc.setFillColor(...BG_COLOR); | |
| doc.rect(0, 0, PAGE_WIDTH, PAGE_HEIGHT, 'F'); | |
| // --- 3. DATA PREPARATION --- | |
| const ledgerEvents: Array< | |
| | { kind: 'bill'; date: string; tx: Transaction } | |
| | { kind: 'payment'; date: string; tx: Transaction; payment: any } | |
| > = []; | |
| for (const tx of transactions) { | |
| const billDateIso = TO_ISO_DATE(tx.bill_date) || tx.bill_date; | |
| ledgerEvents.push({ kind: 'bill', date: billDateIso, tx }); | |
| const payments = tx.payments || []; | |
| const validPayments = payments.filter((p: any) => String(p.mode).toLowerCase() !== 'due'); | |
| for (const p of validPayments) { | |
| const paymentDateRaw = | |
| (p as any).payment_date || | |
| (p as any).created_at || | |
| (p as any).date || | |
| EXTRACT_DATE_FROM_TEXT((p as any).reference) || | |
| tx.bill_date; | |
| const paymentDateIso = TO_ISO_DATE(paymentDateRaw) || TO_ISO_DATE(tx.bill_date) || tx.bill_date; | |
| const billTime = (PARSE_DATE(billDateIso) || new Date(billDateIso)).getTime(); | |
| const payTime = (PARSE_DATE(paymentDateIso) || new Date(paymentDateIso)).getTime(); | |
| const safePaymentDate = payTime < billTime ? billDateIso : paymentDateIso; | |
| ledgerEvents.push({ kind: 'payment', date: safePaymentDate, tx, payment: p }); | |
| } | |
| const recordedPaid = validPayments.reduce((sum: number, p: any) => sum + (Number(p.amount) || 0), 0); | |
| const extraPaid = (Number(tx.paid_amount) || 0) - recordedPaid; | |
| if (extraPaid > 0) { | |
| const paymentDateRaw = | |
| (tx as any).updated_at || | |
| (tx as any).created_at || | |
| tx.bill_date; | |
| const paymentDateIso = TO_ISO_DATE(paymentDateRaw) || billDateIso; | |
| const billTime = (PARSE_DATE(billDateIso) || new Date(billDateIso)).getTime(); | |
| const payTime = (PARSE_DATE(paymentDateIso) || new Date(paymentDateIso)).getTime(); | |
| const safePaymentDate = payTime < billTime ? billDateIso : paymentDateIso; | |
| ledgerEvents.push({ | |
| kind: 'payment', | |
| date: safePaymentDate, | |
| tx, | |
| payment: { mode: 'payment', amount: extraPaid, reference: undefined, __synthetic: true }, | |
| }); | |
| } | |
| } | |
| ledgerEvents.sort((a, b) => { | |
| const ta = new Date(a.date).getTime(); | |
| const tb = new Date(b.date).getTime(); | |
| if (ta !== tb) return ta - tb; | |
| if (a.kind !== b.kind) return a.kind === 'bill' ? -1 : 1; | |
| const na = a.tx.bill_number || ''; | |
| const nb = b.tx.bill_number || ''; | |
| return na.localeCompare(nb); | |
| }); | |
| let runningBalance = 0; | |
| const tableRows = ledgerEvents.map(ev => { | |
| const tx = ev.tx; | |
| const typeStr = String(tx.bill_type).toUpperCase(); | |
| const numStr = tx.bill_number ? tx.bill_number.toUpperCase() : ""; | |
| const isJawaakFromNumber = numStr.startsWith('JAWAAK'); | |
| const isAwaakFromNumber = numStr.startsWith('AWAAK'); | |
| const isAwaak = isAwaakFromNumber ? true : isJawaakFromNumber ? false : typeStr === 'AWAAK'; | |
| if (ev.kind === 'bill') { | |
| let itemDetails = ""; | |
| if (tx.items && tx.items.length > 0) { | |
| tx.items.forEach(item => { | |
| let wStr = ""; | |
| if (Array.isArray(item.poti_weights)) wStr = item.poti_weights.join(","); | |
| else if (item.poti_weights) wStr = String(item.poti_weights); | |
| const prefix = itemDetails ? "\n" : ""; | |
| itemDetails += `${prefix}${item.mirchi_name}`; | |
| if (wStr) itemDetails += ` (${wStr})`; | |
| }); | |
| } | |
| let billParticulars = `No: ${tx.bill_number}`; | |
| if (tx.is_return) billParticulars += " (RETURN)"; | |
| if (itemDetails) billParticulars += `\n${itemDetails}`; | |
| let totalPoti = 0; | |
| tx.items?.forEach(i => totalPoti += Number(i.poti_count) || 0); | |
| let billDebit = 0; | |
| let billCredit = 0; | |
| if (isAwaak) { | |
| if (tx.is_return) billDebit = tx.total_amount; | |
| else billCredit = tx.total_amount; | |
| } else { | |
| if (tx.is_return) billCredit = tx.total_amount; | |
| else billDebit = tx.total_amount; | |
| } | |
| runningBalance = runningBalance + billDebit - billCredit; | |
| return { | |
| date: FORMAT_DATE(ev.date), | |
| particulars: billParticulars, | |
| poti: totalPoti > 0 ? totalPoti.toString() : "-", | |
| credit: billCredit > 0 ? FORMAT_CURRENCY(billCredit) : "-", | |
| debit: billDebit > 0 ? FORMAT_CURRENCY(billDebit) : "-", | |
| balance: `${FORMAT_CURRENCY(Math.abs(runningBalance))} ${runningBalance >= 0 ? 'Dr' : 'Cr'}`, | |
| isMainRow: true | |
| }; | |
| } | |
| const p = ev.payment; | |
| const amount = Number(p.amount) || 0; | |
| let payDebit = 0; | |
| let payCredit = 0; | |
| if (isAwaak) { | |
| if (tx.is_return) payCredit = amount; | |
| else payDebit = amount; | |
| } else { | |
| if (tx.is_return) payDebit = amount; | |
| else payCredit = amount; | |
| } | |
| runningBalance = runningBalance + payDebit - payCredit; | |
| const modeStr = String(p.mode || '').charAt(0).toUpperCase() + String(p.mode || '').slice(1); | |
| let payDesc = (p as any).__synthetic ? ` [Payment]` : ` [${modeStr}]`; | |
| if (p.reference) payDesc += ` ${p.reference}`; | |
| return { | |
| date: FORMAT_DATE(ev.date), | |
| particulars: payDesc, | |
| poti: "-", | |
| credit: payCredit > 0 ? FORMAT_CURRENCY(payCredit) : "-", | |
| debit: payDebit > 0 ? FORMAT_CURRENCY(payDebit) : "-", | |
| balance: `${FORMAT_CURRENCY(Math.abs(runningBalance))} ${runningBalance >= 0 ? 'Dr' : 'Cr'}`, | |
| isMainRow: false | |
| }; | |
| }); | |
| // --- 4. GENERATE TABLE --- | |
| autoTable(doc, { | |
| startY: 35, | |
| tableWidth: CONTENT_WIDTH, | |
| margin: { left: MARGIN, right: MARGIN }, | |
| head: [[ | |
| 'Date', | |
| 'Particulars', | |
| 'Poti', | |
| 'Credit\n(Jama)', | |
| 'Debit\n(Nave)', | |
| 'Balance' | |
| ]], | |
| body: tableRows.map(r => [r.date, r.particulars, r.poti, r.credit, r.debit, r.balance]), | |
| theme: 'grid', | |
| styles: { | |
| fillColor: false, | |
| textColor: 0, | |
| lineColor: 0, | |
| lineWidth: 0.1, | |
| font: 'helvetica', | |
| fontSize: 9, | |
| valign: 'top', | |
| cellPadding: 3, | |
| overflow: 'linebreak' | |
| }, | |
| headStyles: { | |
| fillColor: false, | |
| textColor: 0, | |
| fontStyle: 'bold', | |
| halign: 'center', | |
| valign: 'middle', | |
| lineWidth: 0.1, | |
| }, | |
| columnStyles: { | |
| 0: { cellWidth: 20, halign: 'center' }, // Date | |
| 1: { cellWidth: 72, halign: 'left' }, // Particulars | |
| 2: { cellWidth: 12, halign: 'center' }, // Poti | |
| 3: { cellWidth: 26, halign: 'right' }, // Credit | |
| 4: { cellWidth: 26, halign: 'right' }, // Debit | |
| 5: { cellWidth: 26, halign: 'right', fontStyle: 'bold' } // Balance | |
| }, | |
| didParseCell: (data) => { | |
| const rowIdx = data.row.index; | |
| const rowData = tableRows[rowIdx]; | |
| if (data.section === 'body' && data.column.index === 1 && rowData?.isMainRow) { | |
| data.cell.styles.fontStyle = 'bold'; | |
| } | |
| }, | |
| didDrawPage: (data) => { | |
| // Border | |
| doc.setDrawColor(...BORDER_COLOR); | |
| doc.setLineWidth(0.4); | |
| doc.rect(MARGIN, MARGIN, CONTENT_WIDTH, PAGE_HEIGHT - (MARGIN * 2)); | |
| // Header | |
| if (data.pageNumber === 1) { | |
| const startX = MARGIN + 4; | |
| const startY = 25; | |
| doc.setFontSize(14); | |
| doc.setFont("helvetica", "bold"); | |
| doc.text("Name of A/c :", startX, startY); | |
| const labelWidth = doc.getTextWidth("Name of A/c : "); | |
| doc.text(party.name, startX + labelWidth, startY); | |
| if (party.city || party.phone) { | |
| doc.setFontSize(9); | |
| doc.setFont("helvetica", "normal"); | |
| const subText = [party.city, party.phone].filter(Boolean).join(" - "); | |
| doc.text(subText, startX, startY + 6); | |
| } | |
| } | |
| } | |
| }); | |
| const cleanName = party.name.replace(/[^a-zA-Z0-9]/g, '_'); | |
| doc.save(`${cleanName}_Ledger.pdf`); | |
| }; |