From 82cae89fae9e5613b3e3e66feeec294b25e178d2 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 10 Sep 2026 08:09:10 +0530 Subject: [PATCH] feat(medical-store): add server-side A4/A5 vector PDF generation with mPDF and 48-col plain-text thermal receipt printing --- app/Config/Routes.php | 1 + app/Controllers/Api/v1/MedicalStoreApi.php | 540 +++++++++++++++++- public/App/MedicalStore/index.html | 623 +++++++++++++++------ 3 files changed, 997 insertions(+), 167 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4c17412..5284dbd 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1671,6 +1671,7 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->get('medical-store/stock/alerts', 'MedicalStoreApi::stockAlerts'); $routes->post('medical-store/sales/save', 'MedicalStoreApi::saveSale'); $routes->get('medical-store/sales/invoice/(:num)', 'MedicalStoreApi::getInvoice/$1'); + $routes->get('medical-store/sales/invoice-pdf/(:num)', 'MedicalStoreApi::invoicePdf/$1'); $routes->get('medical-store/sales/recent', 'MedicalStoreApi::recentSales'); $routes->get('medical-store/suppliers', 'MedicalStoreApi::suppliers'); $routes->post('medical-store/suppliers/save', 'MedicalStoreApi::saveSupplier'); diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index 4f558de..fc8d719 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -1901,7 +1901,7 @@ class MedicalStoreApi extends BaseController ]); } - public function getInvoice(int $saleId) + protected function getInvoiceData(int $saleId): ?array { $sale = $this->db->table('mst_sales s') ->select('s.*, st.store_name, st.building_name, st.floor_no, st.room_no, st.drug_license_no_20b, st.drug_license_no_21b, st.drug_license_no_20f_x, st.gstin, st.pan_no, st.fssai_no, st.state_code, st.state_name, st.registered_pharmacist_name, st.pharmacist_reg_no, st.contact_phone, st.contact_email, st.address as store_address, st.upi_id, st.terms_conditions, st.abdm_hfr_id, st.abdm_hip_id, st.pharmacist_hpr_id') @@ -1911,7 +1911,7 @@ class MedicalStoreApi extends BaseController ->getRowArray(); if (!$sale) { - return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Invoice not found.']); + return null; } $items = $this->db->table('mst_sales_items si') @@ -1967,8 +1967,7 @@ class MedicalStoreApi extends BaseController $upiQrString = "upi://pay?pa={$sale['upi_id']}&pn=" . urlencode($sale['store_name']) . "&am={$sale['net_amount']}&cu=INR&tn=" . urlencode("Bill " . $sale['invoice_no']); } - return $this->response->setJSON([ - 'status' => 1, + return [ 'sale' => $sale, 'items' => $saleItems, 'return_items' => $returnItems, @@ -1976,9 +1975,540 @@ class MedicalStoreApi extends BaseController 'credit_note' => $creditNote, 'hsn_summary' => array_values($hsnSummary), 'upi_qr_string' => $upiQrString - ]); + ]; } + public function getInvoice(int $saleId) + { + $data = $this->getInvoiceData($saleId); + if (!$data) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Invoice not found.']); + } + + return $this->response->setJSON(array_merge(['status' => 1], $data)); + } + + public function invoicePdf(int $saleId) + { + $data = $this->getInvoiceData($saleId); + if (!$data) { + return $this->response->setStatusCode(404)->setBody('Invoice not found.'); + } + + $format = strtolower($this->request->getGet('format') ?? 'a4'); + $download = (bool)($this->request->getGet('download') ?? false); + $isA5 = ($format === 'a5'); + + $tmpDir = WRITEPATH . 'cache/mpdf'; + if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0777, true); + } + + $mpdfConfig = [ + 'tempDir' => $tmpDir, + 'mode' => 'utf-8', + 'format' => $isA5 ? 'A5' : 'A4', + 'orientation' => 'P', + 'margin_left' => $isA5 ? 6 : 8, + 'margin_right' => $isA5 ? 6 : 8, + 'margin_top' => $isA5 ? 6 : 8, + 'margin_bottom' => $isA5 ? 6 : 8, + 'default_font' => 'dejavusans' + ]; + + try { + $mpdf = new \Mpdf\Mpdf($mpdfConfig); + $html = $this->renderInvoicePdfHtml($data, $isA5); + $mpdf->WriteHTML($html); + + $safeInv = preg_replace('/[^A-Za-z0-9_-]/', '_', $data['sale']['invoice_no'] ?: ('INV_' . $saleId)); + $fileName = "Invoice_{$safeInv}_{$format}.pdf"; + + $pdfBinary = $mpdf->Output('', \Mpdf\Output\Destination::STRING_RETURN); + + return $this->response + ->setHeader('Content-Type', 'application/pdf') + ->setHeader('Content-Disposition', ($download ? 'attachment' : 'inline') . '; filename="' . $fileName . '"') + ->setBody($pdfBinary); + } catch (\Throwable $e) { + log_message('error', 'MedicalStoreApi::invoicePdf failed: ' . $e->getMessage()); + return $this->response->setStatusCode(500)->setBody('Failed to generate PDF: ' . $e->getMessage()); + } + } + + protected function renderInvoicePdfHtml(array $data, bool $isA5): string + { + $sale = $data['sale']; + $items = $data['items']; + $returnItems = $data['return_items']; + $creditNote = $data['credit_note']; + $hsnSummary = $data['hsn_summary']; + $upiQrString = $data['upi_qr_string']; + + $esc = fn($v) => htmlspecialchars((string)($v ?? ''), ENT_QUOTES, 'UTF-8'); + $curr = fn($v) => '₹' . number_format((float)($v ?? 0), 2, '.', ''); + + $fontSize = $isA5 ? '7pt' : '8pt'; + $thPadding = $isA5 ? '2.5px 3.5px' : '4px 5px'; + $tdPadding = $isA5 ? '2.5px 3.5px' : '3.5px 5px'; + + $qrCodeValue = $upiQrString ?: $sale['invoice_no']; + $qrHtml = ''; + if (!empty($qrCodeValue)) { + $qrSize = $isA5 ? '0.55' : '0.65'; + $qrHtml = ''; + } + + $html = ' + + + + + + '; + + // 1. Header Table + $html .= ' + + + + + +
+
+ ' . $esc($sale['store_name'] ?: 'City Hospital Central Pharmacy') . ' +
+
+ ' . $esc($sale['building_name'] ?: 'Main Hospital Block A') . ', ' . $esc($sale['floor_no'] ?: 'Ground Floor') . ($sale['store_address'] ? ', ' . $esc($sale['store_address']) : '') . ' +
+
+ DL 20-B: ' . $esc($sale['drug_license_no_20b'] ?: 'N/A') . ' | DL 21-B: ' . $esc($sale['drug_license_no_21b'] ?: 'N/A') . ' + ' . ($sale['fssai_no'] ? ' | FSSAI: ' . $esc($sale['fssai_no']) : '') . ' +
+
+ GSTIN: ' . $esc($sale['gstin'] ?: 'N/A') . ' | State: ' . $esc($sale['state_name'] ?: 'Delhi') . ' (Code: ' . $esc($sale['state_code'] ?: '07') . ') + ' . ($sale['abdm_hfr_id'] ? ' | ABDM HFR: ' . $esc($sale['abdm_hfr_id']) : '') . ' +
+
+ + + + ' . ($qrHtml ? '' : '') . ' + +
+
+ ' . ($isA5 ? 'RETAIL TAX INVOICE' : 'HOSPITAL GST TAX INVOICE') . ' +
+
+ ' . $esc($sale['invoice_no']) . ' +
+
+ ' . $esc($sale['sale_date']) . ' +
+
' . $qrHtml . '
+
'; + + // 2. Patient & Doctor Info Table + $html .= ' +
+ + + + + + '; + + if (!empty($sale['abha_id']) || !empty($sale['abha_address']) || !empty($sale['abdm_care_context_ref'])) { + $html .= ' + + + '; + } + + if (!empty($sale['ipd_id'])) { + $html .= ' + + + '; + } + + $html .= ' +
+ Patient Name: ' . $esc($sale['patient_name'] ?: 'Walk-in Customer') . '
+ UHID No: ' . $esc($sale['uhid'] ?: 'Walk-in') . ' +
+ Phone: ' . $esc($sale['patient_mobile'] ?: 'N/A') . '
+ Age / Gender: ' . $esc($sale['age'] ?: 'N/A') . ' / ' . $esc($sale['gender'] ?: 'N/A') . ' +
+ Prescribing Doctor: ' . $esc($sale['doctor_name'] ?: 'Dr. Consultant') . '
+ Doctor Reg No: ' . $esc($sale['doctor_reg_no'] ?: 'N/A') . ' +
+ ✓ ABDM Linked: ABHA No: ' . $esc($sale['abha_id'] ?: 'N/A') . ' | Address: ' . $esc($sale['abha_address'] ?: 'N/A') . ($sale['abdm_care_context_ref'] ? ' | Care Context: ' . $esc($sale['abdm_care_context_ref']) : '') . ' +
+ 🏥 IPD Admission #' . $esc($sale['ipd_id']) . ' • Ward: ' . $esc($sale['ward_name'] ?: 'General') . ' • Bed: ' . $esc($sale['bed_no'] ?: 'Bed') . ' (Charged to IPD Running Bill) +
+
'; + + // 3. Dispensed Medicines Table + $html .= ' + + + + + + + + + + + + + + + + + '; + + if (empty($items)) { + $html .= ' + + + '; + } else { + foreach ($items as $idx => $it) { + $qtyUnitStr = ''; + if ($it['sell_unit'] === 'Tablet') { + $qtyUnitStr = $it['total_units'] . ' Tab'; + } elseif ($it['sell_unit'] === 'Combo') { + $qtyUnitStr = $it['qty'] . ' Str + ' . $it['loose_qty'] . ' Tab'; + } else { + $qtyUnitStr = ((int)$it['units_per_pack'] > 1) ? ($it['qty'] . ' Strip') : ($it['qty'] . ' ' . ($it['unit_pack'] ?: 'Unit')); + } + + $rateStr = ($it['sell_unit'] === 'Tablet') ? ($curr($it['unit_price']) . '/tab') : $curr($it['unit_mrp']); + + $html .= ' + + + + + + + + + + + + + '; + } + } + + $html .= ' + +
#Medicine & FormulationHSNBatchExpQty & UnitRateTaxableCGSTSGSTTotal
+ - No new medicines dispensed (Sales Return & Customer Refund Bill) - +
' . ($idx + 1) . ' + ' . $esc($it['item_name']) . ''; + if ((int)$it['units_per_pack'] > 1 && $it['sell_unit'] !== 'Tablet') { + $html .= '
Pack: 1 Strip of ' . $esc($it['units_per_pack']) . ' Tabs
'; + } + if (!empty($it['snomed_ct_code'])) { + $html .= '
SNOMED: ' . $esc($it['snomed_ct_code']) . '
'; + } + $html .= ' +
' . $esc($it['hsn_code'] ?: '3004') . '' . $esc($it['batch_no']) . '' . $esc(substr($it['expiry_date'] ?? '', 0, 7)) . '' . $esc($qtyUnitStr) . '' . $rateStr . '' . $curr($it['taxable_value']) . '' . $curr($it['cgst_amount']) . '' . $curr($it['sgst_amount']) . '' . $curr($it['total_amount']) . '
'; + + // 4. Returned / Exchanged Medicines Table (if any) + if (!empty($returnItems)) { + $html .= ' +
+ RETURNED / EXCHANGED MEDICINES ' . ($creditNote ? '(Credit Note: ' . $esc($creditNote['credit_note_no']) . ')' : '') . ' +
+ + + + + + + + + + + + + + + + '; + + foreach ($returnItems as $rIdx => $rit) { + $retQtyStr = '-' . (($rit['sell_unit'] === 'Tablet') ? ($rit['total_units'] . ' Tab') : ($rit['qty'] . ' ' . ($rit['unit_pack'] ?: 'Unit'))); + $html .= ' + + + + + + + + + + + + '; + } + + $html .= ' + +
#Returned MedicineHSNBatchOriginal BillConditionQty ReturnedRateTaxableCredit Total
' . ($rIdx + 1) . '' . $esc($rit['item_name']) . '' . $esc($rit['hsn_code'] ?: '3004') . '' . $esc($rit['batch_no']) . '' . $esc($rit['ref_invoice_no'] ?: 'N/A') . '' . $esc($rit['return_condition'] ?: 'GOOD') . '' . $esc($retQtyStr) . '' . $curr($rit['unit_price']) . '-' . $curr(abs((float)$rit['taxable_value'])) . '-' . $curr(abs((float)$rit['total_amount'])) . '
'; + } + + // 5. GST Tax Breakdown Table (HSN Summary) + if (!empty($hsnSummary)) { + $html .= ' +
+ GST Tax Breakdown (Indian GST Standard): +
+ + + + + + + + + + + + '; + + foreach ($hsnSummary as $h) { + $html .= ' + + + + + + + + '; + } + + $html .= ' + +
HSN CodeGST RateTaxable ValueCGSTSGSTTotal Tax
' . $esc($h['hsn_code']) . '' . $esc($h['gst_rate']) . '%' . $curr($h['taxable_value']) . '' . $curr($h['cgst_amount']) . '' . $curr($h['sgst_amount']) . '' . $curr($h['total_tax']) . '
'; + } + + // 6. Summary Totals & Signatures + $html .= ' + + + + + + +
+
+ Registered Pharmacist: ' . $esc($sale['registered_pharmacist_name'] ?: 'Pharmacist') . ' (Reg No: ' . $esc($sale['pharmacist_reg_no'] ?: 'N/A') . ') + ' . ($sale['pharmacist_hpr_id'] ? '
HPR ID: ' . $esc($sale['pharmacist_hpr_id']) : '') . ' +
+ +
+ Payment Settlement: ' . $esc($sale['payment_mode']) . '
'; + + if ((float)$sale['cash_paid'] > 0) { + $html .= '• Cash: ' . $curr($sale['cash_paid']) . ' '; + } + if ((float)$sale['upi_paid'] > 0) { + $html .= '• UPI/Bank: ' . $curr($sale['upi_paid']) . ($sale['upi_ref_no'] ? ' [UTR: ' . $esc($sale['upi_ref_no']) . ']' : '') . ($sale['bank_name'] ? ' (' . $esc($sale['bank_name']) . ')' : '') . ' '; + } + if ((float)$sale['card_paid'] > 0) { + $html .= '• Card: ' . $curr($sale['card_paid']) . ($sale['card_ref_no'] ? ' [Ref: ' . $esc($sale['card_ref_no']) . ']' : '') . ' '; + } + if ((float)$sale['credit_amount'] > 0) { + $html .= '• IPD Credit: ' . $curr($sale['credit_amount']) . ' '; + } + + $html .= '
' . ($sale['is_bank_reconciled'] == 1 ? '✓ Bank Statement Audited' : 'Bank Statement: Pending Audit'); + + $html .= ' +
+ +
+ ' . nl2br($esc($sale['terms_conditions'] ?: "1. Goods once sold are returnable only as per Drug Rules within 7 days.\n2. Keep medicines stored in cool & dry place below 25°C away from direct sunlight.")) . ' +
+
+ + + + + '; + + if ((float)$sale['discount_amount'] > 0) { + $html .= ' + + + + '; + } + + if ((float)$sale['return_amount'] > 0) { + $html .= ' + + + + '; + } + + $html .= ' + + + + + + + + + + + + '; + + if ((float)$sale['round_off'] != 0) { + $html .= ' + + + + '; + } + + $html .= ' + + + '; + + if ((float)$sale['refund_amount'] > 0) { + $html .= ' + + + '; + } + + $html .= ' +
Gross Items Total:' . $curr($sale['gross_amount']) . '
Total Discount Allowed:-' . $curr($sale['discount_amount']) . '
Less Return Credit:-' . $curr($sale['return_amount']) . '
Taxable Amount:' . $curr($sale['taxable_amount']) . '
CGST Amount:' . $curr($sale['cgst_amount']) . '
SGST Amount:' . $curr($sale['sgst_amount']) . '
Round Off:' . $curr($sale['round_off']) . '
+
+ + + + + +
NET PAYABLE:' . $curr($sale['net_amount']) . '
+
+
+
+ REFUND PAID TO CUSTOMER: ' . $curr($sale['refund_amount']) . ' (' . $esc($sale['refund_mode']) . ') +
+
+
+ + + '; + + return $html; + } + + public function recentSales() { $storeId = (int)($this->request->getGet('store_id') ?? 1); diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index 54798b8..07dd64d 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -339,6 +339,10 @@ PRINT FORMATS: 80mm Thermal, A5 Slip, and A4 Hospital Tax Invoice ------------------------------------------------------------- */ @media print { + @page { + size: auto; + margin: 6mm; + } body * { visibility: hidden; } @@ -349,9 +353,23 @@ position: absolute; left: 0; top: 0; - width: 100%; - margin: 0; - padding: 0; + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + } + .print-a4-invoice, .print-a5-invoice { + width: 100% !important; + max-width: 100% !important; + min-height: auto !important; + padding: 0 !important; + border: none !important; + box-shadow: none !important; + } + .print-thermal-80mm { + width: 76mm !important; + max-width: 100% !important; + border: none !important; } .no-print { display: none !important; @@ -366,39 +384,42 @@ font-size: 11px; line-height: 1.3; color: #000; - padding: 6px; + padding: 8px; margin: auto; background: #fff; - border: 1px dashed #ccc; + border: 1px dashed #cbd5e1; + border-radius: 4px; } /* 2. A5 Half-Page Invoice */ .print-a5-invoice { - width: 148mm; - min-height: 210mm; - max-width: 100%; + width: 100%; + max-width: 650px; + min-height: auto; font-family: 'Inter', sans-serif; font-size: 11px; color: #1e293b; - padding: 14px; + padding: 16px; margin: auto; background: #fff; border: 1px solid #cbd5e1; + border-radius: 6px; box-shadow: 0 4px 15px rgba(0,0,0,0.05); } /* 3. A4 Full Comprehensive Tax Invoice */ .print-a4-invoice { - width: 210mm; - min-height: 297mm; - max-width: 100%; + width: 100%; + max-width: 820px; + min-height: auto; font-family: 'Inter', sans-serif; - font-size: 12px; + font-size: 11.5px; color: #0f172a; - padding: 24px; + padding: 20px; margin: auto; background: #fff; border: 1px solid #cbd5e1; + border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); } @@ -877,6 +898,24 @@ function PosCounter({ store, notify }) { const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [isPrintOpen, setIsPrintOpen] = useState(false); + // Automatically preload latest completed sale for this store so "Last Bill" is immediately available + useEffect(() => { + if (store && store.store_id && !lastInvoice) { + fetch(API_BASE + `sales/recent?store_id=${store.store_id}&limit=1`) + .then(r => r.json()) + .then(d => { + if (d.status && d.sales && d.sales.length > 0) { + const latestSaleId = d.sales[0].sale_id; + fetch(API_BASE + `sales/invoice/${latestSaleId}`) + .then(ir => ir.json()) + .then(id => { + if (id.status) setLastInvoice(id); + }).catch(() => {}); + } + }).catch(() => {}); + } + }, [store?.store_id]); + // B2C Sales Return & Same-Invoice Exchange State const [returnCart, setReturnCart] = useState([]); const [showReturnModal, setShowReturnModal] = useState(false); @@ -1664,6 +1703,259 @@ function PosCounter({ store, notify }) { } }; + const generateThermalReceiptText = (inv) => { + if (!inv || !inv.sale) return ''; + const sale = inv.sale; + const items = inv.items || []; + const returnItems = inv.return_items || []; + const LINE_WIDTH = 48; // Standard 80mm thermal receipt line length (Font A: 48 chars) + + const center = (str) => { + str = (str || '').trim(); + if (str.length >= LINE_WIDTH) return str.substring(0, LINE_WIDTH); + const pad = Math.floor((LINE_WIDTH - str.length) / 2); + return ' '.repeat(pad) + str; + }; + + const padLine = (left, right) => { + left = left || ''; + right = right || ''; + const space = LINE_WIDTH - left.length - right.length; + if (space <= 0) return left + ' ' + right; + return left + ' '.repeat(space) + right; + }; + + const divider = '='.repeat(LINE_WIDTH); + const dashDivider = '-'.repeat(LINE_WIDTH); + + let lines = []; + lines.push(divider); + lines.push(center(sale.store_name || 'HOSPITAL PHARMACY')); + if (sale.building_name || sale.floor_no) { + lines.push(center(`${sale.building_name || ''}, ${sale.floor_no || ''}`)); + } + if (sale.store_address) { + lines.push(center(sale.store_address)); + } + if (sale.contact_phone) { + lines.push(center(`Phone: ${sale.contact_phone}`)); + } + if (sale.drug_license_no_20b || sale.drug_license_no_21b) { + lines.push(center(`DL 20B: ${sale.drug_license_no_20b || 'N/A'} | 21B: ${sale.drug_license_no_21b || 'N/A'}`)); + } + if (sale.gstin) { + lines.push(center(`GSTIN: ${sale.gstin}${sale.state_code ? ` (State: ${sale.state_code})` : ''}`)); + } + if (sale.fssai_no) { + lines.push(center(`FSSAI: ${sale.fssai_no}`)); + } + if (sale.abdm_hfr_id) { + lines.push(center(`HFR ID: ${sale.abdm_hfr_id}`)); + } + lines.push(divider); + + lines.push(center('RETAIL TAX INVOICE')); + lines.push(padLine(`Bill No: ${sale.invoice_no}`, `Date: ${sale.sale_date ? sale.sale_date.substring(0, 16) : ''}`)); + lines.push(padLine(`Patient: ${sale.patient_name || 'Walk-in'}`, `UHID: ${sale.uhid || 'Walk-in'}`)); + if (sale.patient_mobile) { + lines.push(padLine(`Mobile : ${sale.patient_mobile}`, `Age/Sex: ${sale.age || 'N/A'}/${sale.gender || 'N/A'}`)); + } + if (sale.doctor_name) { + lines.push(`Doctor : ${sale.doctor_name}`); + } + if (sale.abha_id || sale.abha_address) { + lines.push(`ABHA : ${sale.abha_id || ''} ${sale.abha_address ? `(${sale.abha_address})` : ''}`); + } + if (sale.abdm_care_context_ref) { + lines.push(`ABDM Ref: ${sale.abdm_care_context_ref}`); + } + if (sale.ipd_id) { + lines.push(`IPD # : ${sale.ipd_id} (Ward: ${sale.ward_name || ''} Bed: ${sale.bed_no || ''})`); + } + lines.push(dashDivider); + + // Header: Item (22), Batch (9), Qty (7), Total (10) + lines.push( + 'Item'.padEnd(22) + + 'Batch'.padEnd(9) + + 'Qty'.padStart(7) + + 'Total'.padStart(10) + ); + lines.push(dashDivider); + + if (items.length === 0) { + lines.push(center('- Return / Refund Only -')); + } else { + items.forEach((it) => { + const name = (it.item_name || '').substring(0, 21).padEnd(22); + const batch = (it.batch_no || '').substring(0, 8).padEnd(9); + const qtyStr = (it.sell_unit === 'Tablet' + ? `${it.total_units}T` + : it.sell_unit === 'Combo' + ? `${it.qty}S+${it.loose_qty}T` + : `${it.qty}`).substring(0, 6).padStart(7); + const amtStr = Number(it.total_amount || 0).toFixed(2).padStart(10); + lines.push(name + batch + qtyStr + amtStr); + + let sub = ` Exp:${(it.expiry_date || '').substring(0, 7)} HSN:${it.hsn_code || '3004'} GST:${it.gst_rate}%`; + if (it.sell_unit === 'Tablet') { + sub += ` @Rs.${it.unit_price}/tab`; + } else { + sub += ` @Rs.${it.unit_mrp}`; + } + lines.push(sub.substring(0, LINE_WIDTH)); + }); + } + + if (returnItems.length > 0) { + lines.push(dashDivider); + lines.push(center('-- RETURNED / EXCHANGED --')); + if (inv.credit_note) { + lines.push(center(`Credit Note: ${inv.credit_note.credit_note_no}`)); + } + returnItems.forEach((it) => { + const name = ('*' + (it.item_name || '')).substring(0, 21).padEnd(22); + const batch = (it.batch_no || '').substring(0, 8).padEnd(9); + const qtyStr = ('-' + (it.sell_unit === 'Tablet' ? `${it.total_units}T` : `${it.qty}`)).substring(0, 6).padStart(7); + const amtStr = ('-' + Math.abs(Number(it.total_amount || 0)).toFixed(2)).padStart(10); + lines.push(name + batch + qtyStr + amtStr); + }); + } + + lines.push(dashDivider); + lines.push(padLine('Gross Total:', `Rs. ${Number(sale.gross_amount || 0).toFixed(2)}`)); + if (Number(sale.discount_amount) > 0) { + lines.push(padLine('Discounts Allowed:', `-Rs. ${Number(sale.discount_amount).toFixed(2)}`)); + } + if (Number(sale.return_amount) > 0) { + lines.push(padLine('Less Return Credit:', `-Rs. ${Number(sale.return_amount).toFixed(2)}`)); + } + lines.push(padLine('Taxable Amount:', `Rs. ${Number(sale.taxable_amount || 0).toFixed(2)}`)); + lines.push(padLine('CGST Amount:', `Rs. ${Number(sale.cgst_amount || 0).toFixed(2)}`)); + lines.push(padLine('SGST Amount:', `Rs. ${Number(sale.sgst_amount || 0).toFixed(2)}`)); + if (Number(sale.round_off) !== 0) { + lines.push(padLine('Round Off:', `Rs. ${Number(sale.round_off || 0).toFixed(2)}`)); + } + lines.push(divider); + lines.push(padLine('NET PAYABLE:', `Rs. ${Number(sale.net_amount || 0).toFixed(2)}`)); + lines.push(divider); + + if (Number(sale.refund_amount) > 0) { + lines.push(padLine('REFUND PAID:', `Rs. ${Number(sale.refund_amount).toFixed(2)}`)); + lines.push(padLine('Refund Mode:', `${sale.refund_mode} ${sale.refund_ref_no || ''}`)); + lines.push(dashDivider); + } + + lines.push(`Settlement: ${sale.payment_mode}`); + if (Number(sale.cash_paid) > 0) lines.push(` Cash Paid : Rs. ${Number(sale.cash_paid).toFixed(2)}`); + if (Number(sale.upi_paid) > 0) lines.push(` UPI/Bank : Rs. ${Number(sale.upi_paid).toFixed(2)} ${sale.upi_ref_no ? `[UTR:${sale.upi_ref_no}]` : ''}`); + if (Number(sale.card_paid) > 0) lines.push(` Card Paid : Rs. ${Number(sale.card_paid).toFixed(2)} ${sale.card_ref_no ? `[Ref:${sale.card_ref_no}]` : ''}`); + if (Number(sale.credit_amount) > 0) lines.push(` IPD Credit: Rs. ${Number(sale.credit_amount).toFixed(2)}`); + if (sale.is_bank_reconciled == 1) lines.push(' Status : Bank Statement Audited'); + else lines.push(' Status : Bank Statement Pending Audit'); + + lines.push(dashDivider); + if (sale.registered_pharmacist_name) { + lines.push(`R.Ph: ${sale.registered_pharmacist_name} (${sale.pharmacist_reg_no || 'N/A'})`); + } + lines.push(center(sale.terms_conditions || 'Thank you! Get well soon.')); + lines.push(divider); + + return lines.join('\n'); + }; + + const copyThermalReceiptText = () => { + if (!lastInvoice) return; + const txt = generateThermalReceiptText(lastInvoice); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(txt).then(() => { + notify('Thermal receipt text copied to clipboard!', 'success'); + }).catch(() => { + fallbackCopyText(txt); + }); + } else { + fallbackCopyText(txt); + } + }; + + const fallbackCopyText = (txt) => { + const ta = document.createElement('textarea'); + ta.value = txt; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + notify('Thermal receipt text copied to clipboard!', 'success'); + } catch (err) { + notify('Unable to copy text.', 'error'); + } + document.body.removeChild(ta); + }; + + const printThermalReceipt = () => { + if (!lastInvoice) return; + const txt = generateThermalReceiptText(lastInvoice); + const win = window.open('', '_blank', 'width=420,height=650'); + if (!win) { + notify('Pop-up blocked. Please allow pop-ups to print thermal receipt.', 'warning'); + return; + } + try { + win.document.title = 'Thermal Receipt - ' + (lastInvoice.sale ? lastInvoice.sale.invoice_no : ''); + const style = win.document.createElement('style'); + style.textContent = '@page { size: 80mm auto; margin: 0; } html, body { margin: 0; padding: 0; background: #fff; } body { padding: 4mm 2mm; font-family: Courier, monospace; font-size: 11px; line-height: 1.25; color: #000; } pre { margin: 0; font-family: inherit; font-size: inherit; white-space: pre-wrap; word-break: break-all; }'; + win.document.head.appendChild(style); + const pre = win.document.createElement('pre'); + pre.textContent = txt; + win.document.body.appendChild(pre); + setTimeout(() => { + try { + win.focus(); + win.print(); + setTimeout(() => { try { win.close(); } catch (err) {} }, 1000); + } catch (err) {} + }, 250); + } catch (e) { + console.error('Print thermal error:', e); + } + }; + + const printPdf = (format) => { + if (!lastInvoice || !lastInvoice.sale) return; + const saleId = lastInvoice.sale.sale_id; + const url = `/api/v1/medical-store/sales/invoice-pdf/${saleId}?format=${format || 'a4'}`; + + let iframe = document.getElementById('pdf-print-iframe'); + if (!iframe) { + iframe = document.createElement('iframe'); + iframe.id = 'pdf-print-iframe'; + iframe.style.position = 'fixed'; + iframe.style.right = '0'; + iframe.style.bottom = '0'; + iframe.style.width = '0'; + iframe.style.height = '0'; + iframe.style.border = '0'; + document.body.appendChild(iframe); + } + iframe.src = url; + iframe.onload = function() { + try { + iframe.contentWindow.focus(); + iframe.contentWindow.print(); + } catch (e) { + window.open(url, '_blank'); + } + }; + }; + + const downloadPdf = (format) => { + if (!lastInvoice || !lastInvoice.sale) return; + const saleId = lastInvoice.sale.sale_id; + window.open(`/api/v1/medical-store/sales/invoice-pdf/${saleId}?format=${format || 'a4'}&download=1`, '_blank'); + }; + // Global Keyboard Shortcuts (Desktop Pharmacy Standard) useEffect(() => { const handleGlobalKeyDown = (e) => { @@ -2998,21 +3290,21 @@ function PosCounter({ store, notify }) { className={`btn ${printFormat === 'thermal' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light'}`} onClick={() => setPrintFormat('thermal')} > - Thermal 80mm (1-5 Items) + Thermal (Text) @@ -3061,7 +3353,7 @@ function PosCounter({ store, notify }) { -
+
- - - + + +
- + + {printFormat === 'thermal' ? ( + <> + + + + ) : ( + <> + + + + )} + +
- )} - {Number(lastInvoice.sale.return_amount) > 0 && ( -
- Less Return Credit: - -₹{lastInvoice.sale.return_amount} -
- )} -
- NET PAYABLE: - ₹{lastInvoice.sale.net_amount} -
- {Number(lastInvoice.sale.refund_amount) > 0 && ( -
- REFUND PAID TO PATIENT: - ₹{lastInvoice.sale.refund_amount} ({lastInvoice.sale.refund_mode}) -
- )} -
-
Settlement: {lastInvoice.sale.payment_mode}
- {Number(lastInvoice.sale.cash_paid) > 0 &&
• Cash Paid: ₹{lastInvoice.sale.cash_paid}
} - {Number(lastInvoice.sale.upi_paid) > 0 && ( -
• UPI/Bank: ₹{lastInvoice.sale.upi_paid} {lastInvoice.sale.upi_ref_no ? `[UTR: ${lastInvoice.sale.upi_ref_no}]` : ''} {lastInvoice.sale.bank_name ? `(${lastInvoice.sale.bank_name})` : ''}
- )} - {Number(lastInvoice.sale.card_paid) > 0 && ( -
• Card Paid: ₹{lastInvoice.sale.card_paid} {lastInvoice.sale.card_ref_no ? `[Ref: ${lastInvoice.sale.card_ref_no}]` : ''}
- )} - {Number(lastInvoice.sale.credit_amount) > 0 &&
• IPD Credit: ₹{lastInvoice.sale.credit_amount}
} - {Number(lastInvoice.sale.refund_amount) > 0 && ( -
• Refund Issued: ₹{lastInvoice.sale.refund_amount} via {lastInvoice.sale.refund_mode} {lastInvoice.sale.refund_ref_no ? `[Ref: ${lastInvoice.sale.refund_ref_no}]` : ''}
- )} - {lastInvoice.sale.is_bank_reconciled == 1 &&
✓ Bank Statement Audited
} -
-
- R.Ph: {lastInvoice.sale.registered_pharmacist_name || 'Pharmacist'} {lastInvoice.sale.pharmacist_hpr_id ? `(HPR: ${lastInvoice.sale.pharmacist_hpr_id})` : ''} -
-
- {lastInvoice.sale.terms_conditions || 'Thank you! Get well soon.'}
+
+                                        {generateThermalReceiptText(lastInvoice)}
+                                    
)} {/* Format 2: A5 Half-Page Invoice (Normal OPD/Walk-in) */} {printFormat === 'a5' && (
+
+
+ +
+
A5 Retail Tax Invoice (PDF Ready)
+ Direct server vector PDF • Zero clipped columns +
+
+
+ + +
+
{lastInvoice.sale.store_name}
@@ -3407,6 +3673,23 @@ function PosCounter({ store, notify }) { {/* Format 3: A4 Comprehensive Hospital Tax Invoice (IPD & Large Bills) */} {printFormat === 'a4' && (
+
+
+ +
+
A4 Hospital GST Tax Invoice (PDF Ready)
+ Server-rendered vector PDF • Zero clipped columns +
+
+
+ + +
+

{lastInvoice.sale.store_name}

@@ -3709,13 +3992,29 @@ function PosCounter({ store, notify }) { Format: {printFormat} • Bill No: {lastInvoice.sale.invoice_no} -
+
- + {printFormat === 'thermal' ? ( + <> + + + + ) : ( + <> + + + + )}