From 93172c4a19bbb241f7886d7a6269aeafc56ddbee Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 10 Sep 2026 09:50:34 +0530 Subject: [PATCH] feat(pharmacy): add searchable All Invoices register tab with edit capabilities and POS draft auto-save/recovery --- app/Config/Routes.php | 2 + app/Controllers/Api/v1/MedicalStoreApi.php | 155 +++ public/App/MedicalStore/index.html | 1413 +++++++++++++++++++- 3 files changed, 1555 insertions(+), 15 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5568ac7..9b8de4b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1671,6 +1671,8 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->get('medical-store/stock/list', 'MedicalStoreApi::stockList'); $routes->get('medical-store/stock/alerts', 'MedicalStoreApi::stockAlerts'); $routes->post('medical-store/sales/save', 'MedicalStoreApi::saveSale'); + $routes->get('medical-store/sales/list', 'MedicalStoreApi::salesList'); + $routes->post('medical-store/sales/update', 'MedicalStoreApi::updateSale'); $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'); diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index 8454d26..4404339 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -2689,6 +2689,161 @@ class MedicalStoreApi extends BaseController return $this->response->setJSON(['status' => 1, 'sales' => $sales]); } + public function salesList() + { + $storeId = (int)($this->request->getGet('store_id') ?? 0); + $search = trim($this->request->getGet('search') ?? ''); + $fromDate = trim($this->request->getGet('from_date') ?? ''); + $toDate = trim($this->request->getGet('to_date') ?? ''); + $paymentMode = trim($this->request->getGet('payment_mode') ?? ''); + $page = max(1, (int)($this->request->getGet('page') ?? 1)); + $limit = max(5, min(100, (int)($this->request->getGet('limit') ?? 25))); + $offset = ($page - 1) * $limit; + + $builder = $this->db->table('mst_sales s'); + $builder->select('s.*, st.store_name, st.store_code'); + $builder->join('mst_stores st', 'st.store_id = s.store_id', 'left'); + + if ($storeId > 0) { + $builder->where('s.store_id', $storeId); + } + + if (!empty($search)) { + $builder->groupStart() + ->like('s.invoice_no', $search) + ->orLike('s.patient_name', $search) + ->orLike('s.uhid', $search) + ->orLike('s.patient_mobile', $search) + ->orLike('s.doctor_name', $search) + ->groupEnd(); + } + + if (!empty($fromDate)) { + $builder->where('DATE(s.sale_date) >=', $fromDate); + } + if (!empty($toDate)) { + $builder->where('DATE(s.sale_date) <=', $toDate); + } + if (!empty($paymentMode) && $paymentMode !== 'ALL') { + $builder->where('s.payment_mode', $paymentMode); + } + + // Count total matching + $countBuilder = clone $builder; + $totalRecords = $countBuilder->countAllResults(); + + // Fetch paginated sales - STRICTLY ORDERED LATEST TO OLDEST + $builder->orderBy('s.sale_id', 'DESC'); + $builder->limit($limit, $offset); + $sales = $builder->get()->getResultArray(); + + // For each sale, get a summary of items + $saleIds = array_column($sales, 'sale_id'); + $itemsMap = []; + if (!empty($saleIds)) { + $items = $this->db->table('mst_sales_items si') + ->select('si.sale_id, si.item_id, si.batch_no, si.qty, si.sell_unit, si.total_amount, si.item_type, i.item_name') + ->join('mst_items i', 'i.item_id = si.item_id', 'left') + ->whereIn('si.sale_id', $saleIds) + ->get() + ->getResultArray(); + + foreach ($items as $it) { + $itemsMap[$it['sale_id']][] = $it; + } + } + + foreach ($sales as &$s) { + $sItems = $itemsMap[$s['sale_id']] ?? []; + $s['items_count'] = count($sItems); + $s['items_summary'] = implode(', ', array_map(function($i) { + $prefix = ($i['item_type'] ?? 'SALE') === 'RETURN' ? 'Ret ' : ''; + return ($i['item_name'] ?? 'Item') . ' (' . $prefix . $i['qty'] . ' ' . ($i['sell_unit'] ?? 'Strip') . ')'; + }, array_slice($sItems, 0, 4))); + if (count($sItems) > 4) { + $s['items_summary'] .= ' +' . (count($sItems) - 4) . ' more'; + } + $s['items'] = $sItems; + } + + // Stats summary + $today = date('Y-m-d'); + $statsBuilder = $this->db->table('mst_sales'); + if ($storeId > 0) $statsBuilder->where('store_id', $storeId); + + $todayStats = (clone $statsBuilder)->where('DATE(sale_date)', $today) + ->select('COUNT(*) as today_count, SUM(net_amount) as today_amount') + ->get()->getRowArray(); + + $allStats = (clone $statsBuilder) + ->select('COUNT(*) as total_count, SUM(net_amount) as total_amount, SUM(return_amount) as total_returns') + ->get()->getRowArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'sales' => $sales, + 'pagination' => [ + 'current_page' => $page, + 'per_page' => $limit, + 'total_records' => $totalRecords, + 'total_pages' => ceil($totalRecords / max(1, $limit)) + ], + 'stats' => [ + 'today_count' => (int)($todayStats['today_count'] ?? 0), + 'today_amount' => (float)($todayStats['today_amount'] ?? 0), + 'total_count' => (int)($allStats['total_count'] ?? 0), + 'total_amount' => (float)($allStats['total_amount'] ?? 0), + 'total_returns' => (float)($allStats['total_returns'] ?? 0), + ] + ]); + } + + public function updateSale() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $saleId = (int)($json['sale_id'] ?? 0); + + if ($saleId <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Valid sale ID is required.' + ]); + } + + $sale = $this->db->table('mst_sales')->where('sale_id', $saleId)->get()->getRowArray(); + if (!$sale) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 0, + 'message' => 'Invoice not found.' + ]); + } + + $updateData = []; + if (isset($json['patient_name'])) $updateData['patient_name'] = trim($json['patient_name']); + if (isset($json['patient_mobile'])) $updateData['patient_mobile'] = trim($json['patient_mobile']); + if (isset($json['patient_address'])) $updateData['patient_address'] = trim($json['patient_address']); + if (isset($json['age'])) $updateData['age'] = trim($json['age']); + if (isset($json['gender'])) $updateData['gender'] = trim($json['gender']); + if (isset($json['doctor_name'])) $updateData['doctor_name'] = trim($json['doctor_name']); + if (isset($json['doctor_reg_no'])) $updateData['doctor_reg_no'] = trim($json['doctor_reg_no']); + if (isset($json['payment_mode'])) $updateData['payment_mode'] = trim($json['payment_mode']); + if (isset($json['payment_reference'])) $updateData['payment_reference'] = trim($json['payment_reference']); + if (isset($json['bank_name'])) $updateData['bank_name'] = trim($json['bank_name']); + if (isset($json['upi_ref_no'])) $updateData['upi_ref_no'] = trim($json['upi_ref_no']); + if (isset($json['card_ref_no'])) $updateData['card_ref_no'] = trim($json['card_ref_no']); + if (isset($json['remarks'])) $updateData['reconciled_notes'] = trim($json['remarks']); + + if (!empty($updateData)) { + $this->db->table('mst_sales')->where('sale_id', $saleId)->update($updateData); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Invoice details updated successfully.', + 'sale_id' => $saleId + ]); + } + // ========================================================================= // 5. SUPPLIERS & PURCHASES // ========================================================================= diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index a903289..17c6c2c 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -395,6 +395,55 @@ 100% { transform: rotate(360deg); } } + /* Invoices & Sales View Styles */ + .invoices-stats-card { + border-radius: 10px; + padding: 14px 18px; + border: 1px solid #e2e8f0; + background: #ffffff; + box-shadow: 0 1px 4px rgba(0,0,0,0.03); + transition: transform 0.15s ease, box-shadow 0.15s ease; + } + .invoices-stats-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.06); + } + .invoices-date-pill { + padding: 4px 12px; + border-radius: 20px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #475569; + cursor: pointer; + transition: all 0.15s ease; + } + .invoices-date-pill.active { + background: #0284c7; + color: #ffffff; + border-color: #0284c7; + } + .invoices-table th { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #475569; + background-color: #f8fafc; + border-bottom: 2px solid #e2e8f0; + padding: 10px 12px; + } + .invoices-table td { + font-size: 0.84rem; + vertical-align: middle; + padding: 10px 12px; + border-bottom: 1px solid #f1f5f9; + } + .invoices-table tbody tr:hover { + background-color: #f8fafc; + } + .search-results-dropdown { position: absolute; top: 100%; @@ -582,7 +631,8 @@ class ErrorBoundary extends React.Component { function App() { const [stores, setStores] = useState([]); const [activeStoreId, setActiveStoreId] = useState(null); - const [activeTab, setActiveTab] = useState('pos'); // pos, inventory, purchase, transfer, ledgers + const [activeTab, setActiveTab] = useState('pos'); // pos, invoices, inventory, purchase, transfer, ledgers + const [editInvoiceForPos, setEditInvoiceForPos] = useState(null); const [notification, setNotification] = useState(null); const [isLoadingStores, setIsLoadingStores] = useState(true); @@ -907,6 +957,9 @@ function App() { + @@ -923,7 +976,24 @@ function App() { {/* Main View Container */}
- {activeTab === 'pos' && } + {activeTab === 'pos' && ( + setEditInvoiceForPos(null)} + /> + )} + {activeTab === 'invoices' && ( + { + setEditInvoiceForPos(invData); + setActiveTab('pos'); + }} + /> + )} {activeTab === 'inventory' && } {activeTab === 'purchase' && } {activeTab === 'transfer' && } @@ -936,7 +1006,7 @@ function App() { // ========================================================================= // 1. POS BILLING & DISPENSING COUNTER (MULTI-PRINT: A4, A5, 80mm THERMAL) // ========================================================================= -function PosCounter({ store, notify }) { +function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { if (!store) { return (
@@ -990,6 +1060,194 @@ function PosCounter({ store, notify }) { const [historyActiveTab, setHistoryActiveTab] = useState('last_given'); // 'last_given', 'all_bills', 'prescriptions', 'summary' const [expandedBillId, setExpandedBillId] = useState(null); + // POS Counter Persistent Draft Auto-Save & Recovery + const draftKey = `hms_pharmacy_pos_draft_${store?.store_id || 1}`; + const [draftStatus, setDraftStatus] = useState('idle'); // 'idle', 'saved', 'restored' + const [draftSavedAt, setDraftSavedAt] = useState(null); + const [isRestoredBannerVisible, setIsRestoredBannerVisible] = useState(false); + const [editingInvoiceBanner, setEditingInvoiceBanner] = useState(null); + const isRestoringRef = useRef(false); + + // 1. Handle initialEditInvoice (from Invoices tab "Edit in POS") + useEffect(() => { + if (!initialEditInvoice || !initialEditInvoice.sale) return; + try { + isRestoringRef.current = true; + const sale = initialEditInvoice.sale; + const pt = { + id: sale.patient_id || 0, + patient_id: sale.patient_id || 0, + uhid: sale.uhid || '', + name: sale.patient_name || 'Walk-in Customer', + patient_name: sale.patient_name || 'Walk-in Customer', + mobile: sale.patient_mobile || '', + patient_mobile: sale.patient_mobile || '', + patient_type: sale.patient_type || 'Walk-in', + address: sale.patient_address || '', + age: sale.age || '', + gender: sale.gender || '' + }; + setSelectedPatient(pt); + setPatientType(sale.patient_type || 'Walk-in'); + setDoctorName(sale.doctor_name || ''); + setDoctorRegNo(sale.doctor_reg_no || ''); + if (sale.payment_mode) setPaymentMode(sale.payment_mode); + if (sale.bill_discount_type) setWholeDiscountType(sale.bill_discount_type); + if (sale.bill_discount_val) setWholeDiscountVal(Number(sale.bill_discount_val) || 0); + + if (sale.patient_id || sale.uhid) { + fetchPatientHistory(sale.patient_id, sale.uhid, sale.patient_mobile); + setShowHistoryPanel(true); + } + + const restoredItems = (initialEditInvoice.items || []).filter(it => (it.item_type || 'SALE') === 'SALE').map(it => { + const unitsPerPack = Math.max(1, Number(it.units_per_pack) || 1); + const sellUnit = it.sell_unit || 'Strip'; + const qty = Number(it.qty) || 1; + return { + item_id: Number(it.item_id), + item_name: it.item_name || 'Medicine', + generic_name: it.generic_name || '', + drug_schedule: it.drug_schedule || '', + unit_pack: it.unit_pack || '', + units_per_pack: unitsPerPack, + batch_id: Number(it.batch_id), + batch_no: it.batch_no || '', + expiry_date: it.expiry_date || '', + qty: qty, + strip_qty: sellUnit === 'Strip' ? qty : 0, + loose_qty: sellUnit === 'Tablet' ? qty : 0, + sell_unit: sellUnit, + mrp: Number(it.unit_mrp) || Number(it.unit_price) || 0, + rate: Number(it.unit_price) || 0, + gst_rate: Number(it.gst_rate) || 0, + discount_pct: Number(it.discount_pct) || 0, + current_qty: 999 + }; + }); + setCart(restoredItems); + setEditingInvoiceBanner(sale.invoice_no); + setIsRestoredBannerVisible(false); + notify(`Loaded invoice ${sale.invoice_no} into POS counter for editing.`, 'success'); + setTimeout(() => { isRestoringRef.current = false; }, 400); + } catch (e) { + console.error('Error loading edit invoice:', e); + isRestoringRef.current = false; + } + }, [initialEditInvoice]); + + // 2. Draft Auto-Recovery on Mount (Persistent across computer restart / crash / reload) + useEffect(() => { + if (initialEditInvoice) return; + + try { + const rawDraft = localStorage.getItem(draftKey); + if (rawDraft) { + const draft = JSON.parse(rawDraft); + const hasItems = draft.cart && draft.cart.length > 0; + const hasPatient = !!draft.selectedPatient; + const hasReturns = draft.returnCart && draft.returnCart.length > 0; + + if (hasItems || hasPatient || hasReturns) { + isRestoringRef.current = true; + if (draft.selectedPatient) { + setSelectedPatient(draft.selectedPatient); + if (draft.selectedPatient.patient_id || draft.selectedPatient.uhid) { + fetchPatientHistory(draft.selectedPatient.patient_id, draft.selectedPatient.uhid, draft.selectedPatient.mobile || draft.selectedPatient.patient_mobile); + setShowHistoryPanel(true); + } + } + if (draft.patientType) setPatientType(draft.patientType); + if (draft.doctorName) setDoctorName(draft.doctorName); + if (draft.doctorRegNo) setDoctorRegNo(draft.doctorRegNo); + if (draft.walkinAbhaId) setWalkinAbhaId(draft.walkinAbhaId); + if (draft.walkinAbhaAddress) setWalkinAbhaAddress(draft.walkinAbhaAddress); + if (hasItems) setCart(draft.cart); + if (hasReturns) setReturnCart(draft.returnCart); + if (draft.wholeDiscountType) setWholeDiscountType(draft.wholeDiscountType); + if (draft.wholeDiscountVal) setWholeDiscountVal(draft.wholeDiscountVal); + if (draft.paymentMode) setPaymentMode(draft.paymentMode); + if (draft.splitCash) setSplitCash(draft.splitCash); + if (draft.splitUpi) setSplitUpi(draft.splitUpi); + if (draft.splitCard) setSplitCard(draft.splitCard); + if (draft.editingInvoiceBanner) setEditingInvoiceBanner(draft.editingInvoiceBanner); + + setDraftStatus('restored'); + setDraftSavedAt(draft.timestamp ? new Date(draft.timestamp) : new Date()); + setIsRestoredBannerVisible(true); + setTimeout(() => { isRestoringRef.current = false; }, 400); + } + } + } catch (e) { + console.error('Error recovering draft:', e); + isRestoringRef.current = false; + } + }, [store?.store_id]); + + // 3. Debounced Auto-Save to localStorage on state change + useEffect(() => { + if (isRestoringRef.current) return; + + const hasData = (cart && cart.length > 0) || selectedPatient || (returnCart && returnCart.length > 0) || (doctorName && doctorName.trim().length > 0); + + if (!hasData) { + if (!editingInvoiceBanner) { + try { + localStorage.removeItem(draftKey); + } catch (e) {} + setDraftStatus('idle'); + } + return; + } + + const timer = setTimeout(() => { + try { + const draftData = { + timestamp: Date.now(), + selectedPatient, + patientType, + doctorName, + doctorRegNo, + walkinAbhaId, + walkinAbhaAddress, + cart, + returnCart, + wholeDiscountType, + wholeDiscountVal, + paymentMode, + splitCash, + splitUpi, + splitCard, + editingInvoiceBanner + }; + localStorage.setItem(draftKey, JSON.stringify(draftData)); + setDraftStatus('saved'); + setDraftSavedAt(new Date()); + } catch (e) { + console.error('Draft auto-save error:', e); + } + }, 300); + + return () => clearTimeout(timer); + }, [cart, returnCart, selectedPatient, patientType, doctorName, doctorRegNo, walkinAbhaId, walkinAbhaAddress, wholeDiscountType, wholeDiscountVal, paymentMode, splitCash, splitUpi, splitCard, draftKey, editingInvoiceBanner]); + + const clearDraftAndReset = () => { + try { + localStorage.removeItem(draftKey); + } catch (e) {} + setCart([]); + setReturnCart([]); + resetPatient(); + setDoctorName(''); + setDoctorRegNo(''); + setWholeDiscountVal(0); + setEditingInvoiceBanner(null); + if (onClearEditInvoice) onClearEditInvoice(); + setIsRestoredBannerVisible(false); + setDraftStatus('idle'); + notify('Draft cleared. Counter reset to new blank bill.', 'info'); + }; + // Automatically preload latest completed sale for this store so "Last Bill" is immediately available useEffect(() => { if (store && store.store_id && !lastInvoice) { @@ -1877,8 +2135,13 @@ function PosCounter({ store, notify }) { setBankName(''); setCardRefNo(''); setPaymentRef(''); - setRefundRefNo(''); resetPatient(); + try { + localStorage.removeItem(draftKey); + } catch (e) {} + setDraftStatus('idle'); + setEditingInvoiceBanner(null); + if (onClearEditInvoice) onClearEditInvoice(); } else { notify(data.message || 'Failed to complete transaction.', 'error'); } @@ -2245,6 +2508,47 @@ function PosCounter({ store, notify }) {
{/* Workspace Column (col-lg-8 when patient history open, col-12 otherwise) */}
+ {/* Draft Restored Banner */} + {isRestoredBannerVisible && ( +
+
+ +
+ Draft Auto-Restored: + + Recovered {cart.length} medicine(s) {selectedPatient ? <>for {selectedPatient.name || selectedPatient.patient_name} : ''} from your previous session {draftSavedAt ? `(${draftSavedAt.toLocaleTimeString()})` : ''}. + +
+
+
+ + +
+
+ )} + + {/* Editing Invoice Banner */} + {editingInvoiceBanner && ( +
+
+ +
+ Editing Invoice #{editingInvoiceBanner}: + + Patient and medicines loaded into POS Counter. You can adjust items, quantities, or re-dispense. + +
+
+ +
+ )} + {/* Patient Information Panel */}
@@ -2253,18 +2557,36 @@ function PosCounter({ store, notify }) { {patientType} + {draftStatus === 'saved' && ( + + Draft Auto-Saved ({cart.length} items) + + )} +
+
+ {(cart.length > 0 || selectedPatient) && ( + + )} + {lastInvoice && ( + + )}
- {lastInvoice && ( - - )} {selectedPatient && (
+ ); + } + + const [invoices, setInvoices] = useState([]); + const [stats, setStats] = useState({ today_count: 0, today_amount: 0, total_count: 0, total_amount: 0, total_returns: 0 }); + const [pagination, setPagination] = useState({ current_page: 1, per_page: 25, total_records: 0, total_pages: 1 }); + const [isLoading, setIsLoading] = useState(false); + + // Filters + const [searchQuery, setSearchQuery] = useState(''); + const [datePreset, setDatePreset] = useState('all'); // 'today', 'yesterday', 'last7', 'month', 'all' + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(''); + const [paymentMode, setPaymentMode] = useState('ALL'); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(25); + + // Selected invoice for View/Print Drawer + const [viewInvoice, setViewInvoice] = useState(null); + const [isPrintOpen, setIsPrintOpen] = useState(false); + const [printFormat, setPrintFormat] = useState('thermal'); // 'thermal', 'a5', 'a4' + const [isLoadingView, setIsLoadingView] = useState(false); + + // Quick Edit Details Modal state + const [editModalSale, setEditModalSale] = useState(null); + const [editForm, setEditForm] = useState({ + patient_name: '', + patient_mobile: '', + patient_address: '', + age: '', + gender: '', + doctor_name: '', + doctor_reg_no: '', + payment_mode: 'Cash', + payment_reference: '', + bank_name: '', + upi_ref_no: '', + card_ref_no: '', + remarks: '' + }); + const [isUpdating, setIsUpdating] = useState(false); + + const loadInvoices = async (targetPage = page, customSearch = searchQuery) => { + setIsLoading(true); + try { + const params = new URLSearchParams({ + store_id: store.store_id || 0, + search: customSearch !== undefined ? customSearch : searchQuery, + from_date: fromDate || '', + to_date: toDate || '', + payment_mode: paymentMode || 'ALL', + page: targetPage, + limit: limit + }); + const res = await fetch(API_BASE + 'sales/list?' + params.toString()); + const data = await res.json(); + if (data.status) { + setInvoices(data.sales || []); + if (data.stats) setStats(data.stats); + if (data.pagination) setPagination(data.pagination); + setPage(targetPage); + } else { + notify(data.message || 'Failed to load invoices.', 'error'); + } + } catch (e) { + notify('Error fetching sales list: ' + e.message, 'error'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadInvoices(1); + }, [store?.store_id, fromDate, toDate, paymentMode, limit]); + + const handleDatePreset = (preset) => { + setDatePreset(preset); + const today = new Date(); + const formatDate = (d) => d.toISOString().split('T')[0]; + + if (preset === 'today') { + const todayStr = formatDate(today); + setFromDate(todayStr); + setToDate(todayStr); + } else if (preset === 'yesterday') { + const yest = new Date(today); + yest.setDate(yest.getDate() - 1); + const yestStr = formatDate(yest); + setFromDate(yestStr); + setToDate(yestStr); + } else if (preset === 'last7') { + const past7 = new Date(today); + past7.setDate(past7.getDate() - 7); + setFromDate(formatDate(past7)); + setToDate(formatDate(today)); + } else if (preset === 'month') { + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + setFromDate(formatDate(firstDay)); + setToDate(formatDate(today)); + } else if (preset === 'all') { + setFromDate(''); + setToDate(''); + } + }; + + const handleSearchSubmit = (e) => { + if (e) e.preventDefault(); + loadInvoices(1, searchQuery); + }; + + const clearSearch = () => { + setSearchQuery(''); + loadInvoices(1, ''); + }; + + // Open View / Print Drawer + const openPrintDrawer = async (saleId) => { + setIsLoadingView(true); + try { + const res = await fetch(API_BASE + 'sales/invoice/' + saleId); + const data = await res.json(); + if (data.status) { + setViewInvoice(data); + setIsPrintOpen(true); + } else { + notify(data.message || 'Invoice details not found.', 'error'); + } + } catch (e) { + notify('Error loading invoice: ' + e.message, 'error'); + } finally { + setIsLoadingView(false); + } + }; + + // Open Edit Details Modal + const openEditModal = (sale) => { + setEditModalSale(sale); + setEditForm({ + patient_name: sale.patient_name || '', + patient_mobile: sale.patient_mobile || '', + patient_address: sale.patient_address || '', + age: sale.age || '', + gender: sale.gender || '', + doctor_name: sale.doctor_name || '', + doctor_reg_no: sale.doctor_reg_no || '', + payment_mode: sale.payment_mode || 'Cash', + payment_reference: sale.payment_reference || '', + bank_name: sale.bank_name || '', + upi_ref_no: sale.upi_ref_no || '', + card_ref_no: sale.card_ref_no || '', + remarks: sale.reconciled_notes || '' + }); + }; + + const handleSaveEdit = async (e) => { + e.preventDefault(); + if (!editModalSale) return; + setIsUpdating(true); + try { + const res = await fetch(API_BASE + 'sales/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sale_id: editModalSale.sale_id, + ...editForm + }) + }); + const data = await res.json(); + if (data.status) { + notify(`Invoice {editModalSale.invoice_no} updated successfully!`, 'success'); + setEditModalSale(null); + loadInvoices(page); + } else { + notify(data.message || 'Failed to update invoice.', 'error'); + } + } catch (err) { + notify('Error saving invoice update: ' + err.message, 'error'); + } finally { + setIsUpdating(false); + } + }; + + // Load into POS Counter Desk for direct re-billing / line items editing + const handleEditInPos = async (saleId) => { + setIsLoadingView(true); + try { + const res = await fetch(API_BASE + 'sales/invoice/' + saleId); + const data = await res.json(); + if (data.status && data.sale) { + if (onEditInPos) { + onEditInPos(data); + } + } else { + notify(data.message || 'Failed to load invoice for POS edit.', 'error'); + } + } catch (e) { + notify('Error: ' + e.message, 'error'); + } finally { + setIsLoadingView(false); + } + }; + + const printThermalReceipt = () => { + if (!viewInvoice || !viewInvoice.sale) return; + const txt = generateThermalReceiptText(viewInvoice); + 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 - ' + (viewInvoice.sale ? viewInvoice.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(); + } catch (pe) { + console.error('Print trigger error', pe); + } + }, 350); + } catch (e) { + console.error('Error writing thermal window:', e); + } + }; + + const printPdf = (format = 'a5') => { + if (!viewInvoice || !viewInvoice.sale) return; + const saleId = viewInvoice.sale.sale_id; + const url = `{API_BASE}sales/invoice-pdf/{saleId}?format={format}&view=1`; + const printWindow = window.open(url, '_blank'); + if (!printWindow) { + notify('Popup was blocked by browser. Please allow popups for PDF printing.', 'error'); + } + }; + + const downloadPdf = (format = 'a5') => { + if (!viewInvoice || !viewInvoice.sale) return; + const saleId = viewInvoice.sale.sale_id; + const url = `{API_BASE}sales/invoice-pdf/{saleId}?format={format}&download=1`; + const a = document.createElement('a'); + a.href = url; + a.download = `Invoice_{viewInvoice.sale.invoice_no.replace(/[^a-zA-Z0-9_-]/g, '_')}_{format.toUpperCase()}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + 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; + const padR = (str, len) => String(str || '').slice(0, len).padEnd(len, ' '); + const padL = (str, len) => String(str || '').slice(0, len).padStart(len, ' '); + const center = (str) => { + const s = String(str || '').trim(); + if (s.length >= LINE_WIDTH) return s.slice(0, LINE_WIDTH); + const pad = Math.floor((LINE_WIDTH - s.length) / 2); + return ' '.repeat(pad) + s; + }; + const rule = (char = '=') => char.repeat(LINE_WIDTH); + + let lines = []; + lines.push(center(inv.store?.store_name || 'HOSPITAL PHARMACY')); + lines.push(center(`{inv.store?.building_name || 'Main Hospital'} - {inv.store?.floor_no || 'Ground Floor'}`)); + if (inv.store?.phone) lines.push(center(`Ph: {inv.store.phone}`)); + if (inv.store?.gstin) lines.push(center(`GSTIN: {inv.store.gstin}`)); + if (inv.store?.drug_license_no_20b) lines.push(center(`DL: {inv.store.drug_license_no_20b} / {inv.store?.drug_license_no_21b || ''}`)); + lines.push(rule('=')); + lines.push(center('TAX INVOICE / CASH MEMO')); + lines.push(rule('-')); + lines.push(padR(`Inv No : {sale.invoice_no}`, 28) + padL(`Date: {String(sale.sale_date).slice(0, 10)}`, 20)); + lines.push(padR(`Patient: {sale.patient_name || 'Walk-in Customer'}`, 32) + padL(sale.uhid ? `UHID: {sale.uhid}` : '', 16)); + if (sale.doctor_name) lines.push(`Doctor : {sale.doctor_name}`); + lines.push(rule('-')); + lines.push(padR('ITEM', 22) + padR('BATCH', 8) + padL('QTY', 5) + padL('RATE', 6) + padL('TOTAL', 7)); + lines.push(rule('-')); + + items.forEach(it => { + const name = (it.item_name || 'Item').slice(0, 21); + const batch = (it.batch_no || '').slice(0, 7); + const qty = String(it.qty || 1); + const rate = Number(it.unit_price || it.effective_rate || 0).toFixed(2); + const total = Number(it.total_amount || 0).toFixed(2); + lines.push(padR(name, 22) + padR(batch, 8) + padL(qty, 5) + padL(rate, 6) + padL(total, 7)); + }); + + if (returnItems.length > 0) { + lines.push(rule('-')); + lines.push(center('RETURNED / EXCHANGED ITEMS')); + returnItems.forEach(it => { + const name = ('RET: ' + (it.item_name || 'Item')).slice(0, 21); + const batch = (it.batch_no || '').slice(0, 7); + const qty = String(it.qty || 1); + const rate = Number(it.unit_price || 0).toFixed(2); + const total = ('-' + Number(it.total_amount || 0).toFixed(2)); + lines.push(padR(name, 22) + padR(batch, 8) + padL(qty, 5) + padL(rate, 6) + padL(total, 7)); + }); + } + + lines.push(rule('=')); + lines.push(padR('Gross Total:', 36) + padL(`Rs. {Number(sale.gross_amount).toFixed(2)}`, 12)); + if (Number(sale.discount_amount) > 0) { + lines.push(padR('Discount Allowed:', 36) + padL(`-Rs. {Number(sale.discount_amount).toFixed(2)}`, 12)); + } + if (Number(sale.return_amount) > 0) { + lines.push(padR('Less Return Value:', 36) + padL(`-Rs. {Number(sale.return_amount).toFixed(2)}`, 12)); + } + lines.push(padR('NET AMOUNT PAYABLE:', 34) + padL(`Rs. {Number(sale.net_amount).toFixed(2)}`, 14)); + lines.push(rule('-')); + lines.push(padR(`Paid by: {sale.payment_mode || 'Cash'}`, 30) + padL(`Status: PAID`, 18)); + lines.push(rule('=')); + lines.push(center('Thank you for your visit! Get well soon!')); + lines.push(center('Medicines cannot be returned without original bill.')); + return lines.join('n'); + }; + + return ( +
+ {/* Header and Control Bar */} +
+
+
+ + Invoices & Sales Register + + {pagination.total_records || 0} Bills + +
+ + Showing all sales across pharmacy desk ordered Latest to Oldest. Search by bill #, patient, mobile, or doctor. + +
+
+ + {onEditInPos && ( + + )} +
+
+ + {/* Quick Date Presets Bar */} +
+ Quick Filter: + + + + + +
+ + {/* KPI Metrics Cards */} +
+
+
+
+ Today's Bills + +
+

{stats.today_count || 0}

+ ₹{Number(stats.today_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} +
+
+
+
+
+ Total Invoices + +
+

{stats.total_count || 0}

+ ₹{Number(stats.total_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} +
+
+
+
+
+ Returns / Refunds + +
+

₹{Number(stats.total_returns || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}

+ Total customer returns +
+
+
+
+
+ Net Realized + +
+

+ ₹{Number((stats.total_amount || 0) - (stats.total_returns || 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })} +

+ Net pharmacy collection +
+
+
+ + {/* Filters Row */} +
+
+
+
+
+ + + + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} + +
+
+ +
+
+ From + { setFromDate(e.target.value); setDatePreset('custom'); }} + /> +
+
+ +
+
+ To + { setToDate(e.target.value); setDatePreset('custom'); }} + /> +
+
+ +
+ +
+ +
+ + +
+
+
+
+ + {/* Invoices List Table */} +
+
+ + + + + + + + + + + + + + + + {isLoading ? ( + + + + ) : invoices.length === 0 ? ( + + + + ) : ( + invoices.map((inv) => { + const hasReturns = Number(inv.return_amount) > 0; + return ( + + + + + + + + + + + + ); + }) + )} + +
Invoice NoDate & TimePatient DetailsDoctorItems DispensedPaymentNet TotalStatusActions
+
+ Loading invoices... +
+ +
No invoices found
+

Try changing your search query or date range filters.

+
+ openPrintDrawer(inv.sale_id)} + title="Click to View / Print Invoice" + style={{ cursor: 'pointer' }} + > + {inv.invoice_no} + + {inv.abdm_care_context_ref && ( + + ABDM + + )} + +
+ {String(inv.sale_date).slice(0, 10)} +
+ + {String(inv.sale_date).slice(11, 16)} + +
+
+ {inv.patient_name || 'Walk-in Customer'} +
+
+ {inv.uhid && ( + + {inv.uhid} + + )} + {inv.patient_mobile && ( + + {inv.patient_mobile} + + )} + + {inv.patient_type || 'Walk-in'} + +
+
+
+ {inv.doctor_name || 'Self / Walk-in'} +
+ {inv.doctor_reg_no && ( + Reg: {inv.doctor_reg_no} + )} +
+
+ + {inv.items_count || 0} items + +
+
+ {inv.items_summary || 'N/A'} +
+
+ + {inv.payment_mode || 'Cash'} + + +
+ ₹{Number(inv.net_amount).toFixed(2)} +
+ {Number(inv.discount_amount) > 0 && ( + + Disc: -₹{Number(inv.discount_amount).toFixed(2)} + + )} + {hasReturns && ( + + Ret: -₹{Number(inv.return_amount).toFixed(2)} + + )} +
+ {hasReturns ? ( + + Exchange + + ) : ( + + Paid + + )} + +
+ + + +
+
+
+ + {/* Pagination Footer */} + {pagination.total_pages > 1 && ( +
+ + Showing page {pagination.current_page} of {pagination.total_pages} ({pagination.total_records} total invoices) + +
+ + + {pagination.current_page} / {pagination.total_pages} + + +
+
+ )} +
+ + {/* Quick Edit Details Modal */} + {editModalSale && ( +
+
+
+
+
+ + Edit Invoice Details: {editModalSale.invoice_no} +
+ +
+
+
+
+ + Use this to correct patient information, doctor name, or payment mode on this completed bill. To modify medicine quantities or items, click "Edit in POS" in the main table. +
+ +
+
+ + setEditForm({ ...editForm, patient_name: e.target.value })} + required + /> +
+
+ + setEditForm({ ...editForm, patient_mobile: e.target.value })} + placeholder="10-digit mobile" + /> +
+ +
+ + setEditForm({ ...editForm, age: e.target.value })} + placeholder="e.g. 45 Y" + /> +
+
+ + +
+
+ + +
+ +
+ + setEditForm({ ...editForm, doctor_name: e.target.value })} + placeholder="Prescribing doctor" + /> +
+
+ + setEditForm({ ...editForm, doctor_reg_no: e.target.value })} + placeholder="MCI / State Medical Council Reg No" + /> +
+ +
+ + setEditForm({ ...editForm, patient_address: e.target.value })} + placeholder="Address / City" + /> +
+
+ + setEditForm({ ...editForm, payment_reference: e.target.value, upi_ref_no: e.target.value })} + placeholder="Transaction reference ID" + /> +
+ +
+ + setEditForm({ ...editForm, remarks: e.target.value })} + placeholder="Reason for correction or cashier notes" + /> +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Slide-in Print Drawer for Invoices View */} +
setIsPrintOpen(false)} + /> +
+ {viewInvoice && ( + <> +
+
+ +
+
Invoice & Print Desk
+ + Bill: {viewInvoice.sale.invoice_no} + +
+
+ +
+
+ + + +
+ + {printFormat === 'thermal' ? ( + + ) : ( + <> + + + + )} + + +
+
+ +
+
+
+ {printFormat === 'thermal' && ( +
+
+ Thermal 80mm ESC/POS Text + +
+
+                                            {generateThermalReceiptText(viewInvoice)}
+                                        
+
+ )} + + {printFormat === 'a5' && ( +
+
+ + + A5 Landscape PDF (210mm × 148mm): Native vector document rendered by mPDF. + +
+ + +
+
+
+