feat(medical-store): add server-side A4/A5 vector PDF generation with mPDF and 48-col plain-text thermal receipt printing

This commit is contained in:
DevSoftTech Server 2026-09-10 08:09:10 +05:30
parent c8a2d50df9
commit 82cae89fae
3 changed files with 997 additions and 167 deletions

View File

@ -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');

View File

@ -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 = '<barcode code="' . $esc($qrCodeValue) . '" size="' . $qrSize . '" type="QR" error="M" class="barcode" />';
}
$html = '
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: dejavusans, sans-serif;
font-size: ' . $fontSize . ';
color: #0f172a;
line-height: 1.25;
}
table {
width: 100%;
border-collapse: collapse;
}
.header-table td {
vertical-align: top;
}
.border-box {
border: 0.3mm solid #94a3b8;
border-radius: 1.5mm;
padding: ' . ($isA5 ? '4px' : '6px') . ';
background-color: #f8fafc;
margin-bottom: ' . ($isA5 ? '4px' : '6px') . ';
}
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: ' . ($isA5 ? '4px' : '6px') . ';
}
.items-table th {
background-color: #0f766e;
color: #ffffff;
font-weight: bold;
font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';
padding: ' . $thPadding . ';
border: 0.2mm solid #0f766e;
text-align: left;
}
.items-table td {
padding: ' . $tdPadding . ';
border: 0.2mm solid #cbd5e1;
font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';
vertical-align: top;
}
.items-table tr:nth-child(even) td {
background-color: #f8fafc;
}
.return-table th {
background-color: #be123c !important;
border-color: #be123c !important;
}
.return-table td {
border-color: #fecdd3 !important;
background-color: #fff1f2 !important;
color: #9f1239;
}
.hsn-table th {
background-color: #334155;
color: #ffffff;
font-size: ' . ($isA5 ? '6pt' : '7pt') . ';
padding: ' . ($isA5 ? '2px 3px' : '3px 4px') . ';
border: 0.2mm solid #334155;
}
.hsn-table td {
font-size: ' . ($isA5 ? '6pt' : '7pt') . ';
padding: ' . ($isA5 ? '2px 3px' : '3px 4px') . ';
border: 0.2mm solid #cbd5e1;
text-align: center;
}
.text-center { text-align: center; }
.text-right { text-align: right; }
.fw-bold { font-weight: bold; }
.text-muted { color: #64748b; }
.text-primary { color: #0284c7; }
.text-danger { color: #dc2626; }
.text-success { color: #16a34a; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 1mm;
font-size: 6pt;
font-weight: bold;
}
.badge-teal { background-color: #ccfbf1; color: #0f766e; border: 0.2mm solid #14b8a6; }
.badge-green { background-color: #dcfce7; color: #15803d; border: 0.2mm solid #22c55e; }
.badge-blue { background-color: #e0f2fe; color: #0369a1; border: 0.2mm solid #0ea5e9; }
.total-box {
background-color: #f0fdf4;
border: 0.4mm solid #16a34a;
padding: ' . ($isA5 ? '3px 5px' : '5px 7px') . ';
border-radius: 1.5mm;
color: #14532d;
}
</style>
</head>
<body>';
// 1. Header Table
$html .= '
<table class="header-table" style="margin-bottom: ' . ($isA5 ? '4px' : '8px') . ';">
<tr>
<td style="width: 66%;">
<div style="font-size: ' . ($isA5 ? '11pt' : '13pt') . '; font-weight: bold; color: #0f766e; margin-bottom: 2px;">
' . $esc($sale['store_name'] ?: 'City Hospital Central Pharmacy') . '
</div>
<div style="color: #475569; font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';">
' . $esc($sale['building_name'] ?: 'Main Hospital Block A') . ', ' . $esc($sale['floor_no'] ?: 'Ground Floor') . ($sale['store_address'] ? ', ' . $esc($sale['store_address']) : '') . '
</div>
<div style="font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . '; margin-top: 2px;">
<strong>DL 20-B:</strong> ' . $esc($sale['drug_license_no_20b'] ?: 'N/A') . ' | <strong>DL 21-B:</strong> ' . $esc($sale['drug_license_no_21b'] ?: 'N/A') . '
' . ($sale['fssai_no'] ? ' | <strong>FSSAI:</strong> ' . $esc($sale['fssai_no']) : '') . '
</div>
<div style="font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';">
<strong>GSTIN:</strong> ' . $esc($sale['gstin'] ?: 'N/A') . ' | <strong>State:</strong> ' . $esc($sale['state_name'] ?: 'Delhi') . ' (Code: ' . $esc($sale['state_code'] ?: '07') . ')
' . ($sale['abdm_hfr_id'] ? ' | <strong>ABDM HFR:</strong> ' . $esc($sale['abdm_hfr_id']) : '') . '
</div>
</td>
<td style="width: 34%; text-align: right;">
<table style="width: 100%;">
<tr>
<td style="text-align: right; vertical-align: top;">
<div style="display: inline-block; background-color: #0f766e; color: #ffffff; padding: 2px 6px; font-weight: bold; font-size: ' . ($isA5 ? '7pt' : '8pt') . '; border-radius: 1mm; margin-bottom: 2px;">
' . ($isA5 ? 'RETAIL TAX INVOICE' : 'HOSPITAL GST TAX INVOICE') . '
</div>
<div style="font-size: ' . ($isA5 ? '9pt' : '11pt') . '; font-weight: bold; color: #0f172a;">
' . $esc($sale['invoice_no']) . '
</div>
<div style="color: #64748b; font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';">
' . $esc($sale['sale_date']) . '
</div>
</td>
' . ($qrHtml ? '<td style="width: 45px; text-align: right; vertical-align: top; padding-left: 5px;">' . $qrHtml . '</td>' : '') . '
</tr>
</table>
</td>
</tr>
</table>';
// 2. Patient & Doctor Info Table
$html .= '
<div class="border-box">
<table style="font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';">
<tr>
<td style="width: 35%;">
<strong>Patient Name:</strong> ' . $esc($sale['patient_name'] ?: 'Walk-in Customer') . '<br>
<strong>UHID No:</strong> <span class="text-primary fw-bold">' . $esc($sale['uhid'] ?: 'Walk-in') . '</span>
</td>
<td style="width: 30%;">
<strong>Phone:</strong> ' . $esc($sale['patient_mobile'] ?: 'N/A') . '<br>
<strong>Age / Gender:</strong> ' . $esc($sale['age'] ?: 'N/A') . ' / ' . $esc($sale['gender'] ?: 'N/A') . '
</td>
<td style="width: 35%;">
<strong>Prescribing Doctor:</strong> ' . $esc($sale['doctor_name'] ?: 'Dr. Consultant') . '<br>
<strong>Doctor Reg No:</strong> ' . $esc($sale['doctor_reg_no'] ?: 'N/A') . '
</td>
</tr>';
if (!empty($sale['abha_id']) || !empty($sale['abha_address']) || !empty($sale['abdm_care_context_ref'])) {
$html .= '
<tr>
<td colspan="3" style="padding-top: 3px; border-top: 0.2mm solid #e2e8f0; color: #15803d; font-weight: bold;">
&#10003; 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']) : '') . '
</td>
</tr>';
}
if (!empty($sale['ipd_id'])) {
$html .= '
<tr>
<td colspan="3" style="padding-top: 2px; border-top: 0.2mm solid #e2e8f0; color: #b91c1c; font-weight: bold;">
&#127973; IPD Admission #' . $esc($sale['ipd_id']) . ' &bull; Ward: ' . $esc($sale['ward_name'] ?: 'General') . ' &bull; Bed: ' . $esc($sale['bed_no'] ?: 'Bed') . ' (Charged to IPD Running Bill)
</td>
</tr>';
}
$html .= '
</table>
</div>';
// 3. Dispensed Medicines Table
$html .= '
<table class="items-table">
<thead>
<tr>
<th style="width: 3%; text-align: center;">#</th>
<th style="width: 27%;">Medicine &amp; Formulation</th>
<th style="width: 8%; text-align: center;">HSN</th>
<th style="width: 10%; text-align: center;">Batch</th>
<th style="width: 8%; text-align: center;">Exp</th>
<th style="width: 10%; text-align: center;">Qty &amp; Unit</th>
<th style="width: 8%; text-align: right;">Rate</th>
<th style="width: 8%; text-align: right;">Taxable</th>
<th style="width: 5%; text-align: right;">CGST</th>
<th style="width: 5%; text-align: right;">SGST</th>
<th style="width: 8%; text-align: right;">Total</th>
</tr>
</thead>
<tbody>';
if (empty($items)) {
$html .= '
<tr>
<td colspan="11" class="text-center text-muted" style="padding: 10px;">
- No new medicines dispensed (Sales Return &amp; Customer Refund Bill) -
</td>
</tr>';
} 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']) . '<span style="font-size:5.5pt;color:#64748b;">/tab</span>') : $curr($it['unit_mrp']);
$html .= '
<tr>
<td style="text-align: center;">' . ($idx + 1) . '</td>
<td>
<strong style="color: #0f172a;">' . $esc($it['item_name']) . '</strong>';
if ((int)$it['units_per_pack'] > 1 && $it['sell_unit'] !== 'Tablet') {
$html .= '<div style="font-size: 6pt; color: #64748b;">Pack: 1 Strip of ' . $esc($it['units_per_pack']) . ' Tabs</div>';
}
if (!empty($it['snomed_ct_code'])) {
$html .= '<div style="font-size: 5.5pt; color: #0284c7;">SNOMED: ' . $esc($it['snomed_ct_code']) . '</div>';
}
$html .= '
</td>
<td style="text-align: center;">' . $esc($it['hsn_code'] ?: '3004') . '</td>
<td style="text-align: center; font-weight: bold;">' . $esc($it['batch_no']) . '</td>
<td style="text-align: center;">' . $esc(substr($it['expiry_date'] ?? '', 0, 7)) . '</td>
<td style="text-align: center; font-weight: bold;">' . $esc($qtyUnitStr) . '</td>
<td style="text-align: right;">' . $rateStr . '</td>
<td style="text-align: right;">' . $curr($it['taxable_value']) . '</td>
<td style="text-align: right;">' . $curr($it['cgst_amount']) . '</td>
<td style="text-align: right;">' . $curr($it['sgst_amount']) . '</td>
<td style="text-align: right; font-weight: bold;">' . $curr($it['total_amount']) . '</td>
</tr>';
}
}
$html .= '
</tbody>
</table>';
// 4. Returned / Exchanged Medicines Table (if any)
if (!empty($returnItems)) {
$html .= '
<div style="background-color: #ffe4e6; border: 0.2mm solid #e11d48; padding: 3px 5px; font-weight: bold; color: #9f1239; font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . ';">
RETURNED / EXCHANGED MEDICINES ' . ($creditNote ? '(Credit Note: ' . $esc($creditNote['credit_note_no']) . ')' : '') . '
</div>
<table class="items-table return-table" style="margin-top: -0.2mm;">
<thead>
<tr>
<th style="width: 3%; text-align: center;">#</th>
<th style="width: 27%;">Returned Medicine</th>
<th style="width: 8%; text-align: center;">HSN</th>
<th style="width: 10%; text-align: center;">Batch</th>
<th style="width: 10%; text-align: center;">Original Bill</th>
<th style="width: 8%; text-align: center;">Condition</th>
<th style="width: 10%; text-align: center;">Qty Returned</th>
<th style="width: 8%; text-align: right;">Rate</th>
<th style="width: 8%; text-align: right;">Taxable</th>
<th style="width: 8%; text-align: right; color: #9f1239;">Credit Total</th>
</tr>
</thead>
<tbody>';
foreach ($returnItems as $rIdx => $rit) {
$retQtyStr = '-' . (($rit['sell_unit'] === 'Tablet') ? ($rit['total_units'] . ' Tab') : ($rit['qty'] . ' ' . ($rit['unit_pack'] ?: 'Unit')));
$html .= '
<tr>
<td style="text-align: center;">' . ($rIdx + 1) . '</td>
<td><strike>' . $esc($rit['item_name']) . '</strike></td>
<td style="text-align: center;">' . $esc($rit['hsn_code'] ?: '3004') . '</td>
<td style="text-align: center;">' . $esc($rit['batch_no']) . '</td>
<td style="text-align: center;">' . $esc($rit['ref_invoice_no'] ?: 'N/A') . '</td>
<td style="text-align: center;">' . $esc($rit['return_condition'] ?: 'GOOD') . '</td>
<td style="text-align: center; font-weight: bold; color: #b91c1c;">' . $esc($retQtyStr) . '</td>
<td style="text-align: right;">' . $curr($rit['unit_price']) . '</td>
<td style="text-align: right;">-' . $curr(abs((float)$rit['taxable_value'])) . '</td>
<td style="text-align: right; font-weight: bold; color: #b91c1c;">-' . $curr(abs((float)$rit['total_amount'])) . '</td>
</tr>';
}
$html .= '
</tbody>
</table>';
}
// 5. GST Tax Breakdown Table (HSN Summary)
if (!empty($hsnSummary)) {
$html .= '
<div style="font-size: ' . ($isA5 ? '6pt' : '7pt') . '; font-weight: bold; color: #475569; margin-bottom: 2px;">
GST Tax Breakdown (Indian GST Standard):
</div>
<table class="hsn-table" style="margin-bottom: ' . ($isA5 ? '4px' : '6px') . ';">
<thead>
<tr>
<th style="width: 16%;">HSN Code</th>
<th style="width: 16%;">GST Rate</th>
<th style="width: 20%;">Taxable Value</th>
<th style="width: 16%;">CGST</th>
<th style="width: 16%;">SGST</th>
<th style="width: 16%;">Total Tax</th>
</tr>
</thead>
<tbody>';
foreach ($hsnSummary as $h) {
$html .= '
<tr>
<td>' . $esc($h['hsn_code']) . '</td>
<td>' . $esc($h['gst_rate']) . '%</td>
<td style="text-align: right;">' . $curr($h['taxable_value']) . '</td>
<td style="text-align: right;">' . $curr($h['cgst_amount']) . '</td>
<td style="text-align: right;">' . $curr($h['sgst_amount']) . '</td>
<td style="text-align: right; font-weight: bold;">' . $curr($h['total_tax']) . '</td>
</tr>';
}
$html .= '
</tbody>
</table>';
}
// 6. Summary Totals & Signatures
$html .= '
<table style="width: 100%; border-top: 0.3mm solid #94a3b8; padding-top: ' . ($isA5 ? '3px' : '5px') . ';">
<tr>
<td style="width: 58%; vertical-align: top; padding-right: 8px;">
<div style="font-size: ' . ($isA5 ? '6pt' : '7pt') . '; color: #334155; margin-bottom: 3px;">
<strong>Registered Pharmacist:</strong> ' . $esc($sale['registered_pharmacist_name'] ?: 'Pharmacist') . ' (Reg No: ' . $esc($sale['pharmacist_reg_no'] ?: 'N/A') . ')
' . ($sale['pharmacist_hpr_id'] ? '<br><strong>HPR ID:</strong> ' . $esc($sale['pharmacist_hpr_id']) : '') . '
</div>
<div style="background-color: #f8fafc; border: 0.2mm solid #cbd5e1; border-radius: 1mm; padding: 4px; font-size: ' . ($isA5 ? '6pt' : '7pt') . '; margin-bottom: 4px;">
<strong>Payment Settlement:</strong> ' . $esc($sale['payment_mode']) . '<br>';
if ((float)$sale['cash_paid'] > 0) {
$html .= '&bull; Cash: ' . $curr($sale['cash_paid']) . ' ';
}
if ((float)$sale['upi_paid'] > 0) {
$html .= '&bull; 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 .= '&bull; Card: ' . $curr($sale['card_paid']) . ($sale['card_ref_no'] ? ' [Ref: ' . $esc($sale['card_ref_no']) . ']' : '') . ' ';
}
if ((float)$sale['credit_amount'] > 0) {
$html .= '&bull; IPD Credit: ' . $curr($sale['credit_amount']) . ' ';
}
$html .= '<br>' . ($sale['is_bank_reconciled'] == 1 ? '<span style="color:#16a34a;font-weight:bold;">&#10003; Bank Statement Audited</span>' : '<span style="color:#d97706;">Bank Statement: Pending Audit</span>');
$html .= '
</div>
<div style="font-size: 5.5pt; color: #64748b; line-height: 1.2;">
' . 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.")) . '
</div>
</td>
<td style="width: 42%; vertical-align: top;">
<table style="font-size: ' . ($isA5 ? '6.5pt' : '7.5pt') . '; line-height: 1.35;">
<tr>
<td>Gross Items Total:</td>
<td class="text-right">' . $curr($sale['gross_amount']) . '</td>
</tr>';
if ((float)$sale['discount_amount'] > 0) {
$html .= '
<tr>
<td class="text-danger">Total Discount Allowed:</td>
<td class="text-right text-danger">-' . $curr($sale['discount_amount']) . '</td>
</tr>';
}
if ((float)$sale['return_amount'] > 0) {
$html .= '
<tr>
<td class="text-danger fw-bold">Less Return Credit:</td>
<td class="text-right text-danger fw-bold">-' . $curr($sale['return_amount']) . '</td>
</tr>';
}
$html .= '
<tr>
<td>Taxable Amount:</td>
<td class="text-right">' . $curr($sale['taxable_amount']) . '</td>
</tr>
<tr>
<td>CGST Amount:</td>
<td class="text-right">' . $curr($sale['cgst_amount']) . '</td>
</tr>
<tr>
<td>SGST Amount:</td>
<td class="text-right">' . $curr($sale['sgst_amount']) . '</td>
</tr>';
if ((float)$sale['round_off'] != 0) {
$html .= '
<tr>
<td>Round Off:</td>
<td class="text-right">' . $curr($sale['round_off']) . '</td>
</tr>';
}
$html .= '
<tr>
<td colspan="2" style="padding-top: 3px;">
<div class="total-box">
<table style="width: 100%; font-size: ' . ($isA5 ? '8pt' : '10pt') . '; font-weight: bold;">
<tr>
<td>NET PAYABLE:</td>
<td style="text-align: right;">' . $curr($sale['net_amount']) . '</td>
</tr>
</table>
</div>
</td>
</tr>';
if ((float)$sale['refund_amount'] > 0) {
$html .= '
<tr>
<td colspan="2" style="padding-top: 3px;">
<div style="background-color: #ffe4e6; border: 0.3mm solid #e11d48; padding: 3px 5px; border-radius: 1mm; color: #9f1239; font-weight: bold; font-size: ' . ($isA5 ? '7pt' : '8pt') . ';">
REFUND PAID TO CUSTOMER: ' . $curr($sale['refund_amount']) . ' (' . $esc($sale['refund_mode']) . ')
</div>
</td>
</tr>';
}
$html .= '
</table>
</td>
</tr>
</table>
</body>
</html>';
return $html;
}
public function recentSales()
{
$storeId = (int)($this->request->getGet('store_id') ?? 1);

View File

@ -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)
<i className="bi bi-receipt me-1"></i> Thermal (Text)
</button>
<button
type="button"
className={`btn ${printFormat === 'a5' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light'}`}
onClick={() => setPrintFormat('a5')}
>
A5 Invoice (OPD)
<i className="bi bi-file-earmark-pdf me-1"></i> A5 (PDF)
</button>
<button
type="button"
className={`btn ${printFormat === 'a4' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light'}`}
onClick={() => setPrintFormat('a4')}
>
A4 Tax Bill (IPD)
<i className="bi bi-file-earmark-pdf me-1"></i> A4 (PDF)
</button>
</div>
</div>
@ -3061,7 +3353,7 @@ function PosCounter({ store, notify }) {
</div>
</div>
<div className="d-flex align-items-center gap-1.5">
<div className="d-flex align-items-center gap-1.5 flex-wrap">
<button
className="btn btn-xs btn-outline-success text-white py-1 px-2"
onClick={() => viewAbdmBundle(lastInvoice.sale.sale_id)}
@ -3071,13 +3363,57 @@ function PosCounter({ store, notify }) {
<i className="bi bi-filetype-json me-1"></i> FHIR R4
</button>
<div className="btn-group btn-group-sm">
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'thermal' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('thermal')}>80mm</button>
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'a5' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('a5')}>A5</button>
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'a4' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('a4')}>A4</button>
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'thermal' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('thermal')}>
<i className="bi bi-receipt me-1"></i> Thermal (Text)
</button>
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'a5' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('a5')}>
<i className="bi bi-file-earmark-pdf me-1"></i> A5 (PDF)
</button>
<button className={`btn btn-xs py-1 px-2 ${printFormat === 'a4' ? 'btn-warning text-dark fw-bold' : 'btn-outline-light text-white'}`} style={{ fontSize: '0.72rem' }} onClick={() => setPrintFormat('a4')}>
<i className="bi bi-file-earmark-pdf me-1"></i> A4 (PDF)
</button>
</div>
<button className="btn btn-sm btn-primary py-1 px-2.5 ms-1 fw-bold" onClick={() => window.print()} title="Print Bill">
<i className="bi bi-printer me-1"></i> Print
</button>
{printFormat === 'thermal' ? (
<>
<button
className="btn btn-sm btn-outline-light py-1 px-2 text-white"
onClick={copyThermalReceiptText}
title="Copy plain-text receipt to clipboard"
style={{ fontSize: '0.75rem' }}
>
<i className="bi bi-clipboard me-1 text-warning"></i> Copy Text
</button>
<button
className="btn btn-sm btn-warning text-dark fw-bold py-1 px-2.5 ms-0.5"
onClick={printThermalReceipt}
title="Print on 80mm Thermal Receipt Printer"
style={{ fontSize: '0.75rem' }}
>
<i className="bi bi-printer-fill me-1"></i> Print Thermal
</button>
</>
) : (
<>
<button
className="btn btn-sm btn-outline-light py-1 px-2 text-white"
onClick={() => downloadPdf(printFormat)}
title={`Download ${printFormat.toUpperCase()} PDF Document`}
style={{ fontSize: '0.75rem' }}
>
<i className="bi bi-download me-1 text-info"></i> PDF
</button>
<button
className="btn btn-sm btn-primary py-1 px-2.5 ms-0.5 fw-bold"
onClick={() => printPdf(printFormat)}
title={`Print Vector-Clean ${printFormat.toUpperCase()} PDF`}
style={{ fontSize: '0.75rem' }}
>
<i className="bi bi-printer me-1"></i> Print PDF ({printFormat.toUpperCase()})
</button>
</>
)}
<button
type="button"
className="btn btn-sm btn-outline-light rounded-pill px-2.5 py-1 ms-1 d-flex align-items-center gap-1"
@ -3093,149 +3429,79 @@ function PosCounter({ store, notify }) {
<div className="print-drawer-body">
<div className="print-paper-card">
<div id="printable-container">
{/* Format 1: 80mm Thermal Slip (Single or 1-5 Items) */}
{/* Format 1: 80mm Thermal Slip in Pure Text Layout */}
{printFormat === 'thermal' && (
<div className="print-thermal-80mm">
<div className="text-center fw-bold fs-6">{lastInvoice.sale.store_name}</div>
<div className="text-center">{lastInvoice.sale.building_name}, {lastInvoice.sale.floor_no}</div>
{lastInvoice.sale.abdm_hfr_id && (
<div className="text-center" style={{ fontSize: '9px' }}>HFR ID: {lastInvoice.sale.abdm_hfr_id}</div>
)}
<div className="text-center">DL 20B: {lastInvoice.sale.drug_license_no_20b}</div>
<div className="text-center">DL 21B: {lastInvoice.sale.drug_license_no_21b}</div>
<div className="text-center">GSTIN: {lastInvoice.sale.gstin}</div>
<hr style={{ margin: '4px 0' }} />
<div>Bill No: <strong>{lastInvoice.sale.invoice_no}</strong></div>
<div>Date: {lastInvoice.sale.sale_date}</div>
<div>Patient: {lastInvoice.sale.patient_name} {lastInvoice.sale.uhid ? `(${lastInvoice.sale.uhid})` : ''}</div>
{(lastInvoice.sale.abha_id || lastInvoice.sale.abha_address) && (
<div style={{ fontSize: '9px' }}>ABHA: {lastInvoice.sale.abha_id || ''} {lastInvoice.sale.abha_address ? `(${lastInvoice.sale.abha_address})` : ''}</div>
)}
{lastInvoice.sale.abdm_care_context_ref && (
<div style={{ fontSize: '8px' }}>Care Context: {lastInvoice.sale.abdm_care_context_ref}</div>
)}
<div>Doctor: {lastInvoice.sale.doctor_name}</div>
<hr style={{ margin: '4px 0' }} />
<table style={{ width: '100%', fontSize: '10px' }}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Item</th>
<th style={{ textAlign: 'center' }}>Batch</th>
<th style={{ textAlign: 'center' }}>Qty</th>
<th style={{ textAlign: 'right' }}>Total</th>
</tr>
</thead>
<tbody>
{lastInvoice.items.length === 0 ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', color: '#666', padding: '6px 0' }}>
- Return / Refund Only (No New Meds) -
</td>
</tr>
) : (
lastInvoice.items.map((it, i) => (
<tr key={i}>
<td>
{it.item_name}
{it.sell_unit === 'Tablet' && (
<div style={{ fontSize: '8px', color: '#666' }}>@ ₹{it.unit_price}/tab</div>
)}
</td>
<td style={{ textAlign: 'center' }}>{it.batch_no}</td>
<td style={{ textAlign: 'center' }}>
{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'}`)}
</td>
<td style={{ textAlign: 'right' }}>₹{it.total_amount}</td>
</tr>
))
)}
</tbody>
</table>
{lastInvoice.return_items && lastInvoice.return_items.length > 0 && (
<>
<div style={{ fontSize: '9px', fontWeight: 'bold', margin: '4px 0 2px', borderTop: '1px dashed #000', paddingTop: 2 }}>
-- RETURNED / EXCHANGED --
{lastInvoice.credit_note && <span style={{ float: 'right' }}>CN: {lastInvoice.credit_note.credit_note_no}</span>}
</div>
<table style={{ width: '100%', fontSize: '9px' }}>
<tbody>
{lastInvoice.return_items.map((it, i) => (
<tr key={`ret-${i}`}>
<td>
<span style={{ textDecoration: 'line-through' }}>{it.item_name}</span>
<div style={{ fontSize: '8px', color: '#666' }}>
{it.ref_invoice_no ? `Ref: ${it.ref_invoice_no}` : ''} ({it.return_condition})
</div>
</td>
<td style={{ textAlign: 'center' }}>{it.batch_no}</td>
<td style={{ textAlign: 'center' }}>
-{it.sell_unit === 'Tablet' ? `${it.total_units} Tab` : `${it.qty} ${it.unit_pack || 'Unit'}`}
</td>
<td style={{ textAlign: 'right', color: '#b91c1c' }}>-₹{Math.abs(Number(it.total_amount)).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
<hr style={{ margin: '4px 0' }} />
<div className="d-flex justify-content-between" style={{ fontSize: '10px' }}>
<span>Gross Total:</span>
<span>₹{lastInvoice.sale.gross_amount}</span>
</div>
{Number(lastInvoice.sale.discount_amount) > 0 && (
<div className="d-flex justify-content-between text-danger" style={{ fontSize: '10px' }}>
<span>Total Discount:</span>
<span>-₹{lastInvoice.sale.discount_amount}</span>
<div className="p-2 text-center">
<div className="d-flex justify-content-between align-items-center mb-2 mx-auto" style={{ maxWidth: '360px' }}>
<span className="badge bg-dark text-warning border border-secondary" style={{ fontSize: '0.72rem' }}>
<i className="bi bi-receipt me-1"></i> 80mm Thermal Receipt (Plain Text)
</span>
<div className="d-flex gap-1">
<button
type="button"
className="btn btn-xs btn-outline-secondary text-dark bg-light py-0.5 px-2"
onClick={copyThermalReceiptText}
title="Copy receipt text to clipboard"
style={{ fontSize: '0.72rem' }}
>
<i className="bi bi-clipboard me-1 text-primary"></i> Copy Text
</button>
<button
type="button"
className="btn btn-xs btn-warning text-dark fw-bold py-0.5 px-2"
onClick={printThermalReceipt}
title="Print Thermal 80mm Receipt"
style={{ fontSize: '0.72rem' }}
>
<i className="bi bi-printer-fill me-1"></i> Print Thermal
</button>
</div>
)}
{Number(lastInvoice.sale.return_amount) > 0 && (
<div className="d-flex justify-content-between text-danger fw-bold" style={{ fontSize: '10px' }}>
<span>Less Return Credit:</span>
<span>-₹{lastInvoice.sale.return_amount}</span>
</div>
)}
<div className="d-flex justify-content-between fw-bold" style={{ fontSize: '12px' }}>
<span>NET PAYABLE:</span>
<span>₹{lastInvoice.sale.net_amount}</span>
</div>
{Number(lastInvoice.sale.refund_amount) > 0 && (
<div className="d-flex justify-content-between fw-bold text-danger border-top border-bottom py-1 my-1" style={{ fontSize: '11px' }}>
<span>REFUND PAID TO PATIENT:</span>
<span>₹{lastInvoice.sale.refund_amount} ({lastInvoice.sale.refund_mode})</span>
</div>
)}
<div style={{ fontSize: '9px', marginTop: 4 }}>
<div><strong>Settlement:</strong> {lastInvoice.sale.payment_mode}</div>
{Number(lastInvoice.sale.cash_paid) > 0 && <div>• Cash Paid: ₹{lastInvoice.sale.cash_paid}</div>}
{Number(lastInvoice.sale.upi_paid) > 0 && (
<div>• 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})` : ''}</div>
)}
{Number(lastInvoice.sale.card_paid) > 0 && (
<div>• Card Paid: ₹{lastInvoice.sale.card_paid} {lastInvoice.sale.card_ref_no ? `[Ref: ${lastInvoice.sale.card_ref_no}]` : ''}</div>
)}
{Number(lastInvoice.sale.credit_amount) > 0 && <div>• IPD Credit: ₹{lastInvoice.sale.credit_amount}</div>}
{Number(lastInvoice.sale.refund_amount) > 0 && (
<div className="text-danger fw-bold">• Refund Issued: ₹{lastInvoice.sale.refund_amount} via {lastInvoice.sale.refund_mode} {lastInvoice.sale.refund_ref_no ? `[Ref: ${lastInvoice.sale.refund_ref_no}]` : ''}</div>
)}
{lastInvoice.sale.is_bank_reconciled == 1 && <div className="text-success fw-bold">✓ Bank Statement Audited</div>}
</div>
<div style={{ fontSize: '9px', marginTop: 4, textAlign: 'center' }}>
R.Ph: {lastInvoice.sale.registered_pharmacist_name || 'Pharmacist'} {lastInvoice.sale.pharmacist_hpr_id ? `(HPR: ${lastInvoice.sale.pharmacist_hpr_id})` : ''}
</div>
<div style={{ fontSize: '8px', textAlign: 'center', marginTop: 4 }}>
{lastInvoice.sale.terms_conditions || 'Thank you! Get well soon.'}
</div>
<pre
id="thermal-receipt-preview"
style={{
fontFamily: "'Courier New', Courier, monospace",
fontSize: '11px',
lineHeight: '1.28',
backgroundColor: '#ffffff',
color: '#000000',
padding: '16px',
border: '1px solid #cbd5e1',
borderRadius: '6px',
boxShadow: '0 4px 15px rgba(0,0,0,0.06)',
width: '360px',
maxWidth: '100%',
margin: '0 auto',
textAlign: 'left',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
}}
>
{generateThermalReceiptText(lastInvoice)}
</pre>
</div>
)}
{/* Format 2: A5 Half-Page Invoice (Normal OPD/Walk-in) */}
{printFormat === 'a5' && (
<div className="print-a5-invoice">
<div className="d-flex justify-content-between align-items-center bg-primary-subtle border border-primary-subtle rounded px-2.5 py-1.5 mb-2.5 no-print">
<div className="d-flex align-items-center gap-2">
<i className="bi bi-file-earmark-pdf-fill text-danger fs-5"></i>
<div>
<div className="fw-bold text-dark" style={{ fontSize: '0.78rem' }}>A5 Retail Tax Invoice (PDF Ready)</div>
<small className="text-muted" style={{ fontSize: '0.68rem' }}>Direct server vector PDF • Zero clipped columns</small>
</div>
</div>
<div className="d-flex gap-1.5">
<button className="btn btn-xs btn-outline-primary py-1 px-2" onClick={() => downloadPdf('a5')} style={{ fontSize: '0.72rem' }}>
<i className="bi bi-download me-1"></i> Download PDF
</button>
<button className="btn btn-xs btn-primary py-1 px-2.5 fw-bold" onClick={() => printPdf('a5')} style={{ fontSize: '0.72rem' }}>
<i className="bi bi-printer me-1"></i> Print PDF
</button>
</div>
</div>
<div className="d-flex justify-content-between align-items-start border-bottom pb-2 mb-2">
<div>
<h6 className="fw-bold mb-0 text-primary">{lastInvoice.sale.store_name}</h6>
@ -3407,6 +3673,23 @@ function PosCounter({ store, notify }) {
{/* Format 3: A4 Comprehensive Hospital Tax Invoice (IPD & Large Bills) */}
{printFormat === 'a4' && (
<div className="print-a4-invoice">
<div className="d-flex justify-content-between align-items-center bg-primary-subtle border border-primary-subtle rounded px-3 py-1.5 mb-3 no-print">
<div className="d-flex align-items-center gap-2">
<i className="bi bi-file-earmark-pdf-fill text-danger fs-5"></i>
<div>
<div className="fw-bold text-dark" style={{ fontSize: '0.8rem' }}>A4 Hospital GST Tax Invoice (PDF Ready)</div>
<small className="text-muted" style={{ fontSize: '0.68rem' }}>Server-rendered vector PDF • Zero clipped columns</small>
</div>
</div>
<div className="d-flex gap-1.5">
<button className="btn btn-xs btn-outline-primary py-1 px-2.5" onClick={() => downloadPdf('a4')} style={{ fontSize: '0.72rem' }}>
<i className="bi bi-download me-1"></i> Download PDF
</button>
<button className="btn btn-xs btn-primary py-1 px-2.5 fw-bold" onClick={() => printPdf('a4')} style={{ fontSize: '0.72rem' }}>
<i className="bi bi-printer me-1"></i> Print PDF
</button>
</div>
</div>
<div className="d-flex justify-content-between align-items-center border-bottom pb-3 mb-3">
<div>
<h4 className="fw-bold text-primary mb-1">{lastInvoice.sale.store_name}</h4>
@ -3709,13 +3992,29 @@ function PosCounter({ store, notify }) {
<span className="text-muted small">
Format: <strong className="text-dark text-uppercase">{printFormat}</strong> • Bill No: <strong>{lastInvoice.sale.invoice_no}</strong>
</span>
<div className="d-flex gap-2">
<div className="d-flex gap-2 flex-wrap align-items-center">
<button className="btn btn-sm btn-outline-secondary px-3" onClick={() => setIsPrintOpen(false)}>
Done / Close (Esc)
</button>
<button className="btn btn-sm btn-primary px-3 fw-bold" onClick={() => window.print()}>
<i className="bi bi-printer me-1"></i> Print Bill ({printFormat.toUpperCase()})
</button>
{printFormat === 'thermal' ? (
<>
<button className="btn btn-sm btn-outline-dark px-3" onClick={copyThermalReceiptText}>
<i className="bi bi-clipboard me-1 text-primary"></i> Copy Text
</button>
<button className="btn btn-sm btn-warning text-dark px-3 fw-bold" onClick={printThermalReceipt}>
<i className="bi bi-printer-fill me-1"></i> Print Thermal Receipt
</button>
</>
) : (
<>
<button className="btn btn-sm btn-outline-primary px-3" onClick={() => downloadPdf(printFormat)}>
<i className="bi bi-download me-1"></i> Download PDF
</button>
<button className="btn btn-sm btn-primary px-3 fw-bold" onClick={() => printPdf(printFormat)}>
<i className="bi bi-printer me-1"></i> Print PDF ({printFormat.toUpperCase()})
</button>
</>
)}
</div>
</div>
</>