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 .= '
+ ';
+
+ // 2. Patient & Doctor Info Table
+ $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') . '
+
+ ';
+
+ if (!empty($sale['abha_id']) || !empty($sale['abha_address']) || !empty($sale['abdm_care_context_ref'])) {
+ $html .= '
+
+
+ ✓ 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']) : '') . '
+
+ ';
+ }
+
+ if (!empty($sale['ipd_id'])) {
+ $html .= '
+
+
+ 🏥 IPD Admission #' . $esc($sale['ipd_id']) . ' • Ward: ' . $esc($sale['ward_name'] ?: 'General') . ' • Bed: ' . $esc($sale['bed_no'] ?: 'Bed') . ' (Charged to IPD Running Bill)
+
+ ';
+ }
+
+ $html .= '
+
+
';
+
+ // 3. Dispensed Medicines Table
+ $html .= '
+
+
+
+ #
+ Medicine & Formulation
+ HSN
+ Batch
+ Exp
+ Qty & Unit
+ Rate
+ Taxable
+ CGST
+ SGST
+ Total
+
+
+ ';
+
+ if (empty($items)) {
+ $html .= '
+
+
+ - No new medicines dispensed (Sales Return & Customer Refund Bill) -
+
+ ';
+ } 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 .= '
+
+ ' . ($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']) . '
+ ';
+ }
+ }
+
+ $html .= '
+
+
';
+
+ // 4. Returned / Exchanged Medicines Table (if any)
+ if (!empty($returnItems)) {
+ $html .= '
+
+ RETURNED / EXCHANGED MEDICINES ' . ($creditNote ? '(Credit Note: ' . $esc($creditNote['credit_note_no']) . ')' : '') . '
+
+
+
+
+ #
+ Returned Medicine
+ HSN
+ Batch
+ Original Bill
+ Condition
+ Qty Returned
+ Rate
+ Taxable
+ Credit Total
+
+
+ ';
+
+ foreach ($returnItems as $rIdx => $rit) {
+ $retQtyStr = '-' . (($rit['sell_unit'] === 'Tablet') ? ($rit['total_units'] . ' Tab') : ($rit['qty'] . ' ' . ($rit['unit_pack'] ?: 'Unit')));
+ $html .= '
+
+ ' . ($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'])) . '
+ ';
+ }
+
+ $html .= '
+
+
';
+ }
+
+ // 5. GST Tax Breakdown Table (HSN Summary)
+ if (!empty($hsnSummary)) {
+ $html .= '
+
+ GST Tax Breakdown (Indian GST Standard):
+
+
+
+
+ HSN Code
+ GST Rate
+ Taxable Value
+ CGST
+ SGST
+ Total Tax
+
+
+ ';
+
+ foreach ($hsnSummary as $h) {
+ $html .= '
+
+ ' . $esc($h['hsn_code']) . '
+ ' . $esc($h['gst_rate']) . '%
+ ' . $curr($h['taxable_value']) . '
+ ' . $curr($h['cgst_amount']) . '
+ ' . $curr($h['sgst_amount']) . '
+ ' . $curr($h['total_tax']) . '
+ ';
+ }
+
+ $html .= '
+
+
';
+ }
+
+ // 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.")) . '
+
+
+
+
+
+
+ Gross Items Total:
+ ' . $curr($sale['gross_amount']) . '
+ ';
+
+ if ((float)$sale['discount_amount'] > 0) {
+ $html .= '
+
+ Total Discount Allowed:
+ -' . $curr($sale['discount_amount']) . '
+ ';
+ }
+
+ if ((float)$sale['return_amount'] > 0) {
+ $html .= '
+
+ Less Return Credit:
+ -' . $curr($sale['return_amount']) . '
+ ';
+ }
+
+ $html .= '
+
+ Taxable Amount:
+ ' . $curr($sale['taxable_amount']) . '
+
+
+ CGST Amount:
+ ' . $curr($sale['cgst_amount']) . '
+
+
+ SGST Amount:
+ ' . $curr($sale['sgst_amount']) . '
+ ';
+
+ if ((float)$sale['round_off'] != 0) {
+ $html .= '
+
+ Round Off:
+ ' . $curr($sale['round_off']) . '
+ ';
+ }
+
+ $html .= '
+
+
+
+
+
+ NET PAYABLE:
+ ' . $curr($sale['net_amount']) . '
+
+
+
+
+ ';
+
+ if ((float)$sale['refund_amount'] > 0) {
+ $html .= '
+
+
+
+ REFUND PAID TO CUSTOMER: ' . $curr($sale['refund_amount']) . ' (' . $esc($sale['refund_mode']) . ')
+
+
+ ';
+ }
+
+ $html .= '
+
+
+
+
+
+
+ ';
+
+ 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)
setPrintFormat('a5')}
>
- A5 Invoice (OPD)
+ A5 (PDF)
setPrintFormat('a4')}
>
- A4 Tax Bill (IPD)
+ A4 (PDF)
@@ -3061,7 +3353,7 @@ function PosCounter({ store, notify }) {
-
+
viewAbdmBundle(lastInvoice.sale.sale_id)}
@@ -3071,13 +3363,57 @@ function PosCounter({ store, notify }) {
FHIR R4
- setPrintFormat('thermal')}>80mm
- setPrintFormat('a5')}>A5
- setPrintFormat('a4')}>A4
+ setPrintFormat('thermal')}>
+ Thermal (Text)
+
+ setPrintFormat('a5')}>
+ A5 (PDF)
+
+ setPrintFormat('a4')}>
+ A4 (PDF)
+
-
window.print()} title="Print Bill">
- Print
-
+
+ {printFormat === 'thermal' ? (
+ <>
+
+ Copy Text
+
+
+ Print Thermal
+
+ >
+ ) : (
+ <>
+
downloadPdf(printFormat)}
+ title={`Download ${printFormat.toUpperCase()} PDF Document`}
+ style={{ fontSize: '0.75rem' }}
+ >
+ PDF
+
+
printPdf(printFormat)}
+ title={`Print Vector-Clean ${printFormat.toUpperCase()} PDF`}
+ style={{ fontSize: '0.75rem' }}
+ >
+ Print PDF ({printFormat.toUpperCase()})
+
+ >
+ )}
+
- {/* Format 1: 80mm Thermal Slip (Single or 1-5 Items) */}
+ {/* Format 1: 80mm Thermal Slip in Pure Text Layout */}
{printFormat === 'thermal' && (
-
-
{lastInvoice.sale.store_name}
-
{lastInvoice.sale.building_name}, {lastInvoice.sale.floor_no}
- {lastInvoice.sale.abdm_hfr_id && (
-
HFR ID: {lastInvoice.sale.abdm_hfr_id}
- )}
-
DL 20B: {lastInvoice.sale.drug_license_no_20b}
-
DL 21B: {lastInvoice.sale.drug_license_no_21b}
-
GSTIN: {lastInvoice.sale.gstin}
-
-
Bill No: {lastInvoice.sale.invoice_no}
-
Date: {lastInvoice.sale.sale_date}
-
Patient: {lastInvoice.sale.patient_name} {lastInvoice.sale.uhid ? `(${lastInvoice.sale.uhid})` : ''}
- {(lastInvoice.sale.abha_id || lastInvoice.sale.abha_address) && (
-
ABHA: {lastInvoice.sale.abha_id || ''} {lastInvoice.sale.abha_address ? `(${lastInvoice.sale.abha_address})` : ''}
- )}
- {lastInvoice.sale.abdm_care_context_ref && (
-
Care Context: {lastInvoice.sale.abdm_care_context_ref}
- )}
-
Doctor: {lastInvoice.sale.doctor_name}
-
-
-
-
- Item
- Batch
- Qty
- Total
-
-
-
- {lastInvoice.items.length === 0 ? (
-
-
- - Return / Refund Only (No New Meds) -
-
-
- ) : (
- lastInvoice.items.map((it, i) => (
-
-
- {it.item_name}
- {it.sell_unit === 'Tablet' && (
- @ ₹{it.unit_price}/tab
- )}
-
- {it.batch_no}
-
- {it.sell_unit === 'Tablet'
- ? `${it.total_units} Tab`
- : it.sell_unit === 'Combo'
- ? `${it.qty} Str + ${it.loose_qty} Tab`
- : (Number(it.units_per_pack) > 1 ? `${it.qty} Strip` : `${it.qty} ${it.unit_pack || 'Unit'}`)}
-
- ₹{it.total_amount}
-
- ))
- )}
-
-
- {lastInvoice.return_items && lastInvoice.return_items.length > 0 && (
- <>
-
- -- RETURNED / EXCHANGED --
- {lastInvoice.credit_note && CN: {lastInvoice.credit_note.credit_note_no} }
-
-
-
- {lastInvoice.return_items.map((it, i) => (
-
-
- {it.item_name}
-
- {it.ref_invoice_no ? `Ref: ${it.ref_invoice_no}` : ''} ({it.return_condition})
-
-
- {it.batch_no}
-
- -{it.sell_unit === 'Tablet' ? `${it.total_units} Tab` : `${it.qty} ${it.unit_pack || 'Unit'}`}
-
- -₹{Math.abs(Number(it.total_amount)).toFixed(2)}
-
- ))}
-
-
- >
- )}
-
-
- Gross Total:
- ₹{lastInvoice.sale.gross_amount}
-
- {Number(lastInvoice.sale.discount_amount) > 0 && (
-
-
Total Discount:
-
-₹{lastInvoice.sale.discount_amount}
+
+
+
+ 80mm Thermal Receipt (Plain Text)
+
+
+
+ Copy Text
+
+
+ Print 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
+
+
+
+ downloadPdf('a5')} style={{ fontSize: '0.72rem' }}>
+ Download PDF
+
+ printPdf('a5')} style={{ fontSize: '0.72rem' }}>
+ Print PDF
+
+
+
{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
+
+
+
+ downloadPdf('a4')} style={{ fontSize: '0.72rem' }}>
+ Download PDF
+
+ printPdf('a4')} style={{ fontSize: '0.72rem' }}>
+ Print PDF
+
+
+
{lastInvoice.sale.store_name}
@@ -3709,13 +3992,29 @@ function PosCounter({ store, notify }) {
Format: {printFormat} • Bill No: {lastInvoice.sale.invoice_no}
-
+
setIsPrintOpen(false)}>
Done / Close (Esc)
- window.print()}>
- Print Bill ({printFormat.toUpperCase()})
-
+ {printFormat === 'thermal' ? (
+ <>
+
+ Copy Text
+
+
+ Print Thermal Receipt
+
+ >
+ ) : (
+ <>
+ downloadPdf(printFormat)}>
+ Download PDF
+
+ printPdf(printFormat)}>
+ Print PDF ({printFormat.toUpperCase()})
+
+ >
+ )}
>