diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5284dbd..5568ac7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1661,6 +1661,7 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->get('medical-store/stores', 'MedicalStoreApi::stores'); $routes->post('medical-store/stores/save', 'MedicalStoreApi::saveStore'); $routes->get('medical-store/patient/search', 'MedicalStoreApi::searchPatient'); + $routes->get('medical-store/patient/history', 'MedicalStoreApi::patientHistory'); $routes->get('medical-store/patient/prescription/(:segment)/(:num)', 'MedicalStoreApi::getPrescription/$1/$2'); $routes->get('medical-store/items/search', 'MedicalStoreApi::searchItems'); $routes->get('medical-store/items/search-master', 'MedicalStoreApi::searchMasterItems'); diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index 69d5ab8..8454d26 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -213,7 +213,174 @@ class MedicalStoreApi extends BaseController return $this->response->setJSON(['status' => 1, 'patients' => $enriched]); } + public function patientHistory() + { + $patientId = (int)($this->request->getGet('patient_id') ?? 0); + $uhid = trim($this->request->getGet('uhid') ?? ''); + $mobile = trim($this->request->getGet('mobile') ?? ''); + $storeId = (int)($this->request->getGet('store_id') ?? 0); + + if ($patientId <= 0 && empty($uhid) && empty($mobile)) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Please provide patient_id, uhid, or mobile number.' + ]); + } + + // Fetch sales history for this patient + $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'); + + $builder->groupStart(); + if ($patientId > 0) { + $builder->orWhere('s.patient_id', $patientId); + } + if (!empty($uhid)) { + $builder->orWhere('s.uhid', $uhid); + } + if (!empty($mobile) && strlen($mobile) >= 10) { + $builder->orWhere('s.patient_mobile', $mobile); + } + $builder->groupEnd(); + + $builder->orderBy('s.sale_id', 'DESC'); + $sales = $builder->limit(30)->get()->getResultArray(); + + $invoices = []; + $totalSpend = 0; + $uniqueMedicinesMap = []; + + foreach ($sales as $sale) { + $totalSpend += (float)($sale['net_amount'] ?? 0); + + // Fetch items for this sale + $items = $this->db->table('mst_sales_items si') + ->select('si.*, i.item_name, i.generic_name, i.category, i.unit_pack, i.drug_schedule, i.manufacturer_name') + ->join('mst_items i', 'i.item_id = si.item_id', 'left') + ->where('si.sale_id', $sale['sale_id']) + ->get() + ->getResultArray(); + + $processedItems = []; + foreach ($items as $it) { + $returnedUnits = 0; + if (($it['item_type'] ?? 'SALE') === 'SALE') { + $retRow = $this->db->table('mst_sales_items') + ->where('ref_sale_id', $sale['sale_id']) + ->where('item_id', $it['item_id']) + ->where('batch_id', $it['batch_id']) + ->where('item_type', 'RETURN') + ->selectSum('total_units', 'tot_ret') + ->get() + ->getRowArray(); + $returnedUnits = (int)($retRow['tot_ret'] ?? 0); + } + + $soldUnits = (int)($it['total_units'] ?? $it['qty']); + $remainingUnits = max(0, $soldUnits - $returnedUnits); + $unitsPerPack = max(1, (int)($it['units_per_pack'] ?? 1)); + + $processedItem = [ + 'sale_item_id' => (int)$it['sale_item_id'], + 'sale_id' => (int)$it['sale_id'], + 'item_id' => (int)$it['item_id'], + 'item_name' => $it['item_name'] ?? 'Unknown Medicine', + 'generic_name' => $it['generic_name'] ?? '', + 'category' => $it['category'] ?? '', + 'unit_pack' => $it['unit_pack'] ?? '', + 'drug_schedule' => $it['drug_schedule'] ?? '', + 'batch_id' => (int)$it['batch_id'], + 'batch_no' => $it['batch_no'] ?? '', + 'expiry_date' => $it['expiry_date'] ?? '', + 'qty' => (int)$it['qty'], + 'sell_unit' => $it['sell_unit'] ?? 'Strip', + 'units_per_pack' => $unitsPerPack, + 'total_units' => $soldUnits, + 'already_returned_units' => $returnedUnits, + 'remaining_units' => $remainingUnits, + 'remaining_strips' => floor($remainingUnits / $unitsPerPack), + 'remaining_loose' => $remainingUnits % $unitsPerPack, + 'unit_mrp' => (float)($it['unit_mrp'] ?? 0), + 'effective_unit_price' => round((float)($it['total_amount'] ?? 0) / max(1, $soldUnits), 2), + 'gst_rate' => (float)($it['gst_rate'] ?? 0), + 'total_amount' => (float)($it['total_amount'] ?? 0), + 'item_type' => $it['item_type'] ?? 'SALE' + ]; + + $processedItems[] = $processedItem; + + if (($it['item_type'] ?? 'SALE') === 'SALE' && !empty($it['item_id'])) { + $mId = (int)$it['item_id']; + if (!isset($uniqueMedicinesMap[$mId])) { + $uniqueMedicinesMap[$mId] = [ + 'item_id' => $mId, + 'item_name' => $it['item_name'] ?? '', + 'generic_name' => $it['generic_name'] ?? '', + 'category' => $it['category'] ?? '', + 'unit_pack' => $it['unit_pack'] ?? '', + 'last_batch_no' => $it['batch_no'] ?? '', + 'last_dispensed' => $sale['sale_date'], + 'last_invoice_no' => $sale['invoice_no'], + 'last_qty' => (int)$it['qty'], + 'last_sell_unit' => $it['sell_unit'] ?? 'Strip', + 'times_dispensed' => 1 + ]; + } else { + $uniqueMedicinesMap[$mId]['times_dispensed']++; + } + } + } + + $invoices[] = [ + 'sale' => $sale, + 'items' => $processedItems + ]; + } + + $lastInvoice = $invoices[0] ?? null; + $lastDispensedItems = []; + if ($lastInvoice) { + $lastDispensedItems = array_values(array_filter($lastInvoice['items'], function($it) { + return ($it['item_type'] ?? 'SALE') === 'SALE'; + })); + } + + // Fetch OPD doctor prescriptions for this patient if patient_id > 0 + $docPrescriptions = []; + if ($patientId > 0) { + try { + $docPrescriptions = $this->db->table('opd_prescription op') + ->select('opp.id as presc_item_id, opp.med_id, opp.med_name, opp.med_type, opp.dosage, opp.dosage_freq_str, opp.no_of_days, opp.remark, opp.qty as presc_qty, op.id as presc_id, op.opd_id, op.date_opd_visit, opm.doc_name') + ->join('opd_prescrption_prescribed opp', 'opp.opd_pre_id = op.id') + ->join('opd_master opm', 'opm.opd_id = op.opd_id', 'left') + ->where('op.p_id', $patientId) + ->orderBy('op.id', 'DESC') + ->limit(20) + ->get() + ->getResultArray(); + } catch (\Exception $e) { + $docPrescriptions = []; + } + } + + + return $this->response->setJSON([ + 'status' => 1, + 'patient_id' => $patientId, + 'uhid' => $uhid, + 'total_sales' => count($sales), + 'total_spend' => round($totalSpend, 2), + 'last_sale' => $lastInvoice ? $lastInvoice['sale'] : null, + 'last_dispensed_items' => $lastDispensedItems, + 'invoices' => $invoices, + 'unique_medicines' => array_values($uniqueMedicinesMap), + 'doctor_prescriptions' => $docPrescriptions + ]); + } + public function getPrescription(string $encounterType, int $encounterId) + { $medicines = []; $encounterType = strtolower($encounterType); diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index 85e81b0..a903289 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -310,6 +310,91 @@ font-weight: 700; } + /* Patient Pharmacy History & Records Sidebar */ + .patient-history-sidebar { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 4px 18px rgba(15, 23, 42, 0.07); + overflow: hidden; + display: flex; + flex-direction: column; + } + .patient-history-header { + padding: 12px 16px; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + color: #ffffff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + .patient-history-tabs { + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + padding: 6px 8px; + display: flex; + gap: 4px; + overflow-x: auto; + } + .patient-history-tab-btn { + font-size: 0.72rem; + font-weight: 700; + padding: 5px 8px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: #64748b; + cursor: pointer; + white-space: nowrap; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; + } + .patient-history-tab-btn.active { + background: #ffffff; + color: #0284c7; + border-color: #cbd5e1; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); + } + .patient-history-tab-btn:hover:not(.active) { + background: #f1f5f9; + color: #1e293b; + } + .patient-history-body { + padding: 12px; + overflow-y: auto; + flex: 1; + max-height: calc(100vh - 220px); + } + .patient-history-body::-webkit-scrollbar { + width: 5px; + } + .patient-history-body::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; + } + .history-med-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 8px; + transition: all 0.15s ease; + } + .history-med-card:hover { + border-color: #38bdf8; + box-shadow: 0 3px 10px rgba(56, 189, 248, 0.12); + } + .history-med-card.last-given { + background: linear-gradient(180deg, #f0fdf4 0%, #ffffff 100%); + border-color: #86efac; + } + .spin { + animation: spin 1s linear infinite; + } + @keyframes spin { + 100% { transform: rotate(360deg); } + } + .search-results-dropdown { position: absolute; top: 100%; @@ -898,6 +983,13 @@ function PosCounter({ store, notify }) { const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [isPrintOpen, setIsPrintOpen] = useState(false); + // Patient History & Old Records State (Right-Side Panel) + const [patientHistory, setPatientHistory] = useState(null); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + const [showHistoryPanel, setShowHistoryPanel] = useState(true); + const [historyActiveTab, setHistoryActiveTab] = useState('last_given'); // 'last_given', 'all_bills', 'prescriptions', 'summary' + const [expandedBillId, setExpandedBillId] = useState(null); + // Automatically preload latest completed sale for this store so "Last Bill" is immediately available useEffect(() => { if (store && store.store_id && !lastInvoice) { @@ -1208,10 +1300,15 @@ function PosCounter({ store, notify }) { setPatientType('Walk-in'); setPrintFormat('thermal'); // Default walkin to Thermal } + + // Fetch patient pharmacy history & old records on the right side + fetchPatientHistory(pt.patient_id, pt.uhid, pt.mobile); + setShowHistoryPanel(true); }; const resetPatient = () => { setSelectedPatient(null); + setPatientHistory(null); setPatientQuery(''); setPatientType('Walk-in'); setDoctorName(''); @@ -1221,6 +1318,7 @@ function PosCounter({ store, notify }) { setPrintFormat('thermal'); }; + const viewAbdmBundle = async (saleId) => { setIsLoadingFhir(true); setShowFhirModal(true); @@ -1503,6 +1601,107 @@ function PosCounter({ store, notify }) { setCart(cart.filter((_, i) => i !== idx)); }; + const fetchPatientHistory = async (patientId, uhid, mobile) => { + if (!patientId && !uhid && !mobile) { + setPatientHistory(null); + return; + } + setIsLoadingHistory(true); + try { + const p = new URLSearchParams(); + if (patientId) p.append('patient_id', patientId); + if (uhid) p.append('uhid', uhid); + if (mobile) p.append('mobile', mobile); + if (store?.store_id) p.append('store_id', store.store_id); + + const res = await fetch(API_BASE + `patient/history?${p.toString()}`); + const data = await res.json(); + if (data.status) { + setPatientHistory(data); + if (data.invoices && data.invoices.length > 0) { + setExpandedBillId(data.invoices[0].sale.sale_id); + } + } else { + setPatientHistory(null); + } + } catch (e) { + console.error('Error fetching patient history:', e); + setPatientHistory(null); + } finally { + setIsLoadingHistory(false); + } + }; + + const reDispenseMedicine = async (itemObj, defaultQty = 1) => { + try { + const searchTerm = itemObj.item_name || itemObj.med_name; + if (!searchTerm) return; + notify(`Finding current stock for "${searchTerm}"...`, 'info'); + const res = await fetch(API_BASE + `items/search?store_id=${store.store_id}&q=${encodeURIComponent(searchTerm)}`); + const data = await res.json(); + if (data.status && data.items && data.items.length > 0) { + let matched = null; + if (itemObj.item_id) { + matched = data.items.find(it => Number(it.item_id) === Number(itemObj.item_id)); + } + if (!matched) matched = data.items[0]; + + if (matched && matched.fefo_batch) { + const qtyToAdd = defaultQty || (itemObj.qty ? Number(itemObj.qty) : 1); + addItemToCart(matched, matched.fefo_batch, qtyToAdd); + notify(`Added "${matched.item_name}" (Batch ${matched.fefo_batch.batch_no}) to cart!`, 'success'); + } else if (matched) { + notify(`"${matched.item_name}" is currently OUT OF STOCK in this counter!`, 'error'); + } + } else { + notify(`Medicine "${searchTerm}" not found in current inventory.`, 'error'); + } + } catch (e) { + notify('Failed to add medicine: ' + e.message, 'error'); + } + }; + + const initiateReturnFromHistory = (saleObj, itemObj) => { + if (!saleObj || !itemObj) return; + const inv = patientHistory?.invoices?.find(inv => Number(inv.sale.sale_id) === Number(saleObj.sale_id)); + if (inv) { + selectReturnInvoice(inv); + const upp = Number(itemObj.units_per_pack) || 1; + const defUnit = (upp > 1 && itemObj.remaining_strips > 0) ? 'Strip' : 'Tab'; + const defQty = defUnit === 'Strip' ? Math.max(1, itemObj.remaining_strips) : Math.max(1, itemObj.remaining_units); + + setReturnDraftItems(prev => ({ + ...prev, + [itemObj.sale_item_id]: { + return_qty: defQty, + return_unit: defUnit, + condition: 'GOOD', + reason: 'EXCESS_UNUSED' + } + })); + openReturnModal(); + notify(`Loaded bill ${saleObj.invoice_no} for return of ${itemObj.item_name}`, 'info'); + } else { + setReturnSearchQuery(saleObj.invoice_no); + searchReturnInvoices(saleObj.invoice_no); + openReturnModal(); + } + }; + + const viewPastBill = (saleId) => { + fetch(API_BASE + `sales/invoice/${saleId}`) + .then(r => r.json()) + .then(data => { + if (data.status) { + setLastInvoice(data); + setIsPrintOpen(true); + } else { + notify('Unable to load invoice', 'error'); + } + }).catch(err => notify('Error loading bill: ' + err.message, 'error')); + }; + + // Calculate totals with whole bill discount and return items const grossTotal = cart.reduce((acc, it) => acc + getItemLineInfo(it).lineGross, 0); const itemDiscountTotal = cart.reduce((acc, it) => acc + getItemDiscount(it), 0); @@ -2044,8 +2243,8 @@ function PosCounter({ store, notify }) { return (