feat(pharmacy): add right-side patient old records, last dispensed medicines, and 1-click re-dispense/return desk
This commit is contained in:
parent
261a69bf2e
commit
741269634e
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="pos-counter-wrapper position-relative">
|
||||
<div className="row g-3">
|
||||
{/* Full-Width Workspace: Patient Selector, Medicine Lookup & Cart Grid */}
|
||||
<div className="col-12">
|
||||
{/* Workspace Column (col-lg-8 when patient history open, col-12 otherwise) */}
|
||||
<div className={selectedPatient && showHistoryPanel ? "col-12 col-lg-8 col-xl-8" : "col-12"}>
|
||||
{/* Patient Information Panel */}
|
||||
<div className="counter-card position-relative">
|
||||
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
|
|
@ -2067,7 +2266,22 @@ function PosCounter({ store, notify }) {
|
|||
</button>
|
||||
)}
|
||||
{selectedPatient && (
|
||||
<div className="d-flex gap-2">
|
||||
<div className="d-flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${showHistoryPanel ? 'btn-primary text-white' : 'btn-outline-primary'} py-1 px-2.5 d-inline-flex align-items-center gap-1 shadow-sm`}
|
||||
onClick={() => setShowHistoryPanel(!showHistoryPanel)}
|
||||
title="Toggle Patient Old Records & Past Dispensed Medicines (Right-Side)"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
<i className="bi bi-clock-history"></i>
|
||||
<span>Old Records</span>
|
||||
{patientHistory && patientHistory.total_sales > 0 && (
|
||||
<span className={`badge ${showHistoryPanel ? 'bg-white text-primary' : 'bg-primary text-white'} ms-1`} style={{ fontSize: "0.65rem" }}>
|
||||
{patientHistory.total_sales}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{(selectedPatient.has_active_opd || selectedPatient.has_active_ipd) && (
|
||||
<button className="btn btn-sm btn-outline-success py-1 px-2.5" onClick={loadDoctorPrescription}>
|
||||
<i className="bi bi-file-earmark-medical me-1"></i> 1-Click Load Doctor Rx
|
||||
|
|
@ -2080,6 +2294,7 @@ function PosCounter({ store, notify }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="row g-2">
|
||||
<div className="col-md-7 position-relative">
|
||||
<div className="input-group input-group-sm">
|
||||
|
|
@ -2774,8 +2989,399 @@ function PosCounter({ store, notify }) {
|
|||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right-Side Patient Pharmacy History & Records Panel */}
|
||||
{selectedPatient && showHistoryPanel && (
|
||||
<div className="col-12 col-lg-4 col-xl-4">
|
||||
<div className="patient-history-sidebar position-sticky shadow-sm" style={{ top: '15px', maxHeight: 'calc(100vh - 100px)', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<div className="patient-history-header">
|
||||
<div className="d-flex justify-content-between align-items-center mb-1.5">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<i className="bi bi-clock-history text-info fs-5"></i>
|
||||
<div>
|
||||
<h6 className="mb-0 fw-bold text-white" style={{ fontSize: '0.9rem' }}>Patient Pharmacy History</h6>
|
||||
<small className="text-secondary" style={{ fontSize: '0.72rem' }}>
|
||||
UHID: <span className="text-warning fw-bold">{selectedPatient.uhid}</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-light py-0.5 px-2"
|
||||
onClick={() => fetchPatientHistory(selectedPatient.patient_id, selectedPatient.uhid, selectedPatient.mobile)}
|
||||
disabled={isLoadingHistory}
|
||||
title="Refresh Patient History"
|
||||
style={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
<i className={`bi bi-arrow-clockwise ${isLoadingHistory ? 'spin' : ''}`}></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-light py-0.5 px-2"
|
||||
onClick={() => setShowHistoryPanel(false)}
|
||||
title="Hide sidebar (Click 'Old Records' in patient bar to re-open)"
|
||||
style={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
<i className="bi bi-chevron-bar-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Patient Summary Sub-bar */}
|
||||
<div className="d-flex flex-wrap align-items-center justify-content-between gap-1 pt-1.5 border-top border-secondary" style={{ fontSize: '0.72rem' }}>
|
||||
<span className="text-light">
|
||||
<strong>{selectedPatient.full_name}</strong> • {selectedPatient.gender}, {selectedPatient.age}
|
||||
</span>
|
||||
{patientHistory && (
|
||||
<span className="badge bg-primary-subtle text-primary border border-primary px-1.5 py-0.5">
|
||||
{patientHistory.total_sales || 0} Bills • ₹{patientHistory.total_spend || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="patient-history-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`patient-history-tab-btn ${historyActiveTab === 'last_given' ? 'active' : ''}`}
|
||||
onClick={() => setHistoryActiveTab('last_given')}
|
||||
>
|
||||
<i className="bi bi-capsule text-success"></i>
|
||||
<span>Last Given</span>
|
||||
{patientHistory?.last_dispensed_items?.length > 0 && (
|
||||
<span className="badge bg-success text-white rounded-pill px-1" style={{ fontSize: '0.62rem' }}>
|
||||
{patientHistory.last_dispensed_items.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`patient-history-tab-btn ${historyActiveTab === 'all_bills' ? 'active' : ''}`}
|
||||
onClick={() => setHistoryActiveTab('all_bills')}
|
||||
>
|
||||
<i className="bi bi-receipt text-primary"></i>
|
||||
<span>Past Bills</span>
|
||||
{patientHistory?.invoices?.length > 0 && (
|
||||
<span className="badge bg-primary text-white rounded-pill px-1" style={{ fontSize: '0.62rem' }}>
|
||||
{patientHistory.invoices.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{patientHistory?.doctor_prescriptions?.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`patient-history-tab-btn ${historyActiveTab === 'prescriptions' ? 'active' : ''}`}
|
||||
onClick={() => setHistoryActiveTab('prescriptions')}
|
||||
>
|
||||
<i className="bi bi-stethoscope text-danger"></i>
|
||||
<span>Doctor Rx</span>
|
||||
<span className="badge bg-danger text-white rounded-pill px-1" style={{ fontSize: '0.62rem' }}>
|
||||
{patientHistory.doctor_prescriptions.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{patientHistory?.unique_medicines?.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={`patient-history-tab-btn ${historyActiveTab === 'summary' ? 'active' : ''}`}
|
||||
onClick={() => setHistoryActiveTab('summary')}
|
||||
>
|
||||
<i className="bi bi-journal-medical text-warning"></i>
|
||||
<span>All Meds</span>
|
||||
<span className="badge bg-secondary text-white rounded-pill px-1" style={{ fontSize: '0.62rem' }}>
|
||||
{patientHistory.unique_medicines.length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="patient-history-body">
|
||||
{isLoadingHistory ? (
|
||||
<div className="text-center py-5 text-muted">
|
||||
<div className="spinner-border spinner-border-sm text-primary me-2"></div>
|
||||
Loading patient records...
|
||||
</div>
|
||||
) : !patientHistory || (patientHistory.total_sales === 0 && (!patientHistory.doctor_prescriptions || patientHistory.doctor_prescriptions.length === 0)) ? (
|
||||
<div className="text-center py-4 px-2 text-muted">
|
||||
<i className="bi bi-folder-x fs-1 text-secondary opacity-50 d-block mb-2"></i>
|
||||
<h6 className="fw-semibold text-dark">No Previous Pharmacy Records</h6>
|
||||
<small>This patient has no recorded medicine dispenses or bills in the pharmacy database yet.</small>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TAB 1: LAST GIVEN MEDICINES */}
|
||||
{historyActiveTab === 'last_given' && (
|
||||
<div>
|
||||
{patientHistory.last_sale ? (
|
||||
<>
|
||||
<div className="p-2.5 mb-2 rounded-3 border" style={{ background: '#f0fdf4', borderColor: '#bbf7d0' }}>
|
||||
<div className="d-flex justify-content-between align-items-center mb-1">
|
||||
<span className="fw-bold text-success" style={{ fontSize: '0.78rem' }}>
|
||||
<i className="bi bi-check-circle-fill me-1"></i> Last Dispensed Visit
|
||||
</span>
|
||||
<span className="badge bg-dark text-white" style={{ fontSize: '0.68rem' }}>
|
||||
{patientHistory.last_sale.invoice_no}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: '0.72rem' }}>
|
||||
<div><i className="bi bi-calendar-event me-1"></i> {patientHistory.last_sale.sale_date ? patientHistory.last_sale.sale_date.substring(0, 16) : 'N/A'}</div>
|
||||
{patientHistory.last_sale.doctor_name && (
|
||||
<div><i className="bi bi-person-badge me-1"></i> Prescribed by: <strong>{patientHistory.last_sale.doctor_name}</strong></div>
|
||||
)}
|
||||
<div className="d-flex justify-content-between align-items-center mt-1.5 pt-1 border-top border-success-subtle">
|
||||
<span>Paid: <strong>₹{patientHistory.last_sale.net_amount}</strong> ({patientHistory.last_sale.payment_mode})</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-success py-0 px-2 fw-bold"
|
||||
onClick={() => viewPastBill(patientHistory.last_sale.sale_id)}
|
||||
style={{ fontSize: '0.68rem' }}
|
||||
>
|
||||
<i className="bi bi-printer me-1"></i> View Bill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fw-bold text-dark mb-1.5 d-flex justify-content-between align-items-center" style={{ fontSize: '0.75rem' }}>
|
||||
<span>Medicines Given ({patientHistory.last_dispensed_items.length}):</span>
|
||||
<small className="text-muted">Click <strong>+</strong> to re-dispense</small>
|
||||
</div>
|
||||
|
||||
{patientHistory.last_dispensed_items.map((it, mIdx) => (
|
||||
<div key={`last-med-${mIdx}`} className="history-med-card last-given">
|
||||
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<div className="fw-bold text-dark" style={{ fontSize: '0.82rem' }}>
|
||||
{it.item_name}
|
||||
</div>
|
||||
<small className="text-muted d-block" style={{ fontSize: '0.68rem' }}>
|
||||
{it.generic_name}
|
||||
</small>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className="fw-bold text-dark" style={{ fontSize: '0.82rem' }}>₹{it.total_amount}</span>
|
||||
<small className="text-muted d-block" style={{ fontSize: '0.68rem' }}>₹{it.unit_mrp}/{it.sell_unit}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex flex-wrap align-items-center gap-1 mb-2" style={{ fontSize: '0.68rem' }}>
|
||||
<span className="badge bg-light text-dark border">Batch: {it.batch_no}</span>
|
||||
<span className="badge bg-light text-secondary border">Exp: {it.expiry_date}</span>
|
||||
<span className="badge bg-primary text-white">Qty: {it.qty} {it.sell_unit}</span>
|
||||
{it.remaining_units > 0 ? (
|
||||
<span className="badge bg-success-subtle text-success border border-success">
|
||||
Returnable: {it.remaining_strips > 0 ? `${it.remaining_strips} Str ` : ''}{it.remaining_loose > 0 ? `${it.remaining_loose} Tab` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-subtle text-muted border">Fully Returned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="d-flex gap-1.5 pt-1.5 border-top">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-success py-1 px-2 fw-semibold d-inline-flex align-items-center gap-1 shadow-xs flex-fill justify-content-center"
|
||||
onClick={() => reDispenseMedicine(it, it.qty)}
|
||||
style={{ fontSize: '0.72rem', background: '#059669', borderColor: '#047857' }}
|
||||
title="Add this medicine to active dispensing cart with current FEFO batch"
|
||||
>
|
||||
<i className="bi bi-plus-circle-fill"></i>
|
||||
<span>Dispense Again</span>
|
||||
</button>
|
||||
{it.remaining_units > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-danger py-1 px-2 fw-semibold d-inline-flex align-items-center gap-1 shadow-xs"
|
||||
onClick={() => initiateReturnFromHistory(patientHistory.last_sale, it)}
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
title="Return or exchange this dispensed item into current bill"
|
||||
>
|
||||
<i className="bi bi-arrow-return-left"></i>
|
||||
<span>Return</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted">
|
||||
<i className="bi bi-basket text-secondary fs-3 d-block mb-1"></i>
|
||||
No pharmacy sales recorded yet for this patient.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: ALL PAST BILLS */}
|
||||
{historyActiveTab === 'all_bills' && (
|
||||
<div>
|
||||
{patientHistory.invoices?.map((inv, invIdx) => {
|
||||
const s = inv.sale;
|
||||
const isExp = expandedBillId === s.sale_id;
|
||||
return (
|
||||
<div key={`inv-${s.sale_id}`} className="card mb-2 border shadow-xs" style={{ borderRadius: '8px' }}>
|
||||
<div
|
||||
className="card-header py-2 px-2.5 bg-white d-flex justify-content-between align-items-center"
|
||||
style={{ cursor: 'pointer', borderBottom: isExp ? '1px solid #e2e8f0' : 'none' }}
|
||||
onClick={() => setExpandedBillId(isExp ? null : s.sale_id)}
|
||||
>
|
||||
<div>
|
||||
<div className="fw-bold text-dark d-flex align-items-center gap-1.5" style={{ fontSize: '0.78rem' }}>
|
||||
<i className={`bi bi-chevron-${isExp ? 'down' : 'right'} text-primary`} style={{ fontSize: '0.7rem' }}></i>
|
||||
<span>{s.invoice_no}</span>
|
||||
<span className="badge bg-light text-secondary border" style={{ fontSize: '0.65rem' }}>{inv.items.length} items</span>
|
||||
</div>
|
||||
<small className="text-muted" style={{ fontSize: '0.68rem' }}>
|
||||
{s.sale_date ? s.sale_date.substring(0, 10) : ''} • Dr. {s.doctor_name || 'Consultant'}
|
||||
</small>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className="fw-bold text-success d-block" style={{ fontSize: '0.82rem' }}>₹{s.net_amount}</span>
|
||||
<span className="badge bg-secondary-subtle text-secondary" style={{ fontSize: '0.62rem' }}>{s.payment_mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExp && (
|
||||
<div className="card-body p-2 bg-light">
|
||||
<div className="d-flex justify-content-between align-items-center mb-1.5">
|
||||
<span className="text-muted fw-semibold" style={{ fontSize: '0.7rem' }}>Items Dispensed:</span>
|
||||
<div className="d-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-dark py-0 px-1.5"
|
||||
onClick={(e) => { e.stopPropagation(); viewPastBill(s.sale_id); }}
|
||||
style={{ fontSize: '0.65rem' }}
|
||||
>
|
||||
<i className="bi bi-printer me-1"></i> Reprint
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-danger py-0 px-1.5"
|
||||
onClick={(e) => { e.stopPropagation(); selectReturnInvoice(inv); openReturnModal(); }}
|
||||
style={{ fontSize: '0.65rem' }}
|
||||
>
|
||||
<i className="bi bi-arrow-return-left me-1"></i> Return All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inv.items.map((it, itIdx) => (
|
||||
<div key={`inv-item-${itIdx}`} className="p-1.5 mb-1 bg-white rounded border d-flex justify-content-between align-items-center" style={{ fontSize: '0.72rem' }}>
|
||||
<div>
|
||||
<div className="fw-bold text-dark">{it.item_name}</div>
|
||||
<div className="text-muted" style={{ fontSize: '0.65rem' }}>
|
||||
Batch: {it.batch_no} • {it.qty} {it.sell_unit} @ ₹{it.unit_mrp}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-success py-0 px-1"
|
||||
onClick={() => reDispenseMedicine(it, it.qty)}
|
||||
title="Dispense again"
|
||||
style={{ fontSize: '0.68rem' }}
|
||||
>
|
||||
<i className="bi bi-plus"></i>
|
||||
</button>
|
||||
{it.remaining_units > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-danger py-0 px-1"
|
||||
onClick={() => initiateReturnFromHistory(s, it)}
|
||||
title="Return this item"
|
||||
style={{ fontSize: '0.68rem' }}
|
||||
>
|
||||
<i className="bi bi-arrow-return-left"></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: DOCTOR PRESCRIPTIONS */}
|
||||
{historyActiveTab === 'prescriptions' && (
|
||||
<div>
|
||||
<div className="text-muted mb-2 fw-semibold" style={{ fontSize: '0.72rem' }}>
|
||||
<i className="bi bi-stethoscope text-primary me-1"></i> Doctor Prescriptions from OPD Visits:
|
||||
</div>
|
||||
{patientHistory.doctor_prescriptions?.map((dp, dpIdx) => (
|
||||
<div key={`doc-rx-${dpIdx}`} className="history-med-card mb-2">
|
||||
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<div className="fw-bold text-dark" style={{ fontSize: '0.82rem' }}>{dp.med_name}</div>
|
||||
<span className="badge bg-secondary-subtle text-secondary border" style={{ fontSize: '0.65rem' }}>{dp.med_type || 'Medicine'}</span>
|
||||
</div>
|
||||
<small className="text-muted" style={{ fontSize: '0.68rem' }}>{dp.date_opd_visit}</small>
|
||||
</div>
|
||||
<div className="text-muted mb-1.5" style={{ fontSize: '0.7rem' }}>
|
||||
{dp.doc_name && <div>Doc: <strong>Dr. {dp.doc_name}</strong></div>}
|
||||
<div>Dose: {dp.dosage_freq_str || dp.dosage || '1 Tab'} • Duration: {dp.no_of_days || '5 Days'}</div>
|
||||
{dp.remark && <div>Note: {dp.remark}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-primary w-100 py-1 fw-semibold d-flex align-items-center justify-content-center gap-1 shadow-xs"
|
||||
onClick={() => reDispenseMedicine({ item_name: dp.med_name, qty: 10 })}
|
||||
style={{ fontSize: '0.72rem' }}
|
||||
>
|
||||
<i className="bi bi-plus-circle"></i> Dispense Prescribed Medicine
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 4: ALL MEDS SUMMARY */}
|
||||
{historyActiveTab === 'summary' && (
|
||||
<div>
|
||||
<div className="text-muted mb-2 fw-semibold" style={{ fontSize: '0.72rem' }}>
|
||||
Distinct Medicines Dispensed to Patient:
|
||||
</div>
|
||||
{patientHistory.unique_medicines?.map((um, uIdx) => (
|
||||
<div key={`uniq-med-${uIdx}`} className="history-med-card mb-2">
|
||||
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||
<div className="fw-bold text-dark" style={{ fontSize: '0.8rem' }}>{um.item_name}</div>
|
||||
<span className="badge bg-primary-subtle text-primary border" style={{ fontSize: '0.65rem' }}>
|
||||
{um.times_dispensed}x dispensed
|
||||
</span>
|
||||
</div>
|
||||
<small className="text-muted d-block mb-1.5" style={{ fontSize: '0.68rem' }}>
|
||||
Last on {um.last_dispensed ? um.last_dispensed.substring(0, 10) : ''} (Batch: {um.last_batch_no})
|
||||
</small>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline-success w-100 py-0.5 fw-semibold d-flex align-items-center justify-content-center gap-1"
|
||||
onClick={() => reDispenseMedicine(um, um.last_qty || 1)}
|
||||
style={{ fontSize: '0.7rem' }}
|
||||
>
|
||||
<i className="bi bi-plus-circle"></i> Dispense Again
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Slide-in Checkout Desk Drawer & Backdrop */}
|
||||
<div
|
||||
className={`checkout-drawer-overlay ${isCheckoutOpen ? 'open' : ''}`}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user