diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 907dbd5..03908e1 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1726,6 +1726,37 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->get('medical-store/returns/lookup-invoice', 'MedicalStoreApi::lookupInvoiceForReturn'); $routes->get('medical-store/returns/recent', 'MedicalStoreApi::recentReturns'); $routes->get('medical-store/returns/credit-note/(:num)', 'MedicalStoreApi::getCreditNoteInvoice/$1'); + + // Stock Audit & Physical Reconciliation + $routes->post('medical-store/stock/reconcile', 'MedicalStoreApi::reconcileStock'); + $routes->get('medical-store/stock/audit-list', 'MedicalStoreApi::getStockAuditList'); + $routes->post('medical-store/stock/batch-reconcile', 'MedicalStoreApi::batchReconcileStock'); + + // Dedicated Medical Store User Management & Operator Session + $routes->get('medical-store/users', 'MedicalStoreApi::getStoreUsers'); + $routes->post('medical-store/users/save', 'MedicalStoreApi::saveStoreUser'); + $routes->post('medical-store/users/switch-operator', 'MedicalStoreApi::switchOperator'); + + // Staff & Customer Credit Accounts & Ledgers + $routes->get('medical-store/credit/accounts', 'MedicalStoreApi::getCreditAccounts'); + $routes->post('medical-store/credit/save-account', 'MedicalStoreApi::saveCreditAccount'); + $routes->get('medical-store/credit/ledger/(:num)', 'MedicalStoreApi::getCreditLedger/$1'); + $routes->post('medical-store/credit/record-payment', 'MedicalStoreApi::recordCreditPayment'); + + // Mobile Rack Collector & Draft Bills + $routes->post('medical-store/drafts/save', 'MedicalStoreApi::saveDraftBill'); + $routes->get('medical-store/drafts/list', 'MedicalStoreApi::getDraftBills'); + $routes->post('medical-store/drafts/cancel', 'MedicalStoreApi::cancelDraftBill'); + + // Mobile Dashboard & Analytics + $routes->get('medical-store/mobile/dashboard', 'MedicalStoreApi::getMobileDashboardData'); + + // Server Network Info & QR Pairing + $routes->get('medical-store/server-info', 'MedicalStoreApi::getServerNetworkInfo'); + + // Purchase Invoice Photo Upload & Verification + $routes->post('medical-store/purchase/upload-photo', 'MedicalStoreApi::uploadPurchasePhoto'); + $routes->post('medical-store/purchase/verify', 'MedicalStoreApi::verifyPurchase'); }); // Dedicated Direct Store Links (e.g. /MedicalStore/storeA, /MedicalStore/storeB) diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index 4404339..1db9ee3 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -23,10 +23,11 @@ class MedicalStoreApi extends BaseController $html = file_get_contents($pwaPath); if ($slug !== null) { $cleanSlug = htmlspecialchars($slug, ENT_QUOTES, 'UTF-8'); - $html = str_replace( - '', + $html = preg_replace( + '//i', "\n ", - $html + $html, + 1 ); } return $this->response->setBody($html); @@ -1425,6 +1426,7 @@ class MedicalStoreApi extends BaseController 'total_value' => $lineNet, 'remarks' => "Dispensed on Bill $invoiceNo", 'conducted_by' => (int)($json['user_id'] ?? 1), + 'conducted_by_name' => trim((string)($json['biller_name'] ?? ($json['operator_name'] ?? ''))), 'created_at' => date('Y-m-d H:i:s') ]); } @@ -1756,7 +1758,8 @@ class MedicalStoreApi extends BaseController 'abdm_fhir_bundle_json' => $fhirBundleJson, 'abdm_sync_status' => (!empty($abhaAddress) || !empty($abhaId)) ? 'PENDING' : 'NOT_APPLICABLE', 'status' => 'completed', - 'created_by' => (int)($json['user_id'] ?? 1) + 'created_by' => (int)($json['user_id'] ?? 1), + 'biller_name' => trim((string)($json['biller_name'] ?? ($json['operator_name'] ?? ''))) ]; // If IPD Credit, link into ipd_invoice_item so hospital discharge handles it @@ -1764,18 +1767,57 @@ class MedicalStoreApi extends BaseController $ipdId = (int)$json['ipd_id']; if ($this->db->tableExists('ipd_invoice_item')) { $this->db->table('ipd_invoice_item')->insert([ - 'ipd_id' => $ipdId, - 'item_name' => "Pharmacy Medicines ({$invoiceNo})", - 'item_desc' => "Medicines dispensed from {$store['store_name']}", - 'item_qty' => 1, - 'item_price' => $finalNetPayable, - 'item_amount' => $finalNetPayable, - 'insert_date' => date('Y-m-d H:i:s'), + 'ipd_id' => $ipdId, + 'item_type' => 7, // Medical Items / Medicine + 'item_id' => 0, + 'item_name' => "Pharmacy Medicines ({$invoiceNo})", + 'comment' => "[STORE: {$store['store_name']}] Bill {$invoiceNo}", + 'item_rate' => $finalNetPayable, + 'item_qty' => 1.00, + 'item_amount' => $finalNetPayable, + 'doc_id' => (int)($json['doctor_id'] ?? 0), + 'doc_name' => trim($json['doctor_name'] ?? ($store['registered_pharmacist_name'] ?? 'Consultant')), + 'date_item' => date('Y-m-d'), + 'item_added_date' => date('Y-m-d H:i:s'), + 'log' => "Dispensed from Pharmacy by {$billerName}" ]); $saleData['ipd_charge_id'] = $this->db->insertID(); } } + // If Staff / Customer Credit, link to credit account + $creditAccountId = !empty($json['credit_account_id']) ? (int)$json['credit_account_id'] : null; + if ($paymentMode === 'Staff_Credit' && $creditAccountId > 0) { + $saleData['credit_account_id'] = $creditAccountId; + $saleData['credit_amount'] = $finalNetPayable; + + if ($this->db->tableExists('mst_credit_accounts')) { + $this->db->table('mst_credit_accounts') + ->where('account_id', $creditAccountId) + ->set('current_balance', 'current_balance + ' . $finalNetPayable, false) + ->update(); + + $accRow = $this->db->table('mst_credit_accounts')->where('account_id', $creditAccountId)->get()->getRowArray(); + $balAfter = $accRow ? (float)$accRow['current_balance'] : $finalNetPayable; + + if ($this->db->tableExists('mst_credit_ledger')) { + $this->db->table('mst_credit_ledger')->insert([ + 'store_id' => $storeId, + 'account_id' => $creditAccountId, + 'entry_type' => 'CREDIT_SALE', + 'invoice_no' => $invoiceNo, + 'debit_amount' => $finalNetPayable, + 'credit_amount' => 0.00, + 'balance_after' => $balAfter, + 'payment_mode' => 'Staff_Credit', + 'remarks' => "Medicine Credit Sale: Bill {$invoiceNo}", + 'recorded_by_name' => $billerName, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + } + } + $this->db->table('mst_sales')->insert($saleData); $saleId = $this->db->insertID(); @@ -3134,7 +3176,8 @@ class MedicalStoreApi extends BaseController 'discount_amount' => $discountTotal, 'net_amount' => $grandTotal, 'payment_status' => 'unpaid', - 'remarks' => trim($json['remarks'] ?? '') + 'remarks' => trim($json['remarks'] ?? ''), + 'received_by_name' => trim((string)($json['received_by_name'] ?? ($json['operator_name'] ?? ''))) ]; $this->db->table('mst_purchases')->insert($purchaseData); $purchaseId = $this->db->insertID(); @@ -4706,11 +4749,13 @@ class MedicalStoreApi extends BaseController public function getDaybook() { $storeId = (int)($this->request->getGet('store_id') ?? 1); - $date = trim($this->request->getGet('date') ?? date('Y-m-d')); + $from = trim($this->request->getGet('from_date') ?? ($this->request->getGet('date') ?? date('Y-m-d'))); + $to = trim($this->request->getGet('to_date') ?? $from); $sales = $this->db->table('mst_sales') ->where('store_id', $storeId) - ->where("DATE(sale_date)", $date) + ->where("DATE(sale_date) >=", $from) + ->where("DATE(sale_date) <=", $to) ->orderBy('sale_id', 'ASC') ->get() ->getResultArray(); @@ -4753,7 +4798,9 @@ class MedicalStoreApi extends BaseController return $this->response->setJSON([ 'status' => 1, 'store_id' => $storeId, - 'date' => $date, + 'date' => $from, + 'from_date' => $from, + 'to_date' => $to, 'summary' => [ 'total_invoices' => count($sales), 'total_sales' => round($totalSales, 2), @@ -4773,8 +4820,14 @@ class MedicalStoreApi extends BaseController public function getGstReport() { $storeId = (int)($this->request->getGet('store_id') ?? 1); - $from = trim($this->request->getGet('from') ?? date('Y-m-01')); - $to = trim($this->request->getGet('to') ?? date('Y-m-t')); + $month = trim((string)$this->request->getGet('month')); + if (!empty($month) && preg_match('/^\d{4}-\d{2}$/', $month)) { + $from = "{$month}-01"; + $to = date('Y-m-t', strtotime($from)); + } else { + $from = trim($this->request->getGet('from') ?? date('Y-m-01')); + $to = trim($this->request->getGet('to') ?? date('Y-m-t')); + } // GSTR-1 Sales Breakdown $salesItems = $this->db->table('mst_sales_items si') @@ -4895,7 +4948,8 @@ class MedicalStoreApi extends BaseController 'pending_count' => $pendingCount, 'pending_amount' => round($pendingAmount, 2), ], - 'records' => $records + 'records' => $records, + 'transactions' => $records ]); } @@ -5282,5 +5336,937 @@ class MedicalStoreApi extends BaseController 'items' => $items ]); } -} + // ========================================================================= + // 25. PHYSICAL STOCK RECONCILIATION & AUDIT LOGS + // ========================================================================= + + /** + * Reconciles a single batch's physical stock count against system stock + * Updates mst_stock and writes a detailed audit entry to mst_stock_audit + */ + public function reconcileStock() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $storeId = (int)($json['store_id'] ?? 1); + $itemId = (int)($json['item_id'] ?? 0); + $batchId = (int)($json['batch_id'] ?? 0); + $physicalQty = isset($json['physical_qty']) ? (int)$json['physical_qty'] : null; + $auditType = trim((string)($json['audit_type'] ?? 'PHYSICAL_VARIATION')); + $remarks = trim((string)($json['remarks'] ?? '')); + $operatorId = (int)($json['operator_id'] ?? ($json['user_id'] ?? 1)); + $operatorName = trim((string)($json['operator_name'] ?? ($json['biller_name'] ?? 'Store Staff'))); + + if ($itemId <= 0 || $batchId <= 0 || $physicalQty === null || $physicalQty < 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Invalid parameters: item_id, batch_id, and physical_qty (>=0) are required.' + ]); + } + + // Validate allowed audit types + $validTypes = ['PHYSICAL_VARIATION', 'DAMAGE', 'BREAKAGE', 'EXPIRY_DUMP', 'SURPLUS_FOUND', 'THEFT_LOSS', 'AUDIT_CORRECTION']; + if (!in_array($auditType, $validTypes)) { + $auditType = 'PHYSICAL_VARIATION'; + } + + // Fetch batch details + $batch = $this->db->table('mst_batches b') + ->select('b.*, i.item_name, i.unit_pack') + ->join('mst_items i', 'i.item_id = b.item_id') + ->where('b.batch_id', $batchId) + ->get() + ->getRowArray(); + + if (!$batch) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 0, + 'message' => 'Batch not found.' + ]); + } + + $rate = (float)($batch['purchase_rate_net'] ?: ($batch['mrp'] ?: 0)); + + $this->db->transStart(); + + // Get current stock + $stkRow = $this->db->table('mst_stock') + ->where('store_id', $storeId) + ->where('item_id', $itemId) + ->where('batch_id', $batchId) + ->get() + ->getRowArray(); + + $systemQty = $stkRow ? (int)$stkRow['current_qty'] : 0; + $variationQty = $physicalQty - $systemQty; + $totalVal = round(abs($variationQty) * $rate, 2); + + // Update or insert mst_stock + if ($stkRow) { + $this->db->table('mst_stock') + ->where('stock_id', $stkRow['stock_id']) + ->update(['current_qty' => $physicalQty]); + } else { + $this->db->table('mst_stock')->insert([ + 'store_id' => $storeId, + 'item_id' => $itemId, + 'batch_id' => $batchId, + 'current_qty' => $physicalQty, + 'reserved_qty' => 0 + ]); + } + + // Insert audit log + $this->db->table('mst_stock_audit')->insert([ + 'store_id' => $storeId, + 'item_id' => $itemId, + 'batch_id' => $batchId, + 'audit_type' => $auditType, + 'system_qty' => $systemQty, + 'physical_qty' => $physicalQty, + 'variation_qty' => $variationQty, + 'rate' => $rate, + 'total_value' => $totalVal, + 'remarks' => $remarks ?: "Physical reconciliation: System {$systemQty} -> Physical {$physicalQty}", + 'conducted_by' => $operatorId, + 'conducted_by_name' => $operatorName, + 'created_at' => date('Y-m-d H:i:s') + ]); + $auditId = $this->db->insertID(); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON([ + 'status' => 0, + 'message' => 'Failed to reconcile stock due to database error.' + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Stock reconciled successfully.', + 'audit_id' => $auditId, + 'system_qty' => $systemQty, + 'physical_qty' => $physicalQty, + 'variation_qty' => $variationQty, + 'variation_val' => $totalVal + ]); + } + + /** + * Batch reconciliation for multiple items during a shelf/rack cycle count + */ + public function batchReconcileStock() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $storeId = (int)($json['store_id'] ?? 1); + $counts = $json['counts'] ?? []; + $operatorId = (int)($json['operator_id'] ?? ($json['user_id'] ?? 1)); + $operatorName = trim((string)($json['operator_name'] ?? 'Auditor')); + $batchAuditType = trim((string)($json['audit_type'] ?? 'PHYSICAL_VARIATION')); + $defaultRemarks = trim((string)($json['remarks'] ?? 'Cycle Count Batch Audit')); + + if (empty($counts) || !is_array($counts)) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'No counts provided for batch reconciliation.' + ]); + } + + $this->db->transStart(); + $processedCount = 0; + $totalDiscrepancyValue = 0; + + foreach ($counts as $cnt) { + $itemId = (int)($cnt['item_id'] ?? 0); + $batchId = (int)($cnt['batch_id'] ?? 0); + if ($itemId <= 0 || $batchId <= 0 || !isset($cnt['physical_qty'])) { + continue; + } + $physicalQty = max(0, (int)$cnt['physical_qty']); + $itemRemarks = trim((string)($cnt['remarks'] ?? $defaultRemarks)); + $itemAuditType = trim((string)($cnt['audit_type'] ?? $batchAuditType)); + + $batch = $this->db->table('mst_batches') + ->where('batch_id', $batchId) + ->get() + ->getRowArray(); + + if (!$batch) continue; + + $rate = (float)($batch['purchase_rate_net'] ?: ($batch['mrp'] ?: 0)); + + $stkRow = $this->db->table('mst_stock') + ->where('store_id', $storeId) + ->where('item_id', $itemId) + ->where('batch_id', $batchId) + ->get() + ->getRowArray(); + + $systemQty = $stkRow ? (int)$stkRow['current_qty'] : 0; + $variationQty = $physicalQty - $systemQty; + $totalVal = round(abs($variationQty) * $rate, 2); + + if ($variationQty !== 0 || !empty($cnt['force_log'])) { + if ($stkRow) { + $this->db->table('mst_stock') + ->where('stock_id', $stkRow['stock_id']) + ->update(['current_qty' => $physicalQty]); + } else { + $this->db->table('mst_stock')->insert([ + 'store_id' => $storeId, + 'item_id' => $itemId, + 'batch_id' => $batchId, + 'current_qty' => $physicalQty, + 'reserved_qty' => 0 + ]); + } + + $this->db->table('mst_stock_audit')->insert([ + 'store_id' => $storeId, + 'item_id' => $itemId, + 'batch_id' => $batchId, + 'audit_type' => $itemAuditType, + 'system_qty' => $systemQty, + 'physical_qty' => $physicalQty, + 'variation_qty' => $variationQty, + 'rate' => $rate, + 'total_value' => $totalVal, + 'remarks' => $itemRemarks, + 'conducted_by' => $operatorId, + 'conducted_by_name' => $operatorName, + 'created_at' => date('Y-m-d H:i:s') + ]); + + $processedCount++; + $totalDiscrepancyValue += $totalVal; + } + } + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON([ + 'status' => 0, + 'message' => 'Batch reconciliation failed.' + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Batch audit complete: {$processedCount} batches reconciled.", + 'reconciled_count' => $processedCount, + 'total_value' => $totalDiscrepancyValue + ]); + } + + /** + * Returns history of physical stock reconciliations and audit events + */ + public function getStockAuditList() + { + $storeId = (int)($this->request->getGet('store_id') ?? 1); + $limit = max(10, min(500, (int)($this->request->getGet('limit') ?? 100))); + $offset = max(0, (int)($this->request->getGet('offset') ?? 0)); + $auditType = trim((string)($this->request->getGet('audit_type') ?? '')); + $search = trim((string)($this->request->getGet('search') ?? '')); + $fromDate = trim((string)($this->request->getGet('from_date') ?? '')); + $toDate = trim((string)($this->request->getGet('to_date') ?? '')); + + $builder = $this->db->table('mst_stock_audit sa') + ->select('sa.*, i.item_name, i.generic_name, i.unit_pack, b.batch_no, b.expiry_date, b.shelf_no, b.rack_no') + ->join('mst_items i', 'i.item_id = sa.item_id', 'left') + ->join('mst_batches b', 'b.batch_id = sa.batch_id', 'left') + ->where('sa.store_id', $storeId); + + if ($auditType !== '') { + $builder->where('sa.audit_type', $auditType); + } + + if ($fromDate !== '') { + $builder->where('DATE(sa.created_at) >=', $fromDate); + } + if ($toDate !== '') { + $builder->where('DATE(sa.created_at) <=', $toDate); + } + + if ($search !== '') { + $builder->groupStart() + ->like('i.item_name', $search) + ->orLike('i.generic_name', $search) + ->orLike('b.batch_no', $search) + ->orLike('sa.conducted_by_name', $search) + ->orLike('sa.remarks', $search) + ->groupEnd(); + } + + $cloneBuilder = clone $builder; + $total = $cloneBuilder->countAllResults(); + + $logs = $builder->orderBy('sa.audit_id', 'DESC') + ->limit($limit, $offset) + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'total' => $total, + 'logs' => $logs + ]); + } + + // ========================================================================= + // 26. MEDICAL STORE STAFF & USER MANAGEMENT + // ========================================================================= + + /** + * Lists staff assigned to this store (plus global store managers) + */ + public function getStoreUsers() + { + $storeId = (int)($this->request->getGet('store_id') ?? 1); + $users = $this->db->table('mst_store_users') + ->where("store_id = {$storeId} OR store_id = 0", null, false) + ->where('is_active', 1) + ->orderBy('is_default', 'DESC') + ->orderBy('user_role', 'ASC') + ->orderBy('full_name', 'ASC') + ->get() + ->getResultArray(); + + // Strip pin_code for security, return has_pin flag + $sanitized = array_map(function($u) { + $u['has_pin'] = !empty($u['pin_code']); + unset($u['pin_code']); + return $u; + }, $users); + + return $this->response->setJSON([ + 'status' => 1, + 'users' => $sanitized + ]); + } + + /** + * Saves or updates a medical store user profile + */ + public function saveStoreUser() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $userId = (int)($json['user_id'] ?? 0); + $storeId = (int)($json['store_id'] ?? 1); + $fullName = trim((string)($json['full_name'] ?? '')); + $username = trim((string)($json['username'] ?? '')); + $userRole = trim((string)($json['user_role'] ?? 'pharmacist')); + $pinCode = trim((string)($json['pin_code'] ?? '')); + $phone = trim((string)($json['phone'] ?? '')); + $regNo = trim((string)($json['pharmacist_reg_no'] ?? '')); + $hprId = trim((string)($json['pharmacist_hpr_id'] ?? '')); + $isActive = isset($json['is_active']) ? (int)$json['is_active'] : 1; + $isDefault = !empty($json['is_default']) ? 1 : 0; + + if ($fullName === '' || $username === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Full Name and Username are required.' + ]); + } + + $validRoles = ['store_manager', 'pharmacist', 'cashier', 'auditor', 'staff']; + if (!in_array($userRole, $validRoles)) { + $userRole = 'pharmacist'; + } + + // If is_default is set, clear other defaults in this store + if ($isDefault === 1) { + $this->db->table('mst_store_users') + ->where('store_id', $storeId) + ->update(['is_default' => 0]); + } + + $data = [ + 'store_id' => $storeId, + 'full_name' => $fullName, + 'username' => $username, + 'user_role' => $userRole, + 'phone' => $phone, + 'pharmacist_reg_no' => $regNo, + 'pharmacist_hpr_id' => $hprId, + 'is_active' => $isActive, + 'is_default' => $isDefault, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if ($pinCode !== '') { + $data['pin_code'] = $pinCode; + } + + if ($userId > 0) { + $this->db->table('mst_store_users') + ->where('user_id', $userId) + ->update($data); + } else { + if (empty($data['pin_code'])) { + $data['pin_code'] = '1234'; + } + $data['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('mst_store_users')->insert($data); + $userId = $this->db->insertID(); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Staff profile saved successfully.', + 'user_id' => $userId + ]); + } + + /** + * Fast operator switch with 4-digit PIN verification + */ + public function switchOperator() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $storeId = (int)($json['store_id'] ?? 1); + $userId = (int)($json['user_id'] ?? 0); + $pinCode = trim((string)($json['pin_code'] ?? '')); + + if ($userId <= 0 || $pinCode === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Operator selection and 4-digit PIN are required.' + ]); + } + + $user = $this->db->table('mst_store_users') + ->where('user_id', $userId) + ->where('is_active', 1) + ->get() + ->getRowArray(); + + if (!$user) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => 0, + 'message' => 'Operator not found or inactive.' + ]); + } + + // Verify PIN (default 1234 if empty) + $userPin = trim((string)($user['pin_code'] ?? '1234')); + if ($userPin !== '' && $userPin !== $pinCode) { + return $this->response->setStatusCode(401)->setJSON([ + 'status' => 0, + 'message' => 'Incorrect PIN code for ' . $user['full_name'] . '.' + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Operator authenticated successfully.', + 'operator' => [ + 'user_id' => (int)$user['user_id'], + 'full_name' => $user['full_name'], + 'username' => $user['username'], + 'user_role' => $user['user_role'], + 'phone' => $user['phone'], + 'pharmacist_reg_no' => $user['pharmacist_reg_no'], + 'pharmacist_hpr_id' => $user['pharmacist_hpr_id'], + 'is_default' => (int)$user['is_default'], + 'store_id' => (int)$user['store_id'] + ] + ]); + } + + // ========================================================================= + // 27. STAFF & CUSTOMER CREDIT ACCOUNTS & LEDGERS + // ========================================================================= + + public function getCreditAccounts() + { + $storeId = (int)($this->request->getGet('store_id') ?? 1); + $search = trim((string)$this->request->getGet('search')); + $type = trim((string)$this->request->getGet('type')); + + $builder = $this->db->table('mst_credit_accounts') + ->where('store_id', $storeId); + + if ($type !== '' && $type !== 'all') { + $builder->where('account_type', $type); + } + if ($search !== '') { + $builder->groupStart() + ->like('account_name', $search) + ->orLike('phone', $search) + ->orLike('staff_id', $search) + ->orLike('department', $search) + ->groupEnd(); + } + + $accounts = $builder->orderBy('current_balance', 'DESC') + ->orderBy('account_name', 'ASC') + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'accounts' => $accounts + ]); + } + + public function saveCreditAccount() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $accountId = (int)($json['account_id'] ?? 0); + $storeId = (int)($json['store_id'] ?? 1); + $accountName = trim((string)($json['account_name'] ?? '')); + $accountType = trim((string)($json['account_type'] ?? 'STAFF')); + $phone = trim((string)($json['phone'] ?? '')); + $staffId = trim((string)($json['staff_id'] ?? '')); + $department = trim((string)($json['department'] ?? '')); + $creditLimit = (float)($json['credit_limit'] ?? 0); + $isActive = isset($json['is_active']) ? (int)$json['is_active'] : 1; + $remarks = trim((string)($json['remarks'] ?? '')); + + if ($accountName === '') { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Account Name is required.' + ]); + } + + $data = [ + 'store_id' => $storeId, + 'account_type' => $accountType, + 'account_name' => $accountName, + 'phone' => $phone, + 'staff_id' => $staffId, + 'department' => $department, + 'credit_limit' => $creditLimit, + 'is_active' => $isActive, + 'remarks' => $remarks, + 'updated_at' => date('Y-m-d H:i:s') + ]; + + if ($accountId > 0) { + $this->db->table('mst_credit_accounts')->where('account_id', $accountId)->update($data); + } else { + $data['current_balance'] = 0.00; + $data['created_at'] = date('Y-m-d H:i:s'); + $this->db->table('mst_credit_accounts')->insert($data); + $accountId = $this->db->insertID(); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Credit account saved successfully.', + 'account_id' => $accountId + ]); + } + + public function getCreditLedger(int $accountId) + { + $account = $this->db->table('mst_credit_accounts')->where('account_id', $accountId)->get()->getRowArray(); + if (!$account) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Credit account not found.']); + } + + $entries = $this->db->table('mst_credit_ledger') + ->where('account_id', $accountId) + ->orderBy('ledger_id', 'DESC') + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'account' => $account, + 'entries' => $entries + ]); + } + + public function recordCreditPayment() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $accountId = (int)($json['account_id'] ?? 0); + $amount = (float)($json['amount'] ?? 0); + $paymentMode = trim((string)($json['payment_mode'] ?? 'Cash')); + $paymentRef = trim((string)($json['payment_ref'] ?? '')); + $remarks = trim((string)($json['remarks'] ?? '')); + $recordedByName = trim((string)($json['recorded_by_name'] ?? 'Cashier')); + + if ($accountId <= 0 || $amount <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Valid Account and payment amount (>0) are required.' + ]); + } + + $account = $this->db->table('mst_credit_accounts')->where('account_id', $accountId)->get()->getRowArray(); + if (!$account) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Account not found.']); + } + + $this->db->transStart(); + + // Reduce balance + $newBalance = max(0.00, (float)$account['current_balance'] - $amount); + $this->db->table('mst_credit_accounts') + ->where('account_id', $accountId) + ->update([ + 'current_balance' => $newBalance, + 'updated_at' => date('Y-m-d H:i:s') + ]); + + // Insert ledger payment entry + $this->db->table('mst_credit_ledger')->insert([ + 'store_id' => (int)$account['store_id'], + 'account_id' => $accountId, + 'entry_type' => 'PAYMENT_RECEIVED', + 'debit_amount' => 0.00, + 'credit_amount' => $amount, + 'balance_after' => $newBalance, + 'payment_mode' => $paymentMode, + 'payment_ref' => $paymentRef, + 'remarks' => $remarks ?: "Payment settlement received via {$paymentMode}", + 'recorded_by_name' => $recordedByName, + 'created_at' => date('Y-m-d H:i:s') + ]); + $ledgerId = $this->db->insertID(); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to record payment.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Payment of ₹{$amount} recorded successfully. New balance: ₹{$newBalance}", + 'new_balance' => $newBalance, + 'receipt_id' => $ledgerId + ]); + } + + // ========================================================================= + // 28. MOBILE RACK COLLECTOR & DRAFT BILLS + // ========================================================================= + + public function saveDraftBill() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $storeId = (int)($json['store_id'] ?? 1); + $items = $json['items'] ?? []; + $patientName = trim((string)($json['patient_name'] ?? 'Walk-in Customer')); + $uhid = trim((string)($json['uhid'] ?? '')); + $mobile = trim((string)($json['patient_mobile'] ?? '')); + $bedNo = trim((string)($json['bed_no'] ?? '')); + $wardName = trim((string)($json['ward_name'] ?? '')); + $doctorName = trim((string)($json['doctor_name'] ?? '')); + $collectedBy = trim((string)($json['collected_by_name'] ?? 'Mobile Pharmacist')); + + if (empty($items) || !is_array($items)) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Items are required for draft bill.']); + } + + $draftCode = 'DRF-' . strtoupper(substr(uniqid(), -6)); + $totalItems = count($items); + $estAmount = 0; + foreach ($items as $it) { + $qty = (float)($it['qty'] ?? 1); + $rate = (float)($it['effective_rate'] ?? ($it['unit_price'] ?? ($it['mrp'] ?? 0))); + $estAmount += ($qty * $rate); + } + + $this->db->table('mst_draft_bills')->insert([ + 'store_id' => $storeId, + 'draft_code' => $draftCode, + 'patient_name' => $patientName, + 'uhid' => $uhid, + 'patient_mobile' => $mobile, + 'bed_no' => $bedNo, + 'ward_name' => $wardName, + 'doctor_name' => $doctorName, + 'total_items' => $totalItems, + 'estimated_amount' => round($estAmount, 2), + 'items_json' => json_encode($items), + 'status' => 'DRAFT', + 'collected_by_name' => $collectedBy, + 'created_at' => date('Y-m-d H:i:s') + ]); + $draftId = $this->db->insertID(); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Draft Bill #{$draftCode} created from rack collection.", + 'draft_id' => $draftId, + 'draft_code' => $draftCode + ]); + } + + public function getDraftBills() + { + $storeId = (int)($this->request->getGet('store_id') ?? 1); + $status = trim((string)($this->request->getGet('status') ?? 'DRAFT')); + + $builder = $this->db->table('mst_draft_bills') + ->where('store_id', $storeId); + + if ($status !== 'all') { + $builder->where('status', $status); + } + + $drafts = $builder->orderBy('draft_id', 'DESC') + ->limit(30) + ->get() + ->getResultArray(); + + foreach ($drafts as &$d) { + $d['items'] = json_decode($d['items_json'], true) ?: []; + } + + return $this->response->setJSON([ + 'status' => 1, + 'drafts' => $drafts + ]); + } + + public function cancelDraftBill() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $draftId = (int)($json['draft_id'] ?? 0); + + if ($draftId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Draft ID is required.']); + } + + $this->db->table('mst_draft_bills') + ->where('draft_id', $draftId) + ->update([ + 'status' => 'CANCELLED', + 'updated_at' => date('Y-m-d H:i:s') + ]); + + return $this->response->setJSON(['status' => 1, 'message' => 'Draft bill cancelled.']); + } + + // ========================================================================= + // 29. MOBILE ADMIN DASHBOARD & ADVANCED METRICS + // ========================================================================= + + public function getMobileDashboardData() + { + $storeId = (int)($this->request->getGet('store_id') ?? 1); + + // 1. General Stock Status + $stockSummary = $this->db->table('mst_stock s') + ->select('COUNT(s.stock_id) as total_batches, SUM(s.current_qty) as total_units, SUM(s.current_qty * b.purchase_rate_net) as cost_valuation, SUM(s.current_qty * b.mrp) as mrp_valuation') + ->join('mst_batches b', 'b.batch_id = s.batch_id') + ->where('s.store_id', $storeId) + ->get() + ->getRowArray(); + + // 2. Nearest Expiry (< 90 days) + $today = date('Y-m-d'); + $in90d = date('Y-m-d', strtotime('+90 days')); + $nearExpiry = $this->db->table('mst_stock s') + ->select('s.current_qty, b.batch_no, b.expiry_date, b.mrp, b.shelf_no, b.rack_no, i.item_name, i.generic_name') + ->join('mst_batches b', 'b.batch_id = s.batch_id') + ->join('mst_items i', 'i.item_id = s.item_id') + ->where('s.store_id', $storeId) + ->where('s.current_qty >', 0) + ->where('b.expiry_date <=', $in90d) + ->where('b.expiry_date >=', $today) + ->orderBy('b.expiry_date', 'ASC') + ->limit(10) + ->get() + ->getResultArray(); + + // 3. High Sale Products (Fast Moving) + $highSales = $this->db->table('mst_sales_items si') + ->select('si.item_id, i.item_name, i.generic_name, SUM(si.qty) as total_sold, SUM(si.total_amount) as total_revenue') + ->join('mst_sales s', 's.sale_id = si.sale_id') + ->join('mst_items i', 'i.item_id = si.item_id') + ->where('s.store_id', $storeId) + ->groupBy('si.item_id') + ->orderBy('total_sold', 'DESC') + ->limit(8) + ->get() + ->getResultArray(); + + // 4. Dump / Dead Stock (Items with current stock > 0 but no sales in last 60 days) + $in60dAgo = date('Y-m-d', strtotime('-60 days')); + $dumpStock = $this->db->table('mst_stock s') + ->select('s.current_qty, b.batch_no, b.expiry_date, b.mrp, i.item_name, i.generic_name') + ->join('mst_batches b', 'b.batch_id = s.batch_id') + ->join('mst_items i', 'i.item_id = s.item_id') + ->where('s.store_id', $storeId) + ->where('s.current_qty >', 10) + ->where("s.item_id NOT IN (SELECT DISTINCT si.item_id FROM mst_sales_items si JOIN mst_sales sa ON sa.sale_id = si.sale_id WHERE sa.store_id = {$storeId} AND DATE(sa.sale_date) >= '{$in60dAgo}')") + ->orderBy('s.current_qty', 'DESC') + ->limit(8) + ->get() + ->getResultArray(); + + // 5. New Inward Stock (Recently received batches) + $recentInwards = $this->db->table('mst_purchases p') + ->select('p.purchase_id, p.supplier_invoice_no, p.invoice_date, p.net_amount, p.received_by_name, p.invoice_photo, p.is_verified, sup.supplier_name') + ->join('mst_suppliers sup', 'sup.supplier_id = p.supplier_id', 'left') + ->where('p.store_id', $storeId) + ->orderBy('p.purchase_id', 'DESC') + ->limit(6) + ->get() + ->getResultArray(); + + // 6. Supplier Payments Pending + $supplierPending = $this->db->table('mst_purchases p') + ->select('p.purchase_id, p.supplier_invoice_no, p.due_date, p.net_amount, p.paid_amount, (p.net_amount - p.paid_amount) as pending_amount, sup.supplier_name, sup.phone') + ->join('mst_suppliers sup', 'sup.supplier_id = p.supplier_id', 'left') + ->where('p.store_id', $storeId) + ->where('p.payment_status !=', 'paid') + ->where('(p.net_amount - p.paid_amount) >', 0) + ->orderBy('p.due_date', 'ASC') + ->limit(8) + ->get() + ->getResultArray(); + + $totalPendingSupplier = 0; + foreach ($supplierPending as $sp) { + $totalPendingSupplier += (float)$sp['pending_amount']; + } + + return $this->response->setJSON([ + 'status' => 1, + 'summary' => [ + 'total_batches' => (int)($stockSummary['total_batches'] ?? 0), + 'total_units' => (int)($stockSummary['total_units'] ?? 0), + 'cost_valuation' => round((float)($stockSummary['cost_valuation'] ?? 0), 2), + 'mrp_valuation' => round((float)($stockSummary['mrp_valuation'] ?? 0), 2), + 'near_expiry_count' => count($nearExpiry), + 'total_supplier_pending' => round($totalPendingSupplier, 2) + ], + 'near_expiry' => $nearExpiry, + 'high_sales' => $highSales, + 'dump_stock' => $dumpStock, + 'recent_inwards' => $recentInwards, + 'supplier_pending' => $supplierPending + ]); + } + + // ========================================================================= + // 30. SERVER NETWORK INFO FOR QR CODE PAIRING + // ========================================================================= + + public function getServerNetworkInfo() + { + $port = $_SERVER['SERVER_PORT'] ?? '8080'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost:8080'; + $ips = []; + + // Attempt to detect local LAN IP on Windows / Linux + if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { + $output = shell_exec('ipconfig'); + if ($output) { + preg_match_all('/IPv4 Address[ .:]+([0-9.]+)/i', $output, $matches); + if (!empty($matches[1])) { + $ips = array_unique($matches[1]); + } + } + } else { + $output = shell_exec("hostname -I 2>/dev/null"); + if ($output) { + $parts = preg_split('/\s+/', trim($output)); + if (!empty($parts)) $ips = array_unique($parts); + } + } + + $lanIp = !empty($ips) ? $ips[0] : 'localhost'; + $lanUrl = "http://{$lanIp}:{$port}/MedicalStore/"; + + return $this->response->setJSON([ + 'status' => 1, + 'detected_ips' => $ips, + 'primary_ip' => $lanIp, + 'port' => $port, + 'current_host' => $host, + 'mobile_base' => $lanUrl + ]); + } + + // ========================================================================= + // 31. PURCHASE INVOICE PHOTO UPLOAD & VERIFICATION + // ========================================================================= + + public function uploadPurchasePhoto() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $purchaseId = (int)($json['purchase_id'] ?? 0); + $base64Image = $json['image_data'] ?? ''; + + if ($purchaseId <= 0 || empty($base64Image)) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => 0, + 'message' => 'Purchase ID and image_data are required.' + ]); + } + + // Save image to public/uploads/medical_store/purchases/ + $uploadDir = FCPATH . 'uploads/medical_store/purchases/'; + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0777, true); + } + + // Extract base64 + if (preg_match('/^data:image\/(\w+);base64,/', $base64Image, $type)) { + $data = substr($base64Image, strpos($base64Image, ',') + 1); + $type = strtolower($type[1]); + if (!in_array($type, ['jpg', 'jpeg', 'png', 'webp'])) { + $type = 'jpeg'; + } + $data = base64_decode($data); + } else { + $data = base64_decode($base64Image); + $type = 'jpeg'; + } + + $filename = "pur_{$purchaseId}_" . time() . ".{$type}"; + $filepath = $uploadDir . $filename; + file_put_contents($filepath, $data); + + $relUrl = "/uploads/medical_store/purchases/{$filename}"; + $this->db->table('mst_purchases') + ->where('purchase_id', $purchaseId) + ->update([ + 'invoice_photo' => $relUrl + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Purchase invoice photo uploaded successfully.', + 'photo_url' => $relUrl + ]); + } + + public function verifyPurchase() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $purchaseId = (int)($json['purchase_id'] ?? 0); + $verifiedByName = trim((string)($json['verified_by_name'] ?? 'Store Manager')); + + if ($purchaseId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Purchase ID is required.']); + } + + $this->db->table('mst_purchases') + ->where('purchase_id', $purchaseId) + ->update([ + 'is_verified' => 1, + 'verified_by_name' => $verifiedByName, + 'verified_at' => date('Y-m-d H:i:s') + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Inward Purchase #{$purchaseId} verified by {$verifiedByName}." + ]); + } +} diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php index f93309f..d0185cd 100644 --- a/app/Views/auth/login.php +++ b/app/Views/auth/login.php @@ -114,12 +114,41 @@ + +
+
+ +
+ Scan with phone camera • Install to home screen +
+
+ -
+ +
+
+
+ Mobile PWA Suite + Installable Apps +
+
Hospital Mobile Apps & QR Codes
+

+ Instant home-screen installation on Android & iPhone. Scan QR code to launch Nursing, Doctor, Pharmacy POS & Token Queue apps. +

+ +
+
+ +
View HMS Features & Capabilities @@ -135,6 +164,719 @@
+ /dev/null"); + if ($netOutput) { + $parts = preg_split('/\s+/', trim($netOutput)); + foreach ($parts as $foundIp) { + $foundIp = trim($foundIp); + if ($foundIp && $foundIp !== '127.0.0.1') { + $detectedNetworkInterfaces[$foundIp] = $foundIp . ' (LAN)'; + } + } + } + } + + $serverPort = $_SERVER['SERVER_PORT'] ?? '8080'; + $httpHost = $_SERVER['HTTP_HOST'] ?? 'localhost:8080'; + $requestScheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http'; + + // Best default IP: Wi-Fi if present, else first LAN IP, else current host + $defaultIp = ''; + foreach ($detectedNetworkInterfaces as $ipKey => $ipLabel) { + if (stripos($ipLabel, 'Wi-Fi') !== false || stripos($ipLabel, 'Wireless') !== false) { + $defaultIp = $ipKey; + break; + } + } + if (!$defaultIp && !empty($detectedNetworkInterfaces)) { + $defaultIp = array_key_first($detectedNetworkInterfaces); + } + if (!$defaultIp) { + $defaultIp = explode(':', $httpHost)[0]; + } + $defaultHost = ($defaultIp === 'localhost' || $defaultIp === '127.0.0.1') ? $httpHost : ($defaultIp . ':' . $serverPort); + ?> + + + + + + + + + + +
-
- -
Pharmacy App
- Coming Soon +
+ +
Pharmacy POS
+ Active PWA
@@ -366,6 +366,9 @@ var selectedApp = 'nursing'; function getAppBaseUrl() { + if (selectedApp === 'pharmacy') { + return ''; + } return '/' + selectedApp; } @@ -374,7 +377,7 @@ var code = sel ? sel.value : ''; var appBaseUrl = getAppBaseUrl(); var paramKey = (selectedApp === 'doctor') ? 'doctor_id=' : 'nurse_code='; - var appUrl = appBaseUrl + (code ? '?' + paramKey + encodeURIComponent(code) : ''); + var appUrl = (selectedApp === 'pharmacy') ? appBaseUrl : (appBaseUrl + (code ? '?' + paramKey + encodeURIComponent(code) : '')); var qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=' + encodeURIComponent(appUrl); var qrImg = document.getElementById('hdr_apps_qr_img'); @@ -387,7 +390,7 @@ if (urlInput) urlInput.value = appUrl; if (launchLink) launchLink.href = appUrl; - var title = (selectedApp === 'doctor') ? 'DoctorCare EMR App' : 'Nursing Care App'; + var title = (selectedApp === 'doctor') ? 'DoctorCare EMR App' : ((selectedApp === 'pharmacy') ? 'Medical Store & Pharmacy POS' : 'Nursing Care App'); if (code && sel && sel.options[sel.selectedIndex]) { var text = sel.options[sel.selectedIndex].text; @@ -395,7 +398,7 @@ if (targetCode) targetCode.textContent = code; } else { if (targetName) targetName.textContent = title; - if (targetCode) targetCode.textContent = '/app/' + selectedApp; + if (targetCode) targetCode.textContent = (selectedApp === 'pharmacy') ? '/MedicalStore' : ('/app/' + selectedApp); } } @@ -418,7 +421,12 @@ var label = document.getElementById('hdr_apps_staff_label'); if (!sel) return; - if (selectedApp === 'doctor') { + if (selectedApp === 'pharmacy') { + if (label) label.textContent = '2. PHARMACY COUNTER / STORE ACCESS'; + sel.innerHTML = ''; + updateHdrAppDetails(); + return; + } else if (selectedApp === 'doctor') { if (label) label.textContent = '2. SELECT DOCTOR PROFILE'; fetch('') .then(function(r) { return r.json(); }) diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index 17c6c2c..d4af458 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -636,6 +636,30 @@ function App() { const [notification, setNotification] = useState(null); const [isLoadingStores, setIsLoadingStores] = useState(true); + // Mobile Companion Mode & PWA Installation State + const [isMobileMode, setIsMobileMode] = useState(() => { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('mode') === 'mobile' || (window.innerWidth <= 768 && !urlParams.get('desktop')); + }); + const [showQrModal, setShowQrModal] = useState(false); + const [serverNetworkInfo, setServerNetworkInfo] = useState(null); + const [selectedLanIp, setSelectedLanIp] = useState(''); + const [pwaInstallPrompt, setPwaInstallPrompt] = useState(null); + + // Active Store Operator / Staff Session State + const [activeOperator, setActiveOperator] = useState(() => { + try { + const saved = localStorage.getItem('mst_active_operator'); + return saved ? JSON.parse(saved) : null; + } catch(e) { return null; } + }); + const [storeUsers, setStoreUsers] = useState([]); + const [showOperatorModal, setShowOperatorModal] = useState(false); + const [selectedUserToSwitch, setSelectedUserToSwitch] = useState(null); + const [operatorPinInput, setOperatorPinInput] = useState(''); + const [pinError, setPinError] = useState(''); + const [isSwitchingOperator, setIsSwitchingOperator] = useState(false); + // Machine Authorization State const [isDeviceAuthorized, setIsDeviceAuthorized] = useState(false); const [deviceToken, setDeviceToken] = useState(''); @@ -656,9 +680,100 @@ function App() { useEffect(() => { if (activeStore) { checkMachineAuthorization(activeStore.store_id); + loadStoreUsers(activeStore.store_id); + loadServerNetworkInfo(); } + + const handleInstallPrompt = (e) => { + e.preventDefault(); + setPwaInstallPrompt(e); + }; + window.addEventListener('beforeinstallprompt', handleInstallPrompt); + return () => window.removeEventListener('beforeinstallprompt', handleInstallPrompt); }, [activeStoreId, stores]); + const loadServerNetworkInfo = async () => { + try { + const res = await fetch(`${API_BASE}server-info`); + const data = await res.json(); + if (data.status) { + setServerNetworkInfo(data); + setSelectedLanIp(data.primary_ip || window.location.hostname); + } + } catch(e) { + console.error("Failed to load server network info:", e); + } + }; + + const triggerPwaInstall = async () => { + if (!pwaInstallPrompt) { + notify('To install on mobile, tap your browser menu (⋮ or Share) and select "Add to Home screen".', 'info'); + return; + } + pwaInstallPrompt.prompt(); + const choice = await pwaInstallPrompt.userChoice; + if (choice.outcome === 'accepted') { + notify('Pharmacy Application installed successfully!', 'success'); + setPwaInstallPrompt(null); + } + }; + + const loadStoreUsers = async (storeId) => { + if (!storeId) return; + try { + const res = await fetch(`${API_BASE}users?store_id=${storeId}`); + const data = await res.json(); + if (data.status && Array.isArray(data.users)) { + setStoreUsers(data.users); + if (!activeOperator || (activeOperator.store_id != storeId && activeOperator.store_id != 0)) { + const def = data.users.find(u => u.is_default == 1) || data.users[0]; + if (def) { + setActiveOperator(def); + localStorage.setItem('mst_active_operator', JSON.stringify(def)); + } + } + } + } catch(e) { + console.error("Failed to load store staff:", e); + } + }; + + const handleSwitchOperator = async () => { + if (!selectedUserToSwitch) return; + if (!operatorPinInput.trim()) { + setPinError('Please enter 4-digit PIN'); + return; + } + setIsSwitchingOperator(true); + setPinError(''); + try { + const res = await fetch(`${API_BASE}users/switch-operator`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: activeStore?.store_id || 1, + user_id: selectedUserToSwitch.user_id, + pin_code: operatorPinInput.trim() + }) + }); + const data = await res.json(); + if (data.status && data.operator) { + setActiveOperator(data.operator); + localStorage.setItem('mst_active_operator', JSON.stringify(data.operator)); + notify(`Switched operator to ${data.operator.full_name} (${data.operator.user_role})`, 'success'); + setShowOperatorModal(false); + setSelectedUserToSwitch(null); + setOperatorPinInput(''); + } else { + setPinError(data.message || 'Incorrect PIN code'); + } + } catch(e) { + setPinError('Network error while verifying PIN'); + } finally { + setIsSwitchingOperator(false); + } + }; + const fetchStores = async () => { setIsLoadingStores(true); try { @@ -922,20 +1037,79 @@ function App() { )} - {/* Store Selector & Exit */} -
- +
+ +
+
+
+ {activeOperator ? activeOperator.full_name : 'Select Staff'} +
+ + {activeOperator ? activeOperator.user_role.toUpperCase() : 'COUNTER'} + +
+ + Switch + + +
+ + {/* Locked Store Badge & Mobile Connect Actions */} +
+ {/* Locked Store Badge */} +
+ + {activeStore ? activeStore.store_name : 'Counter'} + + LOCKED + +
+ + {/* Mobile App QR Pairing Button */} + + + {/* Mobile Companion Mode Toggle */} + + @@ -943,11 +1117,23 @@ function App() {
- {/* Notification Alert Banner */} + {/* Floating Notification Toast (Always in Viewport) */} {notification && ( -
- - {notification.msg} +
+ +
{notification.msg}
)} @@ -972,14 +1158,30 @@ function App() { + - {/* Main View Container */} + {/* Mobile View Or Desktop Views */} + {isMobileMode ? ( + setShowOperatorModal(true)} + onExitMobile={() => setIsMobileMode(false)} + triggerPwaInstall={triggerPwaInstall} + /> + ) : (
{activeTab === 'pos' && ( setShowOperatorModal(true)} initialEditInvoice={editInvoiceForPos} onClearEditInvoice={() => setEditInvoiceForPos(null)} /> @@ -988,17 +1190,277 @@ function App() { { setEditInvoiceForPos(invData); setActiveTab('pos'); }} /> )} - {activeTab === 'inventory' && } - {activeTab === 'purchase' && } - {activeTab === 'transfer' && } - {activeTab === 'ledgers' && } + {activeTab === 'inventory' && ( + + )} + {activeTab === 'purchase' && ( + + )} + {activeTab === 'transfer' && } + {activeTab === 'ledgers' && } + {activeTab === 'staff' && ( + loadStoreUsers(activeStore?.store_id)} + onSelectOperator={(u) => { + setSelectedUserToSwitch(u); + setOperatorPinInput(''); + setPinError(''); + setShowOperatorModal(true); + }} + /> + )}
+ )} + + {/* Mobile App QR Pairing & Installation Modal */} + {showQrModal && ( +
+
+
+
+
+
+ +
+
+
Mobile App QR & Installation
+ {activeStore?.store_name} • Rack Collector & Stock Auditor +
+
+ +
+
+ {/* Local LAN IP Selector for Multi-Network Hospital WiFi */} + {serverNetworkInfo && serverNetworkInfo.detected_ips && serverNetworkInfo.detected_ips.length > 1 && ( +
+ + +
+ )} + + {(() => { + const portStr = serverNetworkInfo?.port ? (':' + serverNetworkInfo.port) : (window.location.port ? (':' + window.location.port) : ''); + const hostIp = selectedLanIp || serverNetworkInfo?.primary_ip || window.location.hostname; + const targetUrl = `http://${hostIp}${portStr}/MedicalStore/${activeStore?.store_slug || 'storea'}?mode=mobile`; + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=${encodeURIComponent(targetUrl)}`; + + return ( +
+
+ Scan to open Mobile Pharmacy App +
+ +
+
Mobile Application URL:
+
{targetUrl}
+
+ + {/* Step Instructions */} +
+
+
+
1. Scan QR
+
Open camera app on your smartphone
+
+
+
+
+
2. Rack Drafts
+
Collect items & audit shelf stock
+
+
+
+
+
3. Install App
+
Tap "Add to Home Screen" to install
+
+
+
+ +
+ + + + + +
+
+ ); + })()} +
+
+
+
+ )} + + {/* Fast Operator Switch PIN Modal */} + {showOperatorModal && ( +
+
+
+
+
+
+ +
+
+
Switch Store Operator / Auditor
+ {activeStore?.store_name} • Counter Session +
+
+ +
+
+ +
+ {storeUsers.map(u => { + const isSelected = selectedUserToSwitch?.user_id == u.user_id; + const isCurrent = activeOperator?.user_id == u.user_id; + return ( +
{ setSelectedUserToSwitch(u); setPinError(''); setOperatorPinInput(''); }} + className={`p-2.5 rounded-3 d-flex align-items-center justify-content-between cursor-pointer transition-all ${isSelected ? 'border border-primary' : 'border border-secondary'}`} + style={{ + background: isSelected ? 'rgba(14, 165, 233, 0.15)' : '#1e293b', + cursor: 'pointer' + }} + > +
+
+ {u.full_name ? u.full_name[0] : 'U'} +
+
+
+ {u.full_name} + {isCurrent && CURRENT} + {u.is_default == 1 && DEFAULT} +
+
+ @{u.username} • {u.user_role.replace('_', ' ')} + {u.pharmacist_reg_no && ` • Reg: ${u.pharmacist_reg_no}`} +
+
+
+ +
+ ); + })} +
+ + {selectedUserToSwitch && ( +
+
+ + Default: 1234/1111 +
+ { setOperatorPinInput(e.target.value); setPinError(''); }} + onKeyDown={(e) => { if (e.key === 'Enter') handleSwitchOperator(); }} + autoFocus + /> + {pinError && ( +
+ {pinError} +
+ )} +
+ )} +
+
+
+ +
+
+ + +
+
+
+
+
+ )}
); } @@ -1006,7 +1468,7 @@ function App() { // ========================================================================= // 1. POS BILLING & DISPENSING COUNTER (MULTI-PRINT: A4, A5, 80mm THERMAL) // ========================================================================= -function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { +function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice, activeOperator, onSwitchOperator }) { if (!store) { return (
@@ -1038,7 +1500,92 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { const [cart, setCart] = useState([]); const [wholeDiscountType, setWholeDiscountType] = useState('pct'); // 'pct' or 'val' const [wholeDiscountVal, setWholeDiscountVal] = useState(0); - const [paymentMode, setPaymentMode] = useState('Cash'); // Cash, UPI, Card, Mixed, IPD_Credit + const [paymentMode, setPaymentMode] = useState('Cash'); // Cash, UPI, Card, Mixed, IPD_Credit, Staff_Credit + const [draftBillsList, setDraftBillsList] = useState([]); + const [showDraftModal, setShowDraftModal] = useState(false); + const [creditAccounts, setCreditAccounts] = useState([]); + const [selectedCreditAccount, setSelectedCreditAccount] = useState(null); + + const loadDraftBills = async () => { + if (!store?.store_id) return; + try { + const res = await fetch(`${API_BASE}drafts/list?store_id=${store.store_id}&status=DRAFT`); + const data = await res.json(); + if (data.status) { + setDraftBillsList(data.drafts || []); + } + } catch (e) { + console.error('Error fetching drafts:', e); + } + }; + + const loadCreditAccountsList = async () => { + if (!store?.store_id) return; + try { + const res = await fetch(`${API_BASE}credit/accounts?store_id=${store.store_id}`); + const data = await res.json(); + if (data.status) { + setCreditAccounts(data.accounts || []); + } + } catch (e) { + console.error('Error fetching credit accounts:', e); + } + }; + + useEffect(() => { + if (store?.store_id) { + loadDraftBills(); + loadCreditAccountsList(); + const timer = setInterval(loadDraftBills, 25000); + return () => clearInterval(timer); + } + }, [store?.store_id]); + + const handleLoadDraftBillIntoCart = (draft) => { + try { + const items = typeof draft.items_json === 'string' ? JSON.parse(draft.items_json) : draft.items_json; + if (!Array.isArray(items) || items.length === 0) { + notify('Draft contains no items.', 'warning'); + return; + } + + const formattedCart = items.map((it, idx) => ({ + ...it, + cart_item_id: Date.now() + '_' + idx, + discount_type: it.discount_type || 'NONE', + discount_val: Number(it.discount_val) || 0 + })); + + setCart(formattedCart); + if (draft.patient_name) setPatientQuery(draft.patient_name); + if (draft.uhid) setWalkinUhid(draft.uhid); + if (draft.patient_mobile) setWalkinMobile(draft.patient_mobile); + if (draft.doctor_name) setDoctorName(draft.doctor_name); + + setShowDraftModal(false); + notify(`Draft [${draft.draft_code}] with ${items.length} items loaded into billing cart!`, 'success'); + } catch (err) { + notify('Failed to load draft items: ' + err.message, 'error'); + } + }; + + const handleCancelDraft = async (draftId) => { + if (!confirm('Discard this draft bill?')) return; + try { + const res = await fetch(`${API_BASE}drafts/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ draft_id: draftId }) + }); + const data = await res.json(); + if (data.status) { + notify('Draft discarded.', 'info'); + loadDraftBills(); + } + } catch (e) { + notify('Error cancelling draft: ' + e.message, 'error'); + } + }; const [paymentRef, setPaymentRef] = useState(''); const [splitCash, setSplitCash] = useState(''); const [splitUpi, setSplitUpi] = useState(''); @@ -2009,6 +2556,11 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { return; } + if (paymentMode === 'Staff_Credit' && !selectedCreditAccount) { + notify('Please select a Staff / Customer credit account to bill.', 'error'); + return; + } + if (paymentMode === 'IPD_Credit' && (!selectedPatient || !selectedPatient.has_active_ipd)) { notify('IPD Credit mode is only allowed for admitted IPD patients.', 'error'); return; @@ -2030,6 +2582,8 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { finalCard = netPayable; } else if (paymentMode === 'IPD_Credit') { finalCredit = netPayable; + } else if (paymentMode === 'Staff_Credit') { + finalCredit = netPayable; } else if (paymentMode === 'Mixed') { finalCash = splitCashNum; finalUpi = splitUpiNum; @@ -2059,6 +2613,8 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { doctor_name: doctorName || (selectedPatient?.active_opd?.doctor_name || selectedPatient?.active_ipd?.doctor_name || 'Dr. Consultant'), doctor_reg_no: doctorRegNo, payment_mode: refundPayable > 0 ? (refundMode === 'Cash' ? 'Cash' : 'UPI') : paymentMode, + credit_account_id: (paymentMode === 'Staff_Credit' && selectedCreditAccount) ? selectedCreditAccount.account_id : null, + credit_person_name: (paymentMode === 'Staff_Credit' && selectedCreditAccount) ? selectedCreditAccount.account_name : null, cash_paid: finalCash, upi_paid: finalUpi, card_paid: finalCard, @@ -2072,6 +2628,9 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { card_ref_no: cardRefNo, whole_discount_type: wholeDiscountType, whole_discount_val: Number(wholeDiscountVal) || 0, + biller_name: activeOperator?.full_name || 'Store Staff', + operator_name: activeOperator?.full_name || 'Store Staff', + user_id: activeOperator?.user_id || 1, items: cart.map(it => { const info = getItemLineInfo(it); const lineGross = info.lineGross; @@ -2564,6 +3123,21 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) { )}
+ {(cart.length > 0 || selectedPatient) && (
+
+ +
+ {paymentMode === 'Staff_Credit' && ( +
+
+ Staff / Customer Credit Account + Ledger Debit +
+
+ + +
+ {selectedCreditAccount && ( +
+
+ Dept / ID: + {selectedCreditAccount.department || selectedCreditAccount.staff_id || 'N/A'} +
+
+ Current Outstanding: + 0 ? 'text-warning' : 'text-success'}`}> + ₹{Number(selectedCreditAccount.current_balance || 0).toFixed(2)} + +
+
+ Credit Limit: + ₹{Number(selectedCreditAccount.credit_limit || 0).toFixed(2)} +
+
+ Balance after this bill: + + ₹{(Number(selectedCreditAccount.current_balance || 0) + netPayable).toFixed(2)} + +
+
+ )} +
+ )} + {paymentMode === 'IPD_Credit' && (
@@ -5342,6 +5977,107 @@ function PosCounter({ store, notify, initialEditInvoice, onClearEditInvoice }) {
)} + + {/* Mobile Draft Bills Modal */} + {showDraftModal && ( +
+
+
+
+
+ +
+
Mobile App Rack Drafts Queue
+ Draft bills collected from pharmacy shelves via smartphone +
+
+ +
+
+ {draftBillsList.length === 0 ? ( +
+ +
No Pending Draft Bills
+

Medicines added by rack collectors from the mobile app will automatically show up here.

+
+ ) : ( +
+ + + + + + + + + + + + + + {draftBillsList.map((d) => ( + + + + + + + + + + ))} + +
Draft CodePatient / WardItemsEst. AmountCollectorTimeAction
+ {d.draft_code} + +
{d.patient_name || 'Walk-in'}
+ + {d.ward_name ? `${d.ward_name} / Bed ${d.bed_no}` : (d.patient_mobile || 'No bed info')} + +
+ {d.total_items} items + + ₹{Number(d.estimated_amount || 0).toFixed(2)} + + {d.collected_by_name || 'Staff'} + + {d.created_at?.split(' ')[1] || d.created_at} + + + +
+
+ )} +
+
+ + +
+
+
+
+ )} ); } @@ -6410,20 +7146,49 @@ function InvoicesView({ store, notify, onEditInPos }) { // ========================================================================= // 2. LIVE INVENTORY & BATCH MATRIX VIEW // ========================================================================= -function InventoryView({ store, notify }) { +function InventoryView({ store, notify, activeOperator }) { if (!store) return null; + const [invSubTab, setInvSubTab] = useState('live'); // 'live', 'physical_audit', 'audit_logs' const [stock, setStock] = useState([]); const [summary, setSummary] = useState({}); const [filter, setFilter] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); const [loading, setLoading] = useState(false); + // Single Batch Quick Reconcile Modal State + const [reconcileModalItem, setReconcileModalItem] = useState(null); + const [reconcilePhysicalQty, setReconcilePhysicalQty] = useState(''); + const [reconcileAuditType, setReconcileAuditType] = useState('PHYSICAL_VARIATION'); + const [reconcileRemarks, setReconcileRemarks] = useState(''); + const [isSubmittingReconcile, setIsSubmittingReconcile] = useState(false); + + // Cycle Count Audit Sheet State + const [cycleRackFilter, setCycleRackFilter] = useState('all'); + const [cycleSearch, setCycleSearch] = useState(''); + const [cycleCounts, setCycleCounts] = useState({}); // { [batch_id]: { physical_qty, audit_type, remarks } } + const [isCommittingCycle, setIsCommittingCycle] = useState(false); + + // Audit Log History State + const [auditLogs, setAuditLogs] = useState([]); + const [auditTotal, setAuditTotal] = useState(0); + const [auditFilterType, setAuditFilterType] = useState('all'); + const [auditSearch, setAuditSearch] = useState(''); + const [auditFromDate, setAuditFromDate] = useState(''); + const [auditToDate, setAuditToDate] = useState(''); + const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); + useEffect(() => { if (store?.store_id) { loadStock(); } }, [store?.store_id, filter]); + useEffect(() => { + if (invSubTab === 'audit_logs' && store?.store_id) { + loadAuditLogs(); + } + }, [invSubTab, store?.store_id, auditFilterType, auditFromDate, auditToDate]); + const loadStock = async () => { setLoading(true); try { @@ -6440,136 +7205,918 @@ function InventoryView({ store, notify }) { } }; + const loadAuditLogs = async () => { + setLoadingAuditLogs(true); + try { + let url = `${API_BASE}stock/audit-list?store_id=${store.store_id}&limit=100`; + if (auditFilterType !== 'all') url += `&audit_type=${encodeURIComponent(auditFilterType)}`; + if (auditSearch.trim()) url += `&search=${encodeURIComponent(auditSearch.trim())}`; + if (auditFromDate) url += `&from_date=${auditFromDate}`; + if (auditToDate) url += `&to_date=${auditToDate}`; + + const res = await fetch(url); + const data = await res.json(); + if (data.status) { + setAuditLogs(data.logs || []); + setAuditTotal(data.total || 0); + } + } catch (e) { + console.error("Failed to load audit logs:", e); + } finally { + setLoadingAuditLogs(false); + } + }; + + // Open Single Reconcile Modal + const openReconcileModal = (item) => { + setReconcileModalItem(item); + setReconcilePhysicalQty(item.current_qty !== undefined ? item.current_qty : ''); + setReconcileAuditType('PHYSICAL_VARIATION'); + setReconcileRemarks(''); + }; + + // Submit Single Reconcile + const handleReconcileSubmit = async () => { + if (!reconcileModalItem) return; + const pQty = Number(reconcilePhysicalQty); + if (isNaN(pQty) || pQty < 0) { + notify('Please enter a valid physical count (0 or greater).', 'error'); + return; + } + + setIsSubmittingReconcile(true); + try { + const res = await fetch(API_BASE + 'stock/reconcile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + item_id: reconcileModalItem.item_id, + batch_id: reconcileModalItem.batch_id, + physical_qty: pQty, + audit_type: reconcileAuditType, + remarks: reconcileRemarks.trim() || `Physical audit: System ${reconcileModalItem.current_qty} -> Physical ${pQty}`, + operator_id: activeOperator?.user_id || 1, + operator_name: activeOperator?.full_name || 'Staff' + }) + }); + const data = await res.json(); + if (data.status) { + const diffMsg = data.variation_qty === 0 + ? 'Counts verified (0 discrepancy).' + : (data.variation_qty < 0 + ? `Shortage adjusted: ${Math.abs(data.variation_qty)} units (Loss: ₹${data.variation_val})` + : `Surplus added: +${data.variation_qty} units (₹${data.variation_val})`); + notify(`Stock reconciled! ${diffMsg}`, 'success'); + setReconcileModalItem(null); + loadStock(); + } else { + notify(data.message || 'Failed to reconcile stock.', 'error'); + } + } catch (e) { + notify('Network error during reconciliation.', 'error'); + } finally { + setIsSubmittingReconcile(false); + } + }; + + // Cycle Count Handler + const handleCycleCountChange = (batchId, val) => { + setCycleCounts(prev => ({ + ...prev, + [batchId]: { + ...(prev[batchId] || {}), + physical_qty: val + } + })); + }; + + const handleCycleReasonChange = (batchId, reason) => { + setCycleCounts(prev => ({ + ...prev, + [batchId]: { + ...(prev[batchId] || {}), + audit_type: reason + } + })); + }; + + const handleCycleRemarksChange = (batchId, remarks) => { + setCycleCounts(prev => ({ + ...prev, + [batchId]: { + ...(prev[batchId] || {}), + remarks: remarks + } + })); + }; + + // Submit Batch Cycle Count + const handleCommitCycleAudit = async () => { + const changedCounts = []; + for (const [bId, data] of Object.entries(cycleCounts)) { + if (data && data.physical_qty !== undefined && data.physical_qty !== '') { + const bItem = stock.find(s => s.batch_id == bId); + if (bItem) { + const pQty = Number(data.physical_qty); + const sQty = Number(bItem.current_qty); + if (!isNaN(pQty) && pQty >= 0 && (pQty !== sQty || data.force_log)) { + changedCounts.push({ + item_id: bItem.item_id, + batch_id: Number(bId), + physical_qty: pQty, + audit_type: data.audit_type || 'PHYSICAL_VARIATION', + remarks: data.remarks || `Cycle Count Audit: System ${sQty} -> Physical ${pQty}` + }); + } + } + } + } + + if (changedCounts.length === 0) { + notify('No stock count adjustments detected in the cycle sheet.', 'warning'); + return; + } + + setIsCommittingCycle(true); + try { + const res = await fetch(API_BASE + 'stock/batch-reconcile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + counts: changedCounts, + operator_id: activeOperator?.user_id || 1, + operator_name: activeOperator?.full_name || 'Auditor', + remarks: `Routine Shelf Cycle Audit by ${activeOperator?.full_name || 'Auditor'}` + }) + }); + const data = await res.json(); + if (data.status) { + notify(`Cycle audit committed: ${data.reconciled_count} batches reconciled (Discrepancy: ₹${data.total_value})!`, 'success'); + setCycleCounts({}); + loadStock(); + } else { + notify(data.message || 'Failed to commit batch audit.', 'error'); + } + } catch (e) { + notify('Network error during batch reconciliation.', 'error'); + } finally { + setIsCommittingCycle(false); + } + }; + + // Extract unique racks/shelves from stock list + const uniqueRacks = [...new Set(stock.map(s => s.rack_no).filter(Boolean))]; + + // Filter stock for Live View const filteredStock = stock.filter(s => s.item_name.toLowerCase().includes(searchTerm.toLowerCase()) || (s.generic_name && s.generic_name.toLowerCase().includes(searchTerm.toLowerCase())) || s.batch_no.toLowerCase().includes(searchTerm.toLowerCase()) ); + // Filter stock for Cycle Audit View + const cycleFilteredStock = stock.filter(s => { + const matchesRack = cycleRackFilter === 'all' || s.rack_no === cycleRackFilter; + const matchesSearch = !cycleSearch.trim() || + s.item_name.toLowerCase().includes(cycleSearch.toLowerCase()) || + (s.generic_name && s.generic_name.toLowerCase().includes(cycleSearch.toLowerCase())) || + s.batch_no.toLowerCase().includes(cycleSearch.toLowerCase()) || + (s.shelf_no && s.shelf_no.toLowerCase().includes(cycleSearch.toLowerCase())); + return matchesRack && matchesSearch; + }); + + // Summary of discrepancies in cycle sheet + let cycleDiscrepancyCount = 0; + let cycleDiscrepancyValue = 0; + for (const item of cycleFilteredStock) { + const cntData = cycleCounts[item.batch_id]; + if (cntData && cntData.physical_qty !== undefined && cntData.physical_qty !== '') { + const p = Number(cntData.physical_qty); + const s = Number(item.current_qty); + if (!isNaN(p) && p !== s) { + cycleDiscrepancyCount++; + const rate = Number(item.ptr || item.mrp || 0); + cycleDiscrepancyValue += Math.abs(p - s) * rate; + } + } + } + return (
- {/* KPI Summary Cards */} -
-
-
- Total Batches in Store -

{summary.total_rows || 0}

-
+ {/* Top Sub-Navigation Header */} +
+
+ + +
-
-
- Stock Cost Valuation (PTR) -

₹{(summary.total_cost_valuation || 0).toLocaleString('en-IN')}

-
-
-
-
- Stock Retail Valuation (MRP) -

₹{(summary.total_mrp_valuation || 0).toLocaleString('en-IN')}

-
-
-
-
- Counter / URL -
MedicalStore/{store.store_slug}
-
+ +
+ + + Auditor: {activeOperator ? activeOperator.full_name : 'Store Staff'} + + {activeOperator ? activeOperator.user_role.toUpperCase() : 'COUNTER'} + +
- {/* Filter Tabs & Search Bar */} -
-
-
- - - - + {/* ========================================================================= */} + {/* SUB-VIEW 1: LIVE STOCK & BATCHES (WITH QUICK RECONCILE MODAL) */} + {/* ========================================================================= */} + {invSubTab === 'live' && ( +
+ {/* KPI Summary Cards */} +
+
+
+ Total Batches in Store +

{summary.total_rows || 0}

+
+
+
+
+ Stock Cost Valuation (PTR) +

₹{(summary.total_cost_valuation || 0).toLocaleString('en-IN')}

+
+
+
+
+ Stock Retail Valuation (MRP) +

₹{(summary.total_mrp_valuation || 0).toLocaleString('en-IN')}

+
+
+
+
+ Counter / URL +
MedicalStore/{store.store_slug}
+
+
-
- setSearchTerm(e.target.value)} - /> -
-
-
+ {/* Filter Tabs & Search Bar */} +
+
+
+ + + + +
- {/* Stock Table */} -
-
- - - - - - - - - - - - - - - - - {loading ? ( - - - - ) : filteredStock.length === 0 ? ( - - - - ) : ( - filteredStock.map((s, idx) => ( - - - - - - - - - - - +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + + + {/* Stock Table */} +
+
+
#Item & Generic MoleculeCategoryBatch NoExpiryCurrent QtyPTR (₹)MRP (₹)Cost Value (₹)Status
- Loading inventory... -
No stock records found.
{idx + 1} -
{s.item_name}
- {s.generic_name} • HSN: {s.hsn_code} -
{s.category}{s.batch_no}{s.expiry_display} - - {s.current_qty} - - ₹{s.ptr}₹{s.mrp}₹{s.cost_valuation} - {s.is_expired ? ( - EXPIRED - ) : s.is_near_expiry ? ( - Near Expiry - ) : ( - Good Stock - )} -
+ + + + + + + + + + + + + + - )) - )} - -
#Item & Generic MoleculeCategoryBatch NoLocationExpirySystem QtyPTR (₹)MRP (₹)Valuation (₹)StatusAction
+ + + {loading ? ( + + + Loading inventory batches... + + + ) : filteredStock.length === 0 ? ( + + + + No stock records found matching filters. + + + ) : ( + filteredStock.map((s, idx) => ( + + {idx + 1} + +
{s.item_name}
+ {s.generic_name || 'Allopathic'} • HSN: {s.hsn_code || '3004'} + + {s.category || 'Medicine'} + {s.batch_no} + + + {s.shelf_no ? `Sh: ${s.shelf_no}` : ''} {s.rack_no ? `• Rk: ${s.rack_no}` : (s.shelf_no ? '' : '-')} + + + {s.expiry_display || s.expiry_date} + + + {s.current_qty} + + Units + + ₹{s.ptr} + ₹{s.mrp} + ₹{s.cost_valuation} + + {s.is_expired ? ( + EXPIRED + ) : s.is_near_expiry ? ( + Near Expiry + ) : ( + Good Stock + )} + + + + + + )) + )} + + +
+
-
+ )} + + {/* ========================================================================= */} + {/* SUB-VIEW 2: PHYSICAL STOCK TAKE & CYCLE COUNT SHEET */} + {/* ========================================================================= */} + {invSubTab === 'physical_audit' && ( +
+ {/* Header Bar with Filters & Commit Button */} +
+
+
+
+ + +
+
+ + setCycleSearch(e.target.value)} + /> +
+
+ + {/* Cycle Audit Live Discrepancy KPI & Commit Button */} +
+
+
Discrepancies Found
+
+ 0 ? 'text-danger' : 'text-success'}> + {cycleDiscrepancyCount} items + + {cycleDiscrepancyCount > 0 && ( + + (₹{cycleDiscrepancyValue.toFixed(2)}) + + )} +
+
+ +
+
+
+ + {/* Cycle Count Table Sheet */} +
+
+ + + + + + + + + + + + + + + + + {cycleFilteredStock.length === 0 ? ( + + + + ) : ( + cycleFilteredStock.map((item, idx) => { + const countData = cycleCounts[item.batch_id] || {}; + const hasInput = countData.physical_qty !== undefined && countData.physical_qty !== ''; + const pVal = hasInput ? Number(countData.physical_qty) : null; + const sVal = Number(item.current_qty); + const diff = pVal !== null ? (pVal - sVal) : 0; + const rate = Number(item.ptr || item.mrp || 0); + + return ( + + + + + + + + + + + + + ); + }) + )} + +
#Medicine ItemBatch NoLocationSystem QtyPhysical CountVarianceAudit ReasonRemarks / NotesAction
+ No items found in this shelf/rack. +
{idx + 1} +
{item.item_name}
+ {item.generic_name} • Packing: {item.unit_pack || 'Unit'} +
+ {item.batch_no} + Exp: {item.expiry_display || item.expiry_date} + + + {item.shelf_no ? `Sh: ${item.shelf_no}` : ''} {item.rack_no ? `• Rk: ${item.rack_no}` : (item.shelf_no ? '' : 'Normal')} + + + + {sVal} + + + handleCycleCountChange(item.batch_id, e.target.value)} + /> + + {hasInput ? ( + diff === 0 ? ( + + ✓ Match (0) + + ) : diff < 0 ? ( + + {diff} Units + + ) : ( + + +{diff} Units + + ) + ) : ( + - + )} + + + + handleCycleRemarksChange(item.batch_id, e.target.value)} + /> + + {hasInput && ( + + )} +
+
+
+
+ )} + + {/* ========================================================================= */} + {/* SUB-VIEW 3: STOCK DISCREPANCY AUDIT LOG & RECONCILIATION HISTORY */} + {/* ========================================================================= */} + {invSubTab === 'audit_logs' && ( +
+ {/* Audit Filter Toolbar */} +
+
+
+ + +
+
+ +
+ setAuditSearch(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') loadAuditLogs(); }} + /> + +
+
+
+ + setAuditFromDate(e.target.value)} + /> +
+
+ + setAuditToDate(e.target.value)} + /> +
+
+ +
+
+
+ + {/* Audit Logs Table */} +
+
+ + + + + + + + + + + + + + + + + {loadingAuditLogs ? ( + + + + ) : auditLogs.length === 0 ? ( + + + + ) : ( + auditLogs.map((log) => { + const vQty = Number(log.variation_qty || 0); + return ( + + + + + + + + + + + + + ); + }) + )} + +
# & TimestampMedicine ItemBatch NoSystem QtyPhysical QtyVariance QtyValuation (₹)Audit Event / ReasonRemarksConducted By
+ Loading audit trail... +
+ + No stock audit events recorded yet for this criteria. +
+
#{log.audit_id}
+ {log.created_at} +
+
{log.item_name || `Item #${log.item_id}`}
+ {log.generic_name} • {log.unit_pack || 'Unit'} +
+ {log.batch_no || `Batch #${log.batch_id}`} + {log.expiry_date && Exp: {log.expiry_date}} + {log.system_qty}{log.physical_qty} + {vQty === 0 ? ( + 0 + ) : vQty < 0 ? ( + + {vQty} Units + + ) : ( + + +{vQty} Units + + )} + ₹{Number(log.total_value || 0).toFixed(2)} + + {log.audit_type ? log.audit_type.replace(/_/g, ' ') : 'AUDIT'} + + {log.remarks || '-'} + + {log.conducted_by_name || 'Staff'} + +
+
+
+
+ )} + + {/* ========================================================================= */} + {/* SINGLE BATCH QUICK RECONCILE MODAL */} + {/* ========================================================================= */} + {reconcileModalItem && ( +
+
+
+
+
+
+ +
+
+
Stock Reconciliation & Discrepancy Audit
+ {store.store_name} • Physical vs System Shelf Count +
+
+ +
+
+ {/* Medicine Details Card */} +
+
{reconcileModalItem.item_name}
+
+ {reconcileModalItem.generic_name} • Unit Pack: {reconcileModalItem.unit_pack || 'Unit'} +
+
+
+ Batch No: {reconcileModalItem.batch_no} +
+
+ Expiry: {reconcileModalItem.expiry_display || reconcileModalItem.expiry_date} +
+
+ PTR / Cost: ₹{reconcileModalItem.ptr} +
+
+ MRP: ₹{reconcileModalItem.mrp} +
+
+
+ + {/* Comparison Grid */} +
+
+
+ System Stock + {reconcileModalItem.current_qty} + Units +
+
+
+
+ Physical Shelf Count + setReconcilePhysicalQty(e.target.value)} + autoFocus + /> +
+
+
+ + {/* Live Variance Alert */} + {(() => { + const sys = Number(reconcileModalItem.current_qty || 0); + const phy = Number(reconcilePhysicalQty !== '' ? reconcilePhysicalQty : sys); + const diff = phy - sys; + const rate = Number(reconcileModalItem.ptr || reconcileModalItem.mrp || 0); + const val = Math.abs(diff) * rate; + + if (diff === 0) { + return ( +
+ +
Counts Match Perfectly. Physical shelf count equals system record (0 discrepancy).
+
+ ); + } else if (diff < 0) { + return ( +
+ +
+ Shortage Detected: {Math.abs(diff)} Units Short +
Estimated Discrepancy Value: ₹{val.toFixed(2)} (System {sys} > Shelf {phy})
+
+
+ ); + } else { + return ( +
+ +
+ Surplus Stock: +{diff} Extra Units Found +
Estimated Valuation: ₹{val.toFixed(2)} (Shelf {phy} > System {sys})
+
+
+ ); + } + })()} + + {/* Reason & Remarks */} +
+ + +
+ +
+ + setReconcileRemarks(e.target.value)} + /> +
+ +
+ + Audited By: {activeOperator ? activeOperator.full_name : 'Store Staff'} ({activeOperator?.user_role || 'auditor'}) + + + Date: {new Date().toLocaleDateString('en-IN')} + +
+
+
+ + +
+
+
+
+ )}
); } @@ -6577,7 +8124,7 @@ function InventoryView({ store, notify }) { // ========================================================================= // 3. INWARD PURCHASES, SUPPLIERS & EXCEL / CSV IMPORT // ========================================================================= -function PurchaseView({ store, notify }) { +function PurchaseView({ store, notify, activeOperator }) { if (!store) return null; const [subTab, setSubTab] = useState('inward'); // inward, excel, suppliers const [suppliers, setSuppliers] = useState([]); @@ -6609,6 +8156,9 @@ function PurchaseView({ store, notify }) { const [activeSearchRow, setActiveSearchRow] = useState(null); const [suggestions, setSuggestions] = useState([]); const [isSearchingMeds, setIsSearchingMeds] = useState(false); + const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const activeSuggestionRef = useRef(null); + const suggestionsDropdownRef = useRef(null); const [showMasterModal, setShowMasterModal] = useState(false); const [masterTargetRow, setMasterTargetRow] = useState(null); const [supportData, setSupportData] = useState({ formulations: [], companies: [], gst_rates: [0, 5, 18, 40, 12, 28], schedules: [] }); @@ -6717,9 +8267,41 @@ function PurchaseView({ store, notify }) { }); }; - const resetItemForm = () => { + const resetItemForm = (shouldFocusSearch = false) => { setItemForm(initialItemForm); setEditingItemIndex(null); + setSelectedSuggestionIndex(-1); + setActiveSearchRow(false); + setSuggestions([]); + if (shouldFocusSearch) { + setTimeout(() => { + const searchInput = document.getElementById('pharma_product_search_input'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + }, 80); + } + }; + + const handleFieldKeyDown = (e, nextFieldId) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + handleAddOrUpdateItem(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (nextFieldId === 'submit') { + handleAddOrUpdateItem(); + } else if (nextFieldId) { + const el = document.getElementById(nextFieldId); + if (el) { + el.focus(); + if (el.select) el.select(); + } + } + } }; const billTotals = useMemo(() => { @@ -6825,11 +8407,13 @@ function PurchaseView({ store, notify }) { notify('Please enter a Batch Number.', 'error'); return; } - if (Number(itemForm.qty_packs) <= 0) { - notify('Quantity in packs must be greater than 0.', 'error'); + const qtyPacks = Number(itemForm.qty_packs); + if (isNaN(qtyPacks) || qtyPacks === 0) { + notify('Quantity in packs cannot be 0. Enter positive qty for stock entry, or negative qty for return/debit.', 'error'); return; } + const isReturnItem = qtyPacks < 0; const yy = String(itemForm.exp_year).length === 2 ? ('20' + itemForm.exp_year) : String(itemForm.exp_year); const mm = String(itemForm.exp_month).padStart(2, '0'); const expiryDate = `${yy}-${mm}-01`; @@ -6839,6 +8423,7 @@ function PurchaseView({ store, notify }) { expiry_date: expiryDate, exp_month: mm, exp_year: itemForm.exp_year, + is_return: isReturnItem, gst_rate: (Number(itemForm.cgst_pct) || 0) + (Number(itemForm.sgst_pct) || 0) }); @@ -6850,10 +8435,14 @@ function PurchaseView({ store, notify }) { notify(`Updated "${rowItem.item_name}" in invoice items list.`, 'success'); } else { setRows(prev => [...prev, rowItem]); - notify(`Added "${rowItem.item_name}" to invoice items list.`, 'success'); + if (isReturnItem) { + notify(`Added Return / Debit item "${rowItem.item_name}" (${rowItem.qty_packs} packs, ₹${rowItem.net_amount}) to invoice.`, 'warning'); + } else { + notify(`Added "${rowItem.item_name}" to invoice items list.`, 'success'); + } } - resetItemForm(); + resetItemForm(true); }; const handleEditRow = (index) => { @@ -6880,6 +8469,13 @@ function PurchaseView({ store, notify }) { if (r.item_id > 0) { fetchProductPurchaseHistory(r.item_id, r.item_name); } + setTimeout(() => { + const qtyInput = document.getElementById('pharma_qty_input'); + if (qtyInput) { + qtyInput.focus(); + qtyInput.select(); + } + }, 120); }; const includeChallanItem = (challan, cItem) => { @@ -7391,6 +8987,42 @@ function PurchaseView({ store, notify }) { fetchRecentPurchases(); }, []); + // Auto-scroll active suggestion into view when navigating with Arrow keys + useEffect(() => { + if (activeSuggestionRef.current) { + activeSuggestionRef.current.scrollIntoView({ block: 'nearest' }); + } + }, [selectedSuggestionIndex]); + + // Close suggestions dropdown when clicking outside + useEffect(() => { + const handleOutsideClick = (e) => { + const searchInput = document.getElementById('pharma_product_search_input'); + if (searchInput && searchInput.contains(e.target)) return; + if (suggestionsDropdownRef.current && suggestionsDropdownRef.current.contains(e.target)) return; + setActiveSearchRow(false); + setSelectedSuggestionIndex(-1); + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + + // Global F2 hotkey to jump to Product Search anytime + useEffect(() => { + const handleGlobalKey = (e) => { + if (e.key === 'F2') { + e.preventDefault(); + const searchInput = document.getElementById('pharma_product_search_input'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + } + }; + window.addEventListener('keydown', handleGlobalKey); + return () => window.removeEventListener('keydown', handleGlobalKey); + }, []); + const fetchMasterSupportData = async () => { try { const res = await fetch(API_BASE + 'items/master-support-data'); @@ -7540,6 +9172,7 @@ function PurchaseView({ store, notify }) { if (!value.trim() || value.trim().length < 1) { setSuggestions([]); + setSelectedSuggestionIndex(-1); return; } @@ -7549,7 +9182,9 @@ function PurchaseView({ store, notify }) { const res = await fetch(API_BASE + 'items/search-master?q=' + encodeURIComponent(value.trim())); const data = await res.json(); if (data.status) { - setSuggestions(data.items || []); + const list = data.items || []; + setSuggestions(list); + setSelectedSuggestionIndex(list.length > 0 ? 0 : -1); } } catch (err) { console.error('Master search error:', err); @@ -7559,6 +9194,51 @@ function PurchaseView({ store, notify }) { }, 200); }; + const handleProductSearchKeyDown = (e) => { + if (!activeSearchRow || suggestions.length === 0) { + if (e.key === 'ArrowDown' && itemForm.item_name.trim().length >= 1) { + e.preventDefault(); + handleProductSearchChange(itemForm.item_name); + } + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedSuggestionIndex(prev => { + if (suggestions.length === 0) return -1; + return (prev + 1) % suggestions.length; + }); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedSuggestionIndex(prev => { + if (suggestions.length === 0) return -1; + return (prev - 1 + suggestions.length) % suggestions.length; + }); + return; + } + + if (e.key === 'Enter') { + e.preventDefault(); + const targetIdx = selectedSuggestionIndex >= 0 && selectedSuggestionIndex < suggestions.length ? selectedSuggestionIndex : 0; + const chosen = suggestions[targetIdx]; + if (chosen) { + handleSelectMedicine(chosen); + } + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + setActiveSearchRow(false); + setSelectedSuggestionIndex(-1); + return; + } + }; + const handleSelectMedicine = (item) => { const gstRate = Number(item.gst_rate) || 12; const uPack = Number(item.units_per_pack) || 10; @@ -7583,10 +9263,19 @@ function PurchaseView({ store, notify }) { setActiveSearchRow(false); setSuggestions([]); + setSelectedSuggestionIndex(-1); if (item.item_id) { fetchProductPurchaseHistory(item.item_id, item.item_name); } + + setTimeout(() => { + const batchInput = document.getElementById('pharma_batch_no_input'); + if (batchInput) { + batchInput.focus(); + batchInput.select(); + } + }, 120); }; const openAddMasterModal = (initialName = '') => { @@ -7680,6 +9369,9 @@ function PurchaseView({ store, notify }) { supplier_invoice_no: invoiceNo.trim(), is_challan: isChallan ? 1 : 0, challan_no: isChallan ? invoiceNo.trim() : null, + received_by_name: activeOperator?.full_name || 'Store Staff', + operator_name: activeOperator?.full_name || 'Store Staff', + user_id: activeOperator?.user_id || 1, converted_from_challan_id: convertedChallanId, invoice_date: invoiceDate, items: itemsPayload @@ -7977,12 +9669,16 @@ function PurchaseView({ store, notify }) { rows.map((r, i) => { const expDisplay = r.exp_month && r.exp_year ? `${r.exp_month}/${r.exp_year}` : (r.expiry_date ? r.expiry_date.slice(2, 7) : '-'); const taxAmt = ((Number(r.cgst_amount) || 0) + (Number(r.sgst_amount) || 0)).toFixed(2); + const isReturn = Number(r.qty_packs) < 0 || Number(r.net_amount) < 0; return ( - + {i + 1} {r.item_id || r.product_master_id || (i + 1)} -
{r.item_name}
+
+ {r.item_name} + {isReturn && RETURN} +
{r.generic_name && (
{r.generic_name}
)} @@ -7991,16 +9687,16 @@ function PurchaseView({ store, notify }) { {expDisplay} ₹{Number(r.mrp || 0).toFixed(2)} - {r.qty_packs} + {r.qty_packs} {Number(r.free_qty_packs) > 0 && +{r.free_qty_packs}F} ₹{Number(r.ptr || 0).toFixed(2)} - ₹{Number(r.amount || 0).toFixed(2)} + ₹{Number(r.amount || 0).toFixed(2)} {Number(r.discount_pct || 0)}% - ₹{taxAmt} - ₹{Number(r.cgst_amount || (Number(r.taxable_amount || 0) * (Number(r.cgst_pct || 6) / 100))).toFixed(2)} - ₹{Number(r.sgst_amount || (Number(r.taxable_amount || 0) * (Number(r.sgst_pct || 6) / 100))).toFixed(2)} - ₹{Number(r.net_amount || 0).toFixed(2)} + ₹{taxAmt} + ₹{Number(r.cgst_amount || (Number(r.taxable_amount || 0) * (Number(r.cgst_pct || 6) / 100))).toFixed(2)} + ₹{Number(r.sgst_amount || (Number(r.taxable_amount || 0) * (Number(r.sgst_pct || 6) / 100))).toFixed(2)} + ₹{Number(r.net_amount || 0).toFixed(2)}
{suggestions.length > 0 ? ( - suggestions.map((sug, sIdx) => ( -
{ - e.preventDefault(); - handleSelectMedicine(sug); - }} - > -
- {sug.item_name} - - {sug.category || 'Tablet'} - + suggestions.map((sug, sIdx) => { + const isSelected = sIdx === selectedSuggestionIndex; + return ( +
setSelectedSuggestionIndex(sIdx)} + onMouseDown={(e) => { + e.preventDefault(); + handleSelectMedicine(sug); + }} + > +
+ + {isSelected && } + {sug.item_name} + +
+ {isSelected && ( + + Enter + + )} + + {sug.category || 'Tablet'} + +
+
+ {sug.generic_name && ( +
{sug.generic_name}
+ )} +
+ Pack: {sug.units_per_pack || 10}s + GST: {sug.gst_rate}% + HSN: {sug.hsn_code || '3004'} + {sug.mrp ? MRP: ₹{sug.mrp} : null} +
- {sug.generic_name && ( -
{sug.generic_name}
- )} -
- Pack: {sug.units_per_pack || 10}s - GST: {sug.gst_rate}% - HSN: {sug.hsn_code || '3004'} - {sug.mrp ? MRP: ₹{sug.mrp} : null} -
-
- )) + ); + }) ) : ( !isSearchingMeds && (
@@ -8218,7 +9942,7 @@ function PurchaseView({ store, notify }) { : `Selected: ${itemForm.item_name} (Auto-filled with last batch, rate & location)` } -
@@ -8229,28 +9953,34 @@ function PurchaseView({ store, notify }) { handleItemFormChange('units_per_pack', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_batch_no_input')} />
handleItemFormChange('batch_no', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_exp_month_input')} />
handleItemFormChange('exp_year', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_mrp_input')} > {['24','25','26','27','28','29','30','31','32','33','34','35','36'].map(y => ( @@ -8274,20 +10006,29 @@ function PurchaseView({ store, notify }) { handleItemFormChange('mrp', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_qty_input')} />
- +
+ + {Number(itemForm.qty_packs) < 0 && ( + RETURN + )} +
handleItemFormChange('qty_packs', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_free_qty_input')} />
@@ -8299,9 +10040,11 @@ function PurchaseView({ store, notify }) { handleItemFormChange('free_qty_packs', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_ptr_input')} />
@@ -8309,10 +10052,12 @@ function PurchaseView({ store, notify }) { handleItemFormChange('ptr', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_disc_pct_input')} />
@@ -8331,9 +10076,11 @@ function PurchaseView({ store, notify }) { step="0.1" min="0" max="100" + id="pharma_disc_pct_input" className="form-control form-control-sm" value={itemForm.discount_pct} onChange={(e) => handleItemFormChange('discount_pct', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'submit')} />
@@ -8342,9 +10089,11 @@ function PurchaseView({ store, notify }) { type="number" step="0.01" min="0" + id="pharma_sch_amount_input" className="form-control form-control-sm" value={itemForm.sch_disc_amount} onChange={(e) => handleItemFormChange('sch_disc_amount', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_sch_disc_input')} />
@@ -8354,9 +10103,11 @@ function PurchaseView({ store, notify }) { step="0.1" min="0" max="100" + id="pharma_sch_disc_input" className="form-control form-control-sm" value={itemForm.sch_disc_pct} onChange={(e) => handleItemFormChange('sch_disc_pct', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'submit')} />
@@ -8420,19 +10171,23 @@ function PurchaseView({ store, notify }) { handleItemFormChange('selling_price', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_shelf_no_input')} />
handleItemFormChange('hsn_code', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_shelf_no_input')} />
@@ -8456,37 +10211,56 @@ function PurchaseView({ store, notify }) { handleItemFormChange('shelf_no', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'pharma_rack_no_input')} />
handleItemFormChange('rack_no', e.target.value)} + onKeyDown={(e) => handleFieldKeyDown(e, 'submit')} />
@@ -10692,36 +12466,222 @@ function TransferView({ store, stores, notify }) { // ========================================================================= function LedgersView({ store, notify }) { if (!store) return null; - const [accTab, setAccTab] = useState('daybook'); + const [accTab, setAccTab] = useState('daybook'); // 'daybook', 'bank_audit', 'gst', 'schedule_h1', 'credit_accounts' + + // Helper: CSV Download + const downloadCsv = (filename, rows) => { + const csvContent = "data:text/csv;charset=utf-8," + rows.map(e => e.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(",")).join("\n"); + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + // Helper: Open Print Window + const openPrintReport = (title, bodyHtml) => { + const win = window.open('', '_blank', 'width=950,height=750'); + if (!win) { + notify('Please allow popups to print report', 'warning'); + return; + } + const printDoc = win.document; + printDoc.open(); + const headerHtml = '
' + + '
' + title + ' — Ready to print
' + + '
' + + '
' + + ''; + + printDoc.write('<' + 'head>' + title + '' + + '' + + '' + + '' + headerHtml + bodyHtml + ''); + printDoc.close(); + }; + + // 1. Daybook State + const todayStr = new Date().toISOString().split('T')[0]; + const [daybookFromDate, setDaybookFromDate] = useState(todayStr); + const [daybookToDate, setDaybookToDate] = useState(todayStr); const [daybookData, setDaybookData] = useState(null); + const [loadingDaybook, setLoadingDaybook] = useState(false); + + // 2. Bank Audit State const [bankAuditData, setBankAuditData] = useState(null); - const [auditFilterStatus, setAuditFilterStatus] = useState('all'); // 'all', 'pending', 'reconciled' + const [auditFilterStatus, setAuditFilterStatus] = useState('all'); const [auditSearch, setAuditSearch] = useState(''); const [reconcilingSale, setReconcilingSale] = useState(null); const [auditNotes, setAuditNotes] = useState(''); const [auditorName, setAuditorName] = useState('Accountant'); const [isSubmittingAudit, setIsSubmittingAudit] = useState(false); + + // 3. GST State + const currentMonthStr = todayStr.substring(0, 7); // YYYY-MM + const [gstMonth, setGstMonth] = useState(currentMonthStr); const [gstData, setGstData] = useState(null); + const [loadingGst, setLoadingGst] = useState(false); + + // 4. Schedule H1 State + const [h1FromDate, setH1FromDate] = useState(todayStr); + const [h1ToDate, setH1ToDate] = useState(todayStr); + const [h1Search, setH1Search] = useState(''); const [h1Records, setH1Records] = useState([]); + const [loadingH1, setLoadingH1] = useState(false); + + // 5. Staff & Customer Credit Accounts State + const [creditAccounts, setCreditAccounts] = useState([]); + const [loadingCredit, setLoadingCredit] = useState(false); + const [creditSearch, setCreditSearch] = useState(''); + const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const [accFormType, setAccFormType] = useState('staff'); + const [accFormName, setAccFormName] = useState(''); + const [accFormPhone, setAccFormPhone] = useState(''); + const [accFormStaffId, setAccFormStaffId] = useState(''); + const [accFormDept, setAccFormDept] = useState(''); + const [accFormLimit, setAccFormLimit] = useState('5000'); + const [accFormRemarks, setAccFormRemarks] = useState(''); + const [isSavingAccount, setIsSavingAccount] = useState(false); + + // Credit Ledger Statement & Payment Settle Modal + const [selectedCreditAcc, setSelectedCreditAcc] = useState(null); + const [creditLedgerData, setCreditLedgerData] = useState(null); + const [loadingLedger, setLoadingLedger] = useState(false); + const [showPaymentModal, setShowPaymentModal] = useState(false); + const [payAmount, setPayAmount] = useState(''); + const [payMode, setPayMode] = useState('Cash'); + const [payRef, setPayRef] = useState(''); + const [payRemarks, setPayRemarks] = useState(''); + const [isRecordingPayment, setIsRecordingPayment] = useState(false); useEffect(() => { if (!store?.store_id) return; - if (accTab === 'daybook') loadDaybook(); + if (accTab === 'daybook') loadDaybook(daybookFromDate, daybookToDate); if (accTab === 'bank_audit') loadBankAudit(auditFilterStatus, auditSearch); - if (accTab === 'gst') loadGst(); - if (accTab === 'schedule_h1') loadScheduleH1(); - }, [accTab, store?.store_id, auditFilterStatus]); + if (accTab === 'gst') loadGst(gstMonth); + if (accTab === 'schedule_h1') loadScheduleH1(h1FromDate, h1ToDate, h1Search); + if (accTab === 'credit_accounts') loadCreditAccounts(); + }, [accTab, store?.store_id]); - const loadDaybook = async () => { + // DAYBOOK FUNCTIONS + const loadDaybook = async (from = daybookFromDate, to = daybookToDate) => { + setLoadingDaybook(true); try { - const res = await fetch(API_BASE + `accounting/daybook?store_id=${store.store_id}`); + const res = await fetch(`${API_BASE}accounting/daybook?store_id=${store.store_id}&from_date=${from}&to_date=${to}`); const data = await res.json(); if (data.status) setDaybookData(data); } catch (e) { console.error(e); + notify('Error loading daybook: ' + e.message, 'error'); + } finally { + setLoadingDaybook(false); } }; + const handleDaybookFilterPreset = (preset) => { + const today = new Date(); + let from = todayStr; + let to = todayStr; + + if (preset === 'today') { + from = todayStr; + to = todayStr; + } else if (preset === 'yesterday') { + const y = new Date(today); + y.setDate(today.getDate() - 1); + from = y.toISOString().split('T')[0]; + to = from; + } else if (preset === 'last7') { + const w = new Date(today); + w.setDate(today.getDate() - 6); + from = w.toISOString().split('T')[0]; + to = todayStr; + } else if (preset === 'month') { + from = `${todayStr.substring(0, 7)}-01`; + to = todayStr; + } + setDaybookFromDate(from); + setDaybookToDate(to); + loadDaybook(from, to); + }; + + const exportDaybookCsv = () => { + if (!daybookData || !daybookData.transactions?.length) { + notify('No transactions to export for selected dates.', 'warning'); + return; + } + const headers = ['Invoice No', 'Sale Date', 'Patient Name', 'UHID', 'Doctor Name', 'Payment Mode', 'Subtotal (Rs)', 'Discount (Rs)', 'CGST (Rs)', 'SGST (Rs)', 'Net Amount (Rs)']; + const rows = daybookData.transactions.map(t => [ + t.invoice_no, + t.sale_date, + t.patient_name, + t.uhid || '', + t.doctor_name, + t.payment_mode, + t.subtotal, + t.discount_amount, + t.cgst_amount, + t.sgst_amount, + t.net_amount + ]); + downloadCsv(`Daybook_${daybookFromDate}_to_${daybookToDate}.csv`, [headers, ...rows]); + notify('Daybook CSV exported successfully.', 'success'); + }; + + const printDaybook = () => { + if (!daybookData || !daybookData.transactions?.length) { + notify('No transactions to print.', 'warning'); + return; + } + const s = daybookData.summary; + const html = ` +
+
Total Sales: ₹${Number(s.total_sales || 0).toLocaleString('en-IN')} (${s.total_invoices} bills)
+
Cash in Drawer: ₹${Number(s.cash_in_drawer || 0).toLocaleString('en-IN')}
+
UPI / QR: ₹${Number(s.upi_collection || 0).toLocaleString('en-IN')}
+
IPD / Staff Credit: ₹${Number(s.ipd_credit || 0).toLocaleString('en-IN')}
+
+

Period: ${daybookFromDate} to ${daybookToDate}

+ + + + + + + + + + + + + + + ${daybookData.transactions.map(t => ` + + + + + + + + + + + `).join('')} + +
Invoice NoDate & TimePatient Name (UHID)DoctorModeCGST (₹)SGST (₹)Net Amount
${t.invoice_no}${t.sale_date}${t.patient_name} ${t.uhid ? '(' + t.uhid + ')' : ''}${t.doctor_name || 'N/A'}${t.payment_mode}₹${t.cgst_amount}₹${t.sgst_amount}₹${t.net_amount}
+ `; + openPrintReport(`Daybook Journal (${daybookFromDate} to ${daybookToDate})`, html); + }; + + // BANK AUDIT FUNCTIONS const loadBankAudit = async (status = auditFilterStatus, search = auditSearch) => { try { const params = new URLSearchParams({ @@ -10729,7 +12689,7 @@ function LedgersView({ store, notify }) { status: status, search: search }); - const res = await fetch(API_BASE + `accounting/bank-reconciliation?${params.toString()}`); + const res = await fetch(`${API_BASE}accounting/bank-reconciliation?${params.toString()}`); const data = await res.json(); if (data.status) setBankAuditData(data); } catch (e) { @@ -10766,28 +12726,371 @@ function LedgersView({ store, notify }) { } }; - const loadGst = async () => { + const exportBankCsv = () => { + if (!bankAuditData || !bankAuditData.transactions?.length) { + notify('No bank/UPI transactions to export.', 'warning'); + return; + } + const headers = ['Invoice No', 'Sale Date', 'Patient', 'Mode', 'Amount (Rs)', 'UPI Ref No', 'Card Ref No', 'Audit Status', 'Reconciled By', 'Notes']; + const bankTxns = bankAuditData.transactions || bankAuditData.records || []; + const rows = bankTxns.map(t => [ + t.invoice_no, + t.sale_date, + t.patient_name, + t.payment_mode, + t.net_amount, + t.upi_ref_no || '', + t.card_ref_no || '', + t.is_reconciled == 1 ? 'RECONCILED' : 'PENDING', + t.reconciled_by || '', + t.reconciled_notes || '' + ]); + downloadCsv(`Bank_UPI_Reconciliation_${todayStr}.csv`, [headers, ...rows]); + notify('Bank & UPI Audit CSV exported.', 'success'); + }; + + const printBankAudit = () => { + if (!bankAuditData || !bankAuditData.transactions?.length) { + notify('No bank transactions to print.', 'warning'); + return; + } + const s = bankAuditData.summary || {}; + const html = ` +
+
Total UPI / Bank Sales: ₹${Number(s.total_amount || 0).toLocaleString('en-IN')} (${s.total_count} txns)
+
Verified / Reconciled: ₹${Number(s.reconciled_amount || 0).toLocaleString('en-IN')} (${s.reconciled_count})
+
Pending Verification: ₹${Number(s.pending_amount || 0).toLocaleString('en-IN')} (${s.pending_count})
+
+ + + + + + + + + + + + + + + ${(bankAuditData.transactions || bankAuditData.records || []).map(t => ` + + + + + + + + + + + `).join('')} + +
Invoice NoDatePatient NameModeAmountUPI / UTR RefStatusAudited By
${t.invoice_no}${t.sale_date}${t.patient_name}${t.payment_mode}₹${t.net_amount}${t.upi_ref_no || t.card_ref_no || 'N/A'}${t.is_reconciled == 1 ? 'VERIFIED' : 'PENDING'}${t.reconciled_by || 'Unverified'}
+ `; + openPrintReport('Bank & UPI Statement Audit Report', html); + }; + + // GST REPORT FUNCTIONS + const loadGst = async (m = gstMonth) => { + setLoadingGst(true); try { - const res = await fetch(API_BASE + `accounting/gst-report?store_id=${store.store_id}`); + const res = await fetch(`${API_BASE}accounting/gst-report?store_id=${store.store_id}&month=${m}`); const data = await res.json(); if (data.status) setGstData(data); } catch (e) { console.error(e); + notify('Error loading GST report: ' + e.message, 'error'); + } finally { + setLoadingGst(false); } }; - const loadScheduleH1 = async () => { + const exportGstCsv = () => { + if (!gstData || !gstData.gstr1_sales?.length) { + notify('No GST data to export for this month.', 'warning'); + return; + } + const headers = ['HSN Code', 'GST Slab (%)', 'Taxable Value (Rs)', 'CGST (Rs)', 'SGST (Rs)', 'IGST (Rs)', 'Total Value (Rs)']; + const rows = gstData.gstr1_sales.map(g => [ + g.hsn_code, + g.gst_rate, + Number(g.taxable_val).toFixed(2), + Number(g.cgst_val).toFixed(2), + Number(g.sgst_val).toFixed(2), + Number(g.igst_val).toFixed(2), + Number(g.total_val).toFixed(2) + ]); + downloadCsv(`GSTR1_Report_${gstMonth}.csv`, [headers, ...rows]); + notify('GSTR-1 CSV exported successfully.', 'success'); + }; + + // SCHEDULE H1 REGISTER FUNCTIONS + const loadScheduleH1 = async (from = h1FromDate, to = h1ToDate, search = h1Search) => { + setLoadingH1(true); try { - const res = await fetch(API_BASE + `compliance/schedule-h1-register?store_id=${store.store_id}`); + const params = new URLSearchParams({ + store_id: store.store_id, + from_date: from, + to_date: to, + search: search + }); + const res = await fetch(`${API_BASE}compliance/schedule-h1-register?${params.toString()}`); const data = await res.json(); if (data.status) setH1Records(data.records || []); } catch (e) { console.error(e); + } finally { + setLoadingH1(false); } }; + const exportH1Csv = () => { + if (!h1Records.length) { + notify('No Schedule H1 records to export.', 'warning'); + return; + } + const headers = ['Date', 'Bill No', 'Patient Name', 'Patient Address', 'Doctor Name', 'Doctor Reg No', 'Medicine Name', 'Batch No', 'Qty Supplied']; + const rows = h1Records.map(h => [ + h.sale_date, + h.invoice_no, + h.patient_name, + h.patient_address || '', + h.doctor_name, + h.doctor_reg_no || '', + h.item_name, + h.batch_no, + h.qty + ]); + downloadCsv(`Schedule_H1_Register_${h1FromDate}_to_${h1ToDate}.csv`, [headers, ...rows]); + notify('Schedule H1 Register CSV exported.', 'success'); + }; + + const printH1Register = () => { + if (!h1Records.length) { + notify('No Schedule H1 records to print.', 'warning'); + return; + } + const html = ` +
+ Statutory Compliance: Mandatory Register under Drugs and Cosmetics (4th Amendment) Rules, 2013. Records must be retained for at least 3 years and open for inspection by Drug Inspector. +
+

Record Period: ${h1FromDate} to ${h1ToDate}

+ + + + + + + + + + + + + + ${h1Records.map(h => ` + + + + + + + + + + `).join('')} + +
DateBill NoPatient Name & AddressPrescribing Doctor & Reg NoSchedule H1 Drug NameBatch NoQty Dispensed
${h.sale_date.split(' ')[0]}${h.invoice_no}${h.patient_name} ${h.patient_address ? '
' + h.patient_address + '' : ''}
${h.doctor_name} ${h.doctor_reg_no ? '
Reg: ' + h.doctor_reg_no + '' : ''}
${h.item_name}${h.batch_no}${h.qty}
+ `; + openPrintReport('Statutory Schedule H1 Drug Register', html); + }; + + // CREDIT ACCOUNTS FUNCTIONS + const loadCreditAccounts = async () => { + setLoadingCredit(true); + try { + const res = await fetch(`${API_BASE}credit/accounts?store_id=${store.store_id}`); + const data = await res.json(); + if (data.status) setCreditAccounts(data.accounts || []); + } catch (e) { + console.error(e); + notify('Error loading credit accounts: ' + e.message, 'error'); + } finally { + setLoadingCredit(false); + } + }; + + const handleSaveCreditAccount = async (e) => { + e.preventDefault(); + if (!accFormName.trim()) { + notify('Account / Person Name is required', 'error'); + return; + } + setIsSavingAccount(true); + try { + const res = await fetch(`${API_BASE}credit/save-account`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + account_type: accFormType, + account_name: accFormName.trim(), + phone: accFormPhone.trim(), + staff_id: accFormStaffId.trim(), + department: accFormDept.trim(), + credit_limit: Number(accFormLimit) || 0, + remarks: accFormRemarks.trim() + }) + }); + const data = await res.json(); + if (data.status) { + notify('Credit account created successfully.', 'success'); + setShowAddAccountModal(false); + setAccFormName(''); + setAccFormPhone(''); + setAccFormStaffId(''); + setAccFormDept(''); + setAccFormRemarks(''); + loadCreditAccounts(); + } else { + notify(data.message || 'Failed to create credit account', 'error'); + } + } catch (err) { + notify('Error: ' + err.message, 'error'); + } finally { + setIsSavingAccount(false); + } + }; + + const openAccountLedger = async (acc) => { + setSelectedCreditAcc(acc); + setLoadingLedger(true); + try { + const res = await fetch(`${API_BASE}credit/ledger/${acc.account_id}?store_id=${store.store_id}`); + const data = await res.json(); + if (data.status) { + setCreditLedgerData(data); + } + } catch (e) { + notify('Error loading account ledger: ' + e.message, 'error'); + } finally { + setLoadingLedger(false); + } + }; + + const openSettlePaymentModal = (acc) => { + setSelectedCreditAcc(acc); + setPayAmount(acc.current_balance > 0 ? String(acc.current_balance) : ''); + setPayMode('Cash'); + setPayRef(''); + setPayRemarks(''); + setShowPaymentModal(true); + }; + + const handleRecordPayment = async (e) => { + e.preventDefault(); + const amt = Number(payAmount); + if (!amt || amt <= 0) { + notify('Please enter a valid payment amount.', 'error'); + return; + } + setIsRecordingPayment(true); + try { + const res = await fetch(`${API_BASE}credit/record-payment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + account_id: selectedCreditAcc.account_id, + amount: amt, + payment_mode: payMode, + payment_ref: payRef.trim(), + remarks: payRemarks.trim(), + recorded_by_name: 'Accountant' + }) + }); + const data = await res.json(); + if (data.status) { + notify(`Settlement of ₹${amt} recorded successfully. New balance: ₹${data.new_balance}`, 'success'); + setShowPaymentModal(false); + loadCreditAccounts(); + if (selectedCreditAcc) { + openAccountLedger({ ...selectedCreditAcc, current_balance: data.new_balance }); + } + } else { + notify(data.message || 'Failed to record payment', 'error'); + } + } catch (err) { + notify('Error recording payment: ' + err.message, 'error'); + } finally { + setIsRecordingPayment(false); + } + }; + + const printAccountLedger = () => { + if (!creditLedgerData || !selectedCreditAcc) return; + const html = ` +
+
+
+ Account Name: ${selectedCreditAcc.account_name} (${selectedCreditAcc.account_type.toUpperCase()})
+ Phone: ${selectedCreditAcc.phone || 'N/A'}
+ Dept / Staff ID: ${selectedCreditAcc.department || ''} ${selectedCreditAcc.staff_id ? '(' + selectedCreditAcc.staff_id + ')' : ''} +
+
+ Credit Limit: ₹${Number(selectedCreditAcc.credit_limit || 0).toLocaleString('en-IN')}
+ Current Outstanding: ₹${Number(selectedCreditAcc.current_balance || 0).toLocaleString('en-IN')} +
+
+
+ + + + + + + + + + + + + + ${creditLedgerData.entries.map(e => ` + + + + + + + + + + `).join('')} + +
DateTypeInvoice / Ref NoDebit (Bills)Credit (Payments)Balance AfterRecorded By / Remarks
${e.created_at}${e.entry_type === 'DEBIT' ? 'BILL DEBIT' : 'PAYMENT'}${e.invoice_no || e.payment_ref || '-'}${Number(e.debit_amount) > 0 ? '₹' + Number(e.debit_amount).toFixed(2) : '-'}${Number(e.credit_amount) > 0 ? '₹' + Number(e.credit_amount).toFixed(2) : '-'}₹${Number(e.balance_after).toFixed(2)}${e.remarks || ''} (${e.recorded_by_name || 'Staff'})
+ `; + openPrintReport(`Credit Account Statement - ${selectedCreditAcc.account_name}`, html); + }; + + // Filtered credit accounts + const filteredCreditAccounts = creditAccounts.filter(acc => { + if (!creditSearch) return true; + const q = creditSearch.toLowerCase(); + return (acc.account_name && acc.account_name.toLowerCase().includes(q)) || + (acc.phone && acc.phone.includes(q)) || + (acc.staff_id && acc.staff_id.toLowerCase().includes(q)) || + (acc.department && acc.department.toLowerCase().includes(q)); + }); + + const totalOutstandingCredit = creditAccounts.reduce((sum, a) => sum + Number(a.current_balance || 0), 0); + return (
+ {/* Top Navigation Tabs */}
+
- {accTab === 'daybook' && daybookData && ( + {/* TAB 1: DAYBOOK */} + {accTab === 'daybook' && (
-
-
-
- TOTAL TODAY SALES -

₹{daybookData.summary.total_sales.toLocaleString('en-IN')}

- {daybookData.summary.total_invoices} Bills + {/* Daybook Filter Toolbar */} +
+
+
+ Quick Filter: + + + +
-
-
-
- CASH IN DRAWER -

₹{daybookData.summary.cash_in_drawer.toLocaleString('en-IN')}

-
-
-
-
- UPI / QR COLLECTIONS -

₹{daybookData.summary.upi_collection.toLocaleString('en-IN')}

-
-
-
-
- IPD CREDIT BILLED -

₹{daybookData.summary.ipd_credit.toLocaleString('en-IN')}

+ +
+
+ + setDaybookFromDate(e.target.value)} + /> +
+
+ + setDaybookToDate(e.target.value)} + /> +
+ +
+ + +
-
-
-
Today's Transactions Journal
+ {daybookData && ( +
+
+
+
+ TOTAL PERIOD SALES +

₹{Number(daybookData.summary.total_sales || 0).toLocaleString('en-IN')}

+ {daybookData.summary.total_invoices} Bills +
+
+
+
+ CASH IN DRAWER +

₹{Number(daybookData.summary.cash_in_drawer || 0).toLocaleString('en-IN')}

+
+
+
+
+ UPI / QR COLLECTIONS +

₹{Number(daybookData.summary.upi_collection || 0).toLocaleString('en-IN')}

+
+
+
+
+ CREDIT BILLED (IPD + STAFF) +

₹{Number(daybookData.summary.ipd_credit || 0).toLocaleString('en-IN')}

+
+
+
+ +
+
+
Transactions Journal ({daybookFromDate} to {daybookToDate})
+ {daybookData.transactions.length} Records +
+
+ + + + + + + + + + + + + + + {daybookData.transactions.length === 0 ? ( + + + + ) : ( + daybookData.transactions.map((t) => ( + + + + + + + + + + + )) + )} + +
Invoice NoDate & TimePatient (UHID)DoctorModeCGST (₹)SGST (₹)Net Amount
No transactions found for the selected date range.
{t.invoice_no}{t.sale_date}{t.patient_name} {t.uhid ? `(${t.uhid})` : ''}{t.doctor_name}{t.payment_mode}₹{t.cgst_amount}₹{t.sgst_amount}₹{t.net_amount}
+
+
-
- - - - - - - - - - - - - - - {daybookData.transactions.map((t) => ( - - - - - - - - - - - ))} - -
Invoice NoTimePatient (UHID)DoctorModeCGST (₹)SGST (₹)Net Amount
{t.invoice_no}{t.sale_date.split(' ')[1]}{t.patient_name} {t.uhid ? `(${t.uhid})` : ''}{t.doctor_name}{t.payment_mode}₹{t.cgst_amount}₹{t.sgst_amount}₹{t.net_amount}
-
-
+ )}
)} + {/* TAB 2: BANK AUDIT */} {accTab === 'bank_audit' && (
- {/* Summary KPI Cards */}
-
+
TOTAL ONLINE / UPI SALES

₹{Number(bankAuditData?.summary?.total_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}

{bankAuditData?.summary?.total_count || 0} Transactions Recorded
-
+
STATEMENT RECONCILED (VERIFIED)

₹{Number(bankAuditData?.summary?.reconciled_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}

{bankAuditData?.summary?.reconciled_count || 0} Bills Matched with Bank
-
+
PENDING AUDIT VERIFICATION

₹{Number(bankAuditData?.summary?.pending_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}

{bankAuditData?.summary?.pending_count || 0} Bills Unverified @@ -10898,217 +13281,162 @@ function LedgersView({ store, notify }) {
- {/* Filter and Search Toolbar */}
-
+
-
+ +
+ setAuditSearch(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') loadBankAudit(auditFilterStatus, auditSearch); }} + onChange={(e) => { + setAuditSearch(e.target.value); + loadBankAudit(auditFilterStatus, e.target.value); + }} /> - -
+ +
+ + +
- {/* Bank Reconciliation Table */} -
-
-
Bank & UPI Transaction Audit Register
- -
+
- +
+ - - - - - - - + + + + + - {(!bankAuditData?.records || bankAuditData.records.length === 0) ? ( - - - - ) : ( - bankAuditData.records.map((r) => { - const bankAmount = (Number(r.upi_paid) || 0) + (Number(r.card_paid) || 0); - const utrText = r.upi_ref_no || r.card_ref_no || r.payment_reference || 'N/A'; - return ( - - - - - - - ; + } + if (txns.length === 0) { + return ; + } + return txns.map((t) => ( + + + + + + + + - - - - - ); - }) - )} + ) : ( + Pending Statement + )} + + + + )); + })()}
Invoice No Date & TimeBill NoPatient (UHID)Settlement ModePayment BreakdownBank / UPI Reference (UTR)Bank/UPI (₹)Audit StatusPatientModeBill TotalBank / UTR ReferenceAudit Status Action
- No UPI / Bank transactions match this filter. -
{r.sale_date}{r.invoice_no} -
{r.patient_name}
- {r.uhid && {r.uhid}} -
- - {r.payment_mode} - - - {Number(r.cash_paid) > 0 && Cash: ₹{r.cash_paid}} - {Number(r.upi_paid) > 0 && UPI: ₹{r.upi_paid}} - {Number(r.card_paid) > 0 && Card: ₹{r.card_paid}} - + {(() => { + const txns = bankAuditData?.transactions || bankAuditData?.records || []; + if (!bankAuditData) { + return
Loading bank audit records...
No transactions found matching the filter.
{t.invoice_no}{t.sale_date}{t.patient_name}{t.payment_mode}₹{t.net_amount} + {t.upi_ref_no ? ( +
UPI {t.upi_ref_no}
+ ) : t.card_ref_no ? ( +
CARD {t.card_ref_no}
+ ) : ( + Direct QR + )} +
+ {t.is_reconciled == 1 ? (
- {utrText} - {utrText !== 'N/A' && ( - - )} + Reconciled +
{t.reconciled_by} • {t.reconciled_at?.split(' ')[0]}
- {r.bank_name && {r.bank_name}} -
- ₹{bankAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} - - {r.is_bank_reconciled == 1 ? ( -
- Reconciled -
- {r.reconciled_by || 'Accountant'} • {r.reconciled_at ? r.reconciled_at.split(' ')[0] : ''} -
- {r.reconciled_notes && ( -
- "{r.reconciled_notes}" -
- )} -
- ) : ( - - Pending Audit - - )} -
- {r.is_bank_reconciled == 1 ? ( - - ) : ( - - )} -
+ {t.is_reconciled == 1 ? ( + + ) : ( + + )} +
- {/* Audit Verification Modal */} + {/* Reconcile Modal */} {reconcilingSale && ( -
+
-
-
-
- -
Audit & Reconcile Bank Statement
-
+
+
+
+ Reconcile Bank / UPI Statement +
-
-
- Bill No: {reconcilingSale.invoice_no} - Date: {reconcilingSale.sale_date} +
+
+ Invoice No: + {reconcilingSale.invoice_no}
-
- Patient: {reconcilingSale.patient_name} - Total Bill: ₹{reconcilingSale.net_amount} +
+ Amount: + ₹{reconcilingSale.net_amount}
-
- -
- -
- Credit Amount in Bank: - - ₹{((Number(reconcilingSale.upi_paid) || 0) + (Number(reconcilingSale.card_paid) || 0)).toFixed(2)} - -
-
- UPI UTR / Trans Ref: - - {reconcilingSale.upi_ref_no || reconcilingSale.card_ref_no || reconcilingSale.payment_reference || 'Not recorded'} - -
-
- Target Bank / A/c: - {reconcilingSale.bank_name || store.bank_name || 'Hospital Current A/c'} +
+ Recorded Ref / UTR: + {reconcilingSale.upi_ref_no || reconcilingSale.card_ref_no || 'Direct Store QR'}
@@ -11129,7 +13457,7 @@ function LedgersView({ store, notify }) { rows="2" value={auditNotes} onChange={(e) => setAuditNotes(e.target.value)} - placeholder="e.g. Matched in SBI A/c statement entry on 08-Sep-2026, Credit Ref verified" + placeholder="e.g. Matched in Bank A/c statement entry on 08-Sep-2026, Credit Ref verified" >
@@ -11143,11 +13471,7 @@ function LedgersView({ store, notify }) { onClick={() => handleReconcileSubmit(reconcilingSale.sale_id, 1)} disabled={isSubmittingAudit} > - {isSubmittingAudit ? ( - Saving... - ) : ( - Mark Verified with Statement - )} + {isSubmittingAudit ? 'Saving...' : 'Mark Verified with Statement'}
@@ -11157,85 +13481,1212 @@ function LedgersView({ store, notify }) {
)} - {accTab === 'gst' && gstData && ( -
-
GSTR-1 Outward Sales Summary (HSN-wise)
-
- - - - - - - - - - - - - - {gstData.gstr1_sales.map((g, i) => ( - - - - - - - - + {/* TAB 3: GST RETURNS */} + {accTab === 'gst' && ( +
+
+
+
+ + { + setGstMonth(e.target.value); + loadGst(e.target.value); + }} + /> + +
+
+ +
+
+
+ + {gstData && ( +
+
+
GSTR-1 Outward Sales Summary (HSN-wise) for {gstMonth}
+ GSTIN: {store.gst_no || 'N/A'} +
+
+
HSN CodeGST SlabTaxable Value (₹)CGST (₹)SGST (₹)IGST (₹)Total Invoice Value (₹)
{g.hsn_code}{g.gst_rate}%₹{Number(g.taxable_val).toFixed(2)}₹{Number(g.cgst_val).toFixed(2)}₹{Number(g.sgst_val).toFixed(2)}₹{Number(g.igst_val).toFixed(2)}₹{Number(g.total_val).toFixed(2)}
+ + + + + + + + + + + + + {gstData.gstr1_sales.length === 0 ? ( + + + + ) : ( + gstData.gstr1_sales.map((g, i) => ( + + + + + + + + + + )) + )} + +
HSN CodeGST SlabTaxable Value (₹)CGST (₹)SGST (₹)IGST (₹)Total Invoice Value (₹)
No sales recorded for GST filing in month {gstMonth}.
{g.hsn_code}{g.gst_rate}%₹{Number(g.taxable_val).toFixed(2)}₹{Number(g.cgst_val).toFixed(2)}₹{Number(g.sgst_val).toFixed(2)}₹{Number(g.igst_val).toFixed(2)}₹{Number(g.total_val).toFixed(2)}
+
+
+ )} +
+ )} + + {/* TAB 4: SCHEDULE H1 DRUG REGISTER */} + {accTab === 'schedule_h1' && ( +
+
+
+
+ Date Filter: +
+ setH1FromDate(e.target.value)} + /> + to + setH1ToDate(e.target.value)} + /> +
+ +
+ +
+ { + setH1Search(e.target.value); + loadScheduleH1(h1FromDate, h1ToDate, e.target.value); + }} + /> + + +
+
+
+ +
+
+
+
Statutory Schedule H1 Drug Register
+ Mandatory under Indian Drugs & Cosmetics Rules (4th Amendment 2013). Retain for 3 years. +
+ {h1Records.length} Dispensed Entries +
+ +
+ + + + + + + + + + - ))} - -
DateBill NoPatient Name & AddressPrescribing Doctor & Reg NoMedicine NameBatch NoQty Supplied
+ + + {h1Records.length === 0 ? ( + + No Schedule H1 medicines dispensed in this period. + + ) : ( + h1Records.map((h, i) => ( + + {h.sale_date?.split(' ')[0]} + {h.invoice_no} + {h.patient_name} {h.patient_address ? `(${h.patient_address})` : ''} + {h.doctor_name} {h.doctor_reg_no ? `(Reg: ${h.doctor_reg_no})` : ''} + {h.item_name} + {h.batch_no} + {h.qty} + + )) + )} + + +
)} - {accTab === 'schedule_h1' && ( -
-
-
-
Statutory Schedule H1 Drug Register
- Mandatory under Indian Drugs & Cosmetics Rules (4th Amendment 2013). Retain records for 3 years. + {/* TAB 5: STAFF & CUSTOMER CREDIT ACCOUNTS */} + {accTab === 'credit_accounts' && ( +
+ {/* KPI Cards */} +
+
+
+ TOTAL REGISTERED ACCOUNTS +

{creditAccounts.length} Accounts

+ Staff, Doctors, Institutional & Patients +
+
+
+
+ TOTAL OUTSTANDING DUES +

₹{totalOutstandingCredit.toLocaleString('en-IN', { minimumFractionDigits: 2 })}

+ Unsettled Pharmacy Debits +
+
+
+
+ TOTAL CREDIT LIMIT SANCTIONED +

+ ₹{creditAccounts.reduce((sum, a) => sum + Number(a.credit_limit || 0), 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} +

+ Authorized Spending Cap +
-
-
- - - - - - - - - - - - - - {h1Records.length === 0 ? ( + {/* Toolbar */} +
+
+
+
+ + setCreditSearch(e.target.value)} + /> +
+
+
+ +
+
+
+ + {/* Accounts Table */} +
+
+
DateBill NoPatient Name & AddressPrescribing Doctor & Reg NoMedicine NameBatch NoQty Supplied
+ - + + + + + + + + - ) : ( - h1Records.map((h, i) => ( - - - - - - - - - - )) - )} - -
No Schedule H1 medicines dispensed in this period.Account / Person NameAccount TypeDept / Staff IDPhoneCredit LimitCurrent BalanceStatusActions
{h.sale_date.split(' ')[0]}{h.invoice_no}{h.patient_name} {h.patient_address ? `(${h.patient_address})` : ''}{h.doctor_name} {h.doctor_reg_no ? `(Reg: ${h.doctor_reg_no})` : ''}{h.item_name}{h.batch_no}{h.qty}
+ + + {loadingCredit ? ( + Loading credit accounts... + ) : filteredCreditAccounts.length === 0 ? ( + No credit accounts found. Click "New Credit Account" to create one. + ) : ( + filteredCreditAccounts.map(acc => { + const typeColor = + acc.account_type === 'staff' ? 'primary' : + acc.account_type === 'doctor' ? 'success' : + acc.account_type === 'corporate' ? 'dark' : 'warning'; + const bal = Number(acc.current_balance || 0); + + return ( + + + {acc.account_name} + {acc.remarks && {acc.remarks}} + + + + {acc.account_type} + + + {acc.department || acc.staff_id || '-'} + {acc.phone || '-'} + ₹{Number(acc.credit_limit || 0).toLocaleString('en-IN')} + + 0 ? 'text-danger' : 'text-success'}`}> + ₹{bal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} + + + + + {acc.is_active == 1 ? 'ACTIVE' : 'INACTIVE'} + + + +
+ + +
+ + + ); + }) + )} + + +
+
+ + {/* Modal: New Credit Account */} + {showAddAccountModal && ( +
+
+
+
+
+
+ Create Credit Account +
+ +
+
+
+
+ + +
+
+ + setAccFormLimit(e.target.value)} + placeholder="5000" + /> +
+
+ +
+ + setAccFormName(e.target.value)} + placeholder="e.g. Dr. Rajesh Sharma / Sister Sunita" + /> +
+ +
+
+ + setAccFormPhone(e.target.value)} + placeholder="e.g. 9876543210" + /> +
+
+ + setAccFormStaffId(e.target.value)} + placeholder="e.g. EMP-1042" + /> +
+
+ +
+ + setAccFormDept(e.target.value)} + placeholder="e.g. ICU Nursing / General Surgery / Administration" + /> +
+ +
+ + +
+
+
+ + +
+
+
+
+
+ )} + + {/* Modal: View Account Ledger */} + {selectedCreditAcc && creditLedgerData && ( +
+
+
+
+
+ +
+
{selectedCreditAcc.account_name} — Credit Statement
+ {selectedCreditAcc.account_type.toUpperCase()} • {selectedCreditAcc.department || 'General'} +
+
+ +
+ +
+ {/* Statement Summary Card */} +
+
+
+ CREDIT LIMIT +
₹{Number(selectedCreditAcc.credit_limit || 0).toLocaleString('en-IN')}
+
+
+ OUTSTANDING BALANCE +

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

+
+
+ AVAILABLE CREDIT +
+ ₹{Math.max(0, Number(selectedCreditAcc.credit_limit || 0) - Number(selectedCreditAcc.current_balance || 0)).toLocaleString('en-IN')} +
+
+
+ + +
+
+
+ + {/* Ledger Entries Table */} +
+ + + + + + + + + + + + + + + {creditLedgerData.entries.length === 0 ? ( + + ) : ( + creditLedgerData.entries.map(ent => ( + + + + + + + + + + + )) + )} + +
TimestampTypeInvoice / Receipt RefDebit (Bills)Credit (Payments)Running BalanceModeRecorded By / Notes
No transactions on this account yet.
{ent.created_at} + + {ent.entry_type} + + {ent.invoice_no || ent.payment_ref || '-'} + {Number(ent.debit_amount) > 0 ? '₹' + Number(ent.debit_amount).toFixed(2) : '-'} + + {Number(ent.credit_amount) > 0 ? '₹' + Number(ent.credit_amount).toFixed(2) : '-'} + + ₹{Number(ent.balance_after).toFixed(2)} + {ent.payment_mode} + {ent.remarks || 'Bill Charge'} ({ent.recorded_by_name || 'System'}) +
+
+
+
+ +
+
+
+
+ )} + + {/* Modal: Record Payment Settlement */} + {showPaymentModal && selectedCreditAcc && ( +
+
+
+
+
+
+ Record Credit Settlement Payment +
+ +
+
+
+
+ Account: + {selectedCreditAcc.account_name} +
+
+ Total Outstanding Due: + ₹{Number(selectedCreditAcc.current_balance || 0).toFixed(2)} +
+
+ +
+ +
+ ₹ + setPayAmount(e.target.value)} + placeholder="Enter amount" + /> + +
+
+ +
+
+ + +
+
+ + setPayRef(e.target.value)} + placeholder="e.g. UTR-991823" + /> +
+
+ +
+ + +
+
+
+ + +
+
+
+
+
+ )} +
+ )} +
+ ); +} + +function StaffManagementView({ store, notify, activeOperator, onOperatorUpdated, onSelectOperator }) { + if (!store) return null; + const [staffList, setStaffList] = useState([]); + const [loading, setLoading] = useState(false); + + // Modal state for Add/Edit Staff + const [showEditModal, setShowEditModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [formFullName, setFormFullName] = useState(''); + const [formUsername, setFormUsername] = useState(''); + const [formPinCode, setFormPinCode] = useState(''); + const [formRole, setFormRole] = useState('pharmacist'); + const [formPhone, setFormPhone] = useState(''); + const [formRegNo, setFormRegNo] = useState(''); + const [formHprId, setFormHprId] = useState(''); + const [formIsDefault, setFormIsDefault] = useState(false); + const [formIsActive, setFormIsActive] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // 6 Granular Permissions Matrix + const defaultPermissions = { + admin: false, + sale: true, + purchase: false, + sale_edit_after_1h: false, + purchase_edit_verified: false, + bank_upi_audit: false + }; + const [formPermissions, setFormPermissions] = useState(defaultPermissions); + + const applyRoleDefaults = (role) => { + setFormRole(role); + if (role === 'store_manager') { + setFormPermissions({ + admin: true, + sale: true, + purchase: true, + sale_edit_after_1h: true, + purchase_edit_verified: true, + bank_upi_audit: true + }); + } else if (role === 'pharmacist') { + setFormPermissions({ + admin: false, + sale: true, + purchase: true, + sale_edit_after_1h: false, + purchase_edit_verified: false, + bank_upi_audit: false + }); + } else if (role === 'cashier') { + setFormPermissions({ + admin: false, + sale: true, + purchase: false, + sale_edit_after_1h: false, + purchase_edit_verified: false, + bank_upi_audit: false + }); + } else if (role === 'auditor') { + setFormPermissions({ + admin: false, + sale: false, + purchase: false, + sale_edit_after_1h: false, + purchase_edit_verified: false, + bank_upi_audit: true + }); + } + }; + + useEffect(() => { + if (store?.store_id) { + loadStaff(); + } + }, [store?.store_id]); + + const loadStaff = async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}users?store_id=${store.store_id}`); + const data = await res.json(); + if (data.status) { + setStaffList(data.users || []); + } + } catch (e) { + console.error("Failed to load staff:", e); + } finally { + setLoading(false); + } + }; + + const openAddModal = () => { + setEditingUser(null); + setFormFullName(''); + setFormUsername(''); + setFormPinCode('1234'); + applyRoleDefaults('pharmacist'); + setFormPhone(''); + setFormRegNo(''); + setFormHprId(''); + setFormIsDefault(false); + setFormIsActive(true); + setShowEditModal(true); + }; + + const openEditModal = (user) => { + setEditingUser(user); + setFormFullName(user.full_name || ''); + setFormUsername(user.username || ''); + setFormPinCode(''); // Keep empty unless resetting + setFormRole(user.user_role || 'pharmacist'); + setFormPhone(user.phone || ''); + setFormRegNo(user.pharmacist_reg_no || ''); + setFormHprId(user.pharmacist_hpr_id || ''); + setFormIsDefault(user.is_default == 1); + setFormIsActive(user.is_active == 1); + + // Parse permissions + let perms = { ...defaultPermissions }; + if (user.permissions) { + try { + const parsed = typeof user.permissions === 'string' ? JSON.parse(user.permissions) : user.permissions; + perms = { ...defaultPermissions, ...parsed }; + } catch (e) { + console.warn('Error parsing user permissions:', e); + } + } + setFormPermissions(perms); + setShowEditModal(true); + }; + + const togglePermission = (key) => { + setFormPermissions(prev => ({ + ...prev, + [key]: !prev[key] + })); + }; + + const handleSaveStaff = async (e) => { + e.preventDefault(); + if (!formFullName.trim() || !formUsername.trim()) { + notify('Full Name and Username are required.', 'error'); + return; + } + + setIsSaving(true); + try { + const payload = { + user_id: editingUser ? editingUser.user_id : 0, + store_id: store.store_id, + full_name: formFullName.trim(), + username: formUsername.trim(), + user_role: formRole, + phone: formPhone.trim(), + pharmacist_reg_no: formRegNo.trim(), + pharmacist_hpr_id: formHprId.trim(), + is_default: formIsDefault ? 1 : 0, + is_active: formIsActive ? 1 : 0, + permissions: JSON.stringify(formPermissions) + }; + if (formPinCode.trim()) { + payload.pin_code = formPinCode.trim(); + } + + const res = await fetch(`${API_BASE}users/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (data.status) { + notify('Staff profile and permission matrix saved successfully.', 'success'); + setShowEditModal(false); + loadStaff(); + if (onOperatorUpdated) onOperatorUpdated(); + } else { + notify(data.message || 'Failed to save staff profile.', 'error'); + } + } catch (err) { + notify('Network error saving staff profile.', 'error'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Header & Add Button */} +
+
+
+ Medical Store Staff & 6-Tier Permission Matrix +
+

+ Manage pharmacy staff credentials, cashier PINs, and granular audit/sale/purchase rights for {store.store_name} +

+
+
+ +
+
+ + {/* Staff Cards Grid */} +
+ {loading ? ( +
+ Loading staff profiles... +
+ ) : staffList.length === 0 ? ( +
+ + No staff members assigned to this store yet. Click "Add Staff Member" to get started. +
+ ) : ( + staffList.map((user) => { + const isCurrent = activeOperator?.user_id == user.user_id; + const roleColor = + user.user_role === 'store_manager' ? 'primary' : + user.user_role === 'pharmacist' ? 'success' : + user.user_role === 'auditor' ? 'info' : + user.user_role === 'cashier' ? 'warning' : 'secondary'; + + let perms = {}; + try { + perms = typeof user.permissions === 'string' ? JSON.parse(user.permissions || '{}') : (user.permissions || {}); + } catch (e) { + perms = {}; + } + + return ( +
+
+
+
+
+ {user.full_name ? user.full_name[0] : 'U'} +
+
+
{user.full_name}
+ @{user.username} +
+
+ + {user.user_role ? user.user_role.replace(/_/g, ' ') : 'STAFF'} + +
+ +
+
{user.phone || 'No phone set'}
+ {user.pharmacist_reg_no ? ( +
+ Reg: {user.pharmacist_reg_no} +
+ ) : null} +
+ + {/* Granular Permission Tags */} +
+
Assigned Rights
+
+ + Admin + + + Sale + + + Purchase + + + Sale Edit >1h + + + Purch Edit Verif + + + Bank Audit + +
+
+ +
+
+ {isCurrent ? ( + + Active Cashier + + ) : ( + + )} +
+ +
+
+
+ ); + }) + )} +
+ + {/* Add / Edit Staff Modal */} + {showEditModal && ( +
+
+
+
+
+
+ + {editingUser ? 'Edit Staff Profile & Permissions' : 'Register New Staff Member'} +
+ +
+
+
+
+ + setFormFullName(e.target.value)} + placeholder="e.g. Rahul Sharma" + /> +
+
+ + setFormUsername(e.target.value)} + placeholder="e.g. rahul_pharma" + /> +
+
+ +
+
+ + +
+
+ + setFormPinCode(e.target.value)} + placeholder={editingUser ? 'Leave blank to keep current PIN' : 'e.g. 1234'} + /> +
+
+ + setFormPhone(e.target.value)} + placeholder="e.g. 9876543210" + /> +
+
+ +
+
+ + setFormRegNo(e.target.value)} + placeholder="e.g. PB-PH-99214" + /> +
+
+ + setFormHprId(e.target.value)} + placeholder="e.g. rahul@hpr.abdm" + /> +
+
+ + {/* 6-Tier Permission Matrix Checklist */} +
+
+ Granular Access & Audit Permissions + Audit Compliant +
+
+
+
+ togglePermission('admin')} + /> + +
Store config & user management
+
+
+
+
+ togglePermission('sale')} + /> + +
Counter POS and draft billing
+
+
+
+
+ togglePermission('purchase')} + /> + +
Challans & stock additions
+
+
+
+
+ togglePermission('sale_edit_after_1h')} + /> + +
Modify bills older than 60 mins
+
+
+
+
+ togglePermission('purchase_edit_verified')} + /> + +
Edit purchase after verification
+
+
+
+
+ togglePermission('bank_upi_audit')} + /> + +
Verify statements & reconciliations
+
+
+
+
+ +
+
+ setFormIsDefault(e.target.checked)} + /> + +
+
+ setFormIsActive(e.target.checked)} + /> + +
+
+
+
+ + +
+
+
)} @@ -11245,6 +14696,1045 @@ function LedgersView({ store, notify }) { // Note: Store & Statutory Licensing Setup is managed centrally in HMS -> Admin -> Medical Store. +// ========================================================================= +// 8. STAFF & OPERATOR ROLE MANAGEMENT (END) +// ========================================================================= + +function MobileAppView({ store, stores, notify, activeOperator, onSwitchOperator, onExitMobile, triggerPwaInstall }) { + if (!store) return null; + const [mobTab, setMobTab] = useState('dashboard'); // 'dashboard', 'collector', 'auditor', 'purchase_photo' + + // Dashboard Data + const [dashData, setDashData] = useState(null); + const [loadingDash, setLoadingDash] = useState(false); + + // Rack Collector (Draft Bill) State + const [collectorItems, setCollectorItems] = useState([]); + const [itemSearchQuery, setItemSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [selectedBatch, setSelectedBatch] = useState(null); + const [collectQty, setCollectQty] = useState(1); + const [collectUnit, setCollectUnit] = useState('Tablet'); + const [patientName, setPatientName] = useState(''); + const [patientMobile, setPatientMobile] = useState(''); + const [bedNo, setBedNo] = useState(''); + const [wardName, setWardName] = useState(''); + const [doctorName, setDoctorName] = useState(''); + const [isSavingDraft, setIsSavingDraft] = useState(false); + + // Stock Auditor State + const [auditSearch, setAuditSearch] = useState(''); + const [auditResults, setAuditResults] = useState([]); + const [auditModalItem, setAuditModalItem] = useState(null); + const [auditCount, setAuditCount] = useState(''); + const [auditReason, setAuditReason] = useState('PHYSICAL_VARIATION'); + const [auditRemarks, setAuditRemarks] = useState(''); + const [isSubmittingAudit, setIsSubmittingAudit] = useState(false); + + // Purchase Invoice Camera State + const [recentPurchases, setRecentPurchases] = useState([]); + const [selectedPurchaseId, setSelectedPurchaseId] = useState(''); + const [capturedImageBase64, setCapturedImageBase64] = useState(null); + const [isUploadingPhoto, setIsUploadingPhoto] = useState(false); + const [previewPhotoUrl, setPreviewPhotoUrl] = useState(null); + + useEffect(() => { + if (store?.store_id) { + loadDashboard(); + loadRecentPurchases(); + } + }, [store?.store_id]); + + const loadDashboard = async () => { + setLoadingDash(true); + try { + const res = await fetch(API_BASE + `mobile/dashboard?store_id=${store.store_id}`); + const data = await res.json(); + if (data.status) setDashData(data); + } catch(e) { + console.error("Failed to load mobile dashboard:", e); + } finally { + setLoadingDash(false); + } + }; + + const loadRecentPurchases = async () => { + try { + const res = await fetch(API_BASE + `purchases?store_id=${store.store_id}&limit=15`); + const data = await res.json(); + if (data.status) { + setRecentPurchases(data.purchases || []); + if (data.purchases && data.purchases.length > 0) { + setSelectedPurchaseId(data.purchases[0].purchase_id); + } + } + } catch(e) { + console.error("Failed to load purchases:", e); + } + }; + + // Medicine Search for Collector + const handleSearchItems = async (q) => { + setItemSearchQuery(q); + if (q.trim().length < 2) { + setSearchResults([]); + return; + } + try { + const res = await fetch(API_BASE + `items/search?store_id=${store.store_id}&q=${encodeURIComponent(q.trim())}`); + const data = await res.json(); + if (data.status) { + setSearchResults(data.items || []); + } + } catch(e) { + console.error(e); + } + }; + + const addItemToDraft = () => { + if (!selectedItem || !selectedBatch) { + notify('Please select medicine item and batch.', 'error'); + return; + } + const q = Number(collectQty); + if (q <= 0) { + notify('Quantity must be greater than 0.', 'error'); + return; + } + + const upp = Number(selectedItem.units_per_pack || selectedBatch.units_per_pack || 1); + const totalUnits = collectUnit === 'Strip' ? (q * upp) : q; + const rate = Number(selectedBatch.mrp || 0) / (collectUnit === 'Strip' ? 1 : upp); + + const newItem = { + item_id: selectedItem.item_id, + item_name: selectedItem.item_name, + generic_name: selectedItem.generic_name || '', + batch_id: selectedBatch.batch_id, + batch_no: selectedBatch.batch_no, + expiry_date: selectedBatch.expiry_date, + qty: q, + sell_unit: collectUnit, + units_per_pack: upp, + total_units: totalUnits, + effective_rate: rate, + line_gross: q * Number(selectedBatch.mrp || 0) + }; + + setCollectorItems(prev => [...prev, newItem]); + setSelectedItem(null); + setSelectedBatch(null); + setItemSearchQuery(''); + setSearchResults([]); + setCollectQty(1); + notify(`Added ${newItem.item_name} (${newItem.qty} ${collectUnit}) to draft basket`, 'success'); + }; + + const handleSaveDraft = async () => { + if (collectorItems.length === 0) { + notify('Draft basket is empty! Add items from shelves.', 'error'); + return; + } + + setIsSavingDraft(true); + try { + const res = await fetch(API_BASE + 'drafts/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + patient_name: patientName.trim() || 'Walk-in Customer', + patient_mobile: patientMobile.trim(), + bed_no: bedNo.trim(), + ward_name: wardName.trim(), + doctor_name: doctorName.trim(), + collected_by_name: activeOperator ? activeOperator.full_name : 'Mobile Pharmacist', + items: collectorItems + }) + }); + const data = await res.json(); + if (data.status) { + notify(`✓ Draft Bill #${data.draft_code} saved! Cashier on counter PC can now bill it.`, 'success'); + setCollectorItems([]); + setPatientName(''); + setPatientMobile(''); + setBedNo(''); + setWardName(''); + setDoctorName(''); + } else { + notify(data.message || 'Failed to save draft bill.', 'error'); + } + } catch(e) { + notify('Network error saving draft bill: ' + e.message, 'error'); + } finally { + setIsSavingDraft(false); + } + }; + + // Auditor Search + const handleSearchAudit = async (q) => { + setAuditSearch(q); + if (q.trim().length < 2) { + setAuditResults([]); + return; + } + try { + const res = await fetch(API_BASE + `stock/list?store_id=${store.store_id}&limit=20&filter=all`); + const data = await res.json(); + if (data.status && data.stock) { + const filtered = data.stock.filter(s => + s.item_name.toLowerCase().includes(q.toLowerCase()) || + (s.generic_name && s.generic_name.toLowerCase().includes(q.toLowerCase())) || + s.batch_no.toLowerCase().includes(q.toLowerCase()) || + (s.shelf_no && s.shelf_no.toLowerCase().includes(q.toLowerCase())) + ); + setAuditResults(filtered); + } + } catch(e) { + console.error(e); + } + }; + + const handleMobileReconcile = async () => { + if (!auditModalItem) return; + const pQty = Number(auditCount); + if (isNaN(pQty) || pQty < 0) { + notify('Please enter valid physical count (>=0).', 'error'); + return; + } + + setIsSubmittingAudit(true); + try { + const res = await fetch(API_BASE + 'stock/reconcile', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + item_id: auditModalItem.item_id, + batch_id: auditModalItem.batch_id, + physical_qty: pQty, + audit_type: auditReason, + remarks: auditRemarks.trim() || `Mobile Shelf Audit by ${activeOperator?.full_name || 'Staff'}`, + operator_id: activeOperator?.user_id || 1, + operator_name: activeOperator?.full_name || 'Mobile Auditor' + }) + }); + const data = await res.json(); + if (data.status) { + notify(`Reconciled ${auditModalItem.item_name}! System ${auditModalItem.current_qty} -> Shelf ${pQty}`, 'success'); + setAuditModalItem(null); + handleSearchAudit(auditSearch); + } else { + notify(data.message || 'Failed to reconcile.', 'error'); + } + } catch(e) { + notify('Network error.', 'error'); + } finally { + setIsSubmittingAudit(false); + } + }; + + // Purchase Invoice Photo Capture + const handleCaptureFile = (e) => { + const file = e.target.files && e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (evt) => { + setCapturedImageBase64(evt.target.result); + }; + reader.readAsDataURL(file); + } + }; + + const handleUploadPhoto = async () => { + if (!selectedPurchaseId || !capturedImageBase64) { + notify('Please select an inward purchase and take/select a photo.', 'error'); + return; + } + + setIsUploadingPhoto(true); + try { + const res = await fetch(API_BASE + 'purchase/upload-photo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + purchase_id: Number(selectedPurchaseId), + image_data: capturedImageBase64 + }) + }); + const data = await res.json(); + if (data.status) { + notify('Purchase paper invoice photo attached successfully!', 'success'); + setCapturedImageBase64(null); + loadRecentPurchases(); + } else { + notify(data.message || 'Failed to attach photo.', 'error'); + } + } catch(e) { + notify('Network error uploading photo: ' + e.message, 'error'); + } finally { + setIsUploadingPhoto(false); + } + }; + + return ( +
+ {/* Top Sticky App Header */} +
+
+
+ +
+
+
{store.store_name}
+ + {activeOperator ? activeOperator.full_name : 'Staff'} + +
+
+ +
+ + + +
+
+ + {/* Main Content Area */} +
+ + {/* ========================================================================= */} + {/* 1. ADMIN DASHBOARD & HEALTH METRICS */} + {/* ========================================================================= */} + {mobTab === 'dashboard' && ( +
+
+
+ Pharmacy Health Dashboard +
+ +
+ + {loadingDash || !dashData ? ( +
+
+
Loading store telemetry...
+
+ ) : ( +
+ {/* KPI Tiles */} +
+
+
+ TOTAL BATCHES + {dashData.summary?.total_batches || 0} + ({dashData.summary?.total_units || 0} Units) +
+
+
+
+ STOCK VALUATION (PTR) + ₹{Number(dashData.summary?.cost_valuation || 0).toLocaleString('en-IN')} +
+
+
+
+ NEAR EXPIRY (<90d) + {dashData.summary?.near_expiry_count || 0} Batches +
+
+
+
+ SUPPLIER PENDING + ₹{Number(dashData.summary?.total_supplier_pending || 0).toLocaleString('en-IN')} +
+
+
+ + {/* Section 1: Nearest Expiry Alert List */} +
+
+ + Nearest Expiry Batches + + Priority Action +
+
+ {dashData.near_expiry?.length === 0 ? ( +
No batches expiring in next 90 days.
+ ) : ( + dashData.near_expiry.map((item, i) => ( +
+
+
{item.item_name}
+
+ Batch: {item.batch_no} {item.shelf_no ? `• Sh: ${item.shelf_no}` : ''} +
+
+
+ + Exp: {item.expiry_date} + +
{item.current_qty} Units
+
+
+ )) + )} +
+
+ + {/* Section 2: High Sale Products (Top Movers) */} +
+
+ + Fast Moving Medicines + + Top Sales +
+
+ {dashData.high_sales?.length === 0 ? ( +
No sale records yet.
+ ) : ( + dashData.high_sales.map((item, i) => ( +
+
+
#{i+1} {item.item_name}
+
{item.generic_name || 'Allopathic'}
+
+
+ {item.total_sold} Units Sold +
₹{item.total_revenue}
+
+
+ )) + )} +
+
+ + {/* Section 3: Dump Stock (Dead Stock) */} +
+
+ + Dead / Dump Stock (>60d No Movement) + + Capital Blocked +
+
+ {dashData.dump_stock?.length === 0 ? ( +
No stagnant stock detected.
+ ) : ( + dashData.dump_stock.map((item, i) => ( +
+
+
{item.item_name}
+
Batch: {item.batch_no} • Exp: {item.expiry_date}
+
+
+ {item.current_qty} Units Stagnant +
MRP: ₹{item.mrp}
+
+
+ )) + )} +
+
+ + {/* Section 4: Supplier Pending Dues */} +
+
+ + Supplier Payments Pending + + Accounts Payable +
+
+ {dashData.supplier_pending?.length === 0 ? ( +
All supplier bills are settled!
+ ) : ( + dashData.supplier_pending.map((item, i) => ( +
+
+
{item.supplier_name || 'Distributor'}
+
Inv: {item.supplier_invoice_no} • Due: {item.due_date}
+
+
+ ₹{Number(item.pending_amount).toFixed(2)} + Paid: ₹{item.paid_amount} +
+
+ )) + )} +
+
+
+ )} +
+ )} + + {/* ========================================================================= */} + {/* 2. RACK COLLECTOR (MOBILE DRAFT BILL CREATOR) */} + {/* ========================================================================= */} + {mobTab === 'collector' && ( +
+
+
+ Shelf & Rack Collector +
+ + {collectorItems.length} Items in Basket + +
+

+ Collect medicines from shelves, add to mobile basket, and tap "Save Draft" so the cashier on counter PC can bill it immediately. +

+ + {/* Search Medicine */} +
+ +
+ + handleSearchItems(e.target.value)} + /> +
+ + {/* Search Dropdown */} + {searchResults.length > 0 && ( +
+ {searchResults.map(item => ( +
{ + setSelectedItem(item); + setSelectedBatch(item.batches && item.batches[0] ? item.batches[0] : null); + setSearchResults([]); + }} + style={{ cursor: 'pointer' }} + > +
{item.item_name}
+ + {item.generic_name} • Stock: {item.total_stock} Units + +
+ ))} +
+ )} +
+ + {/* Selected Item Detail & Batch Selector */} + {selectedItem && ( +
+
{selectedItem.item_name}
+ {selectedItem.generic_name} + +
+
+ + +
+
+ + +
+
+ +
+ + setCollectQty(Math.max(1, Number(e.target.value)))} + /> + +
+
+
+ +
+ + +
+
+ )} + + {/* Current Basket Items List */} +
+
+ Draft Basket Items ({collectorItems.length}) + {collectorItems.length > 0 && ( + + )} +
+
+ {collectorItems.length === 0 ? ( +
+ + No medicines collected yet. Search and pick items above. +
+ ) : ( + collectorItems.map((it, idx) => ( +
+
+
{it.item_name}
+
+ Batch: {it.batch_no} • Qty: {it.qty} {it.sell_unit} ({it.total_units} total units) +
+
+ +
+ )) + )} +
+
+ + {/* Patient & Bed Reference Info */} + {collectorItems.length > 0 && ( +
+ +
+
+ setPatientName(e.target.value)} + /> +
+
+ setPatientMobile(e.target.value)} + /> +
+
+ setBedNo(e.target.value)} + /> +
+
+ setWardName(e.target.value)} + /> +
+
+ + +
+ )} +
+ )} + + {/* ========================================================================= */} + {/* 3. MOBILE STOCK AUDITOR */} + {/* ========================================================================= */} + {mobTab === 'auditor' && ( +
+
+
+ Mobile Shelf Auditor +
+ Routine Count +
+

+ During low workload hours, search medicine or rack, check shelf count against system records, and reconcile stock with 1 tap. +

+ +
+ + handleSearchAudit(e.target.value)} + /> +
+ +
+ {auditResults.length === 0 ? ( +
+ Type medicine or batch to verify shelf stock. +
+ ) : ( + auditResults.map((item, idx) => ( +
+
+
+
{item.item_name}
+
+ Batch: {item.batch_no} • Exp: {item.expiry_display || item.expiry_date} +
+
+ + {item.shelf_no ? `Shelf: ${item.shelf_no}` : 'Normal'} + +
+ +
+
+ SYSTEM STOCK + {item.current_qty} Units +
+ +
+
+ )) + )} +
+ + {/* Single Item Reconcile Modal in Mobile */} + {auditModalItem && ( +
+
+
+
+
Reconcile: {auditModalItem.item_name}
+ +
+
+
+
+
+ System Qty + {auditModalItem.current_qty} +
+
+
+
+ Physical Count + setAuditCount(e.target.value)} + autoFocus + /> +
+
+
+ + {(() => { + const sys = Number(auditModalItem.current_qty); + const phy = Number(auditCount !== '' ? auditCount : sys); + const diff = phy - sys; + const rate = Number(auditModalItem.ptr || auditModalItem.mrp || 0); + const val = Math.abs(diff) * rate; + + if (diff === 0) { + return
✓ Counts match (0 diff).
; + } else if (diff < 0) { + return ( +
+ Shortage: {Math.abs(diff)} units (Loss: ₹{val.toFixed(2)}) +
+ ); + } else { + return ( +
+ Surplus: +{diff} units (Valuation: ₹{val.toFixed(2)}) +
+ ); + } + })()} + +
+ + +
+ +
+ + setAuditRemarks(e.target.value)} + /> +
+
+
+ + +
+
+
+
+ )} +
+ )} + + {/* ========================================================================= */} + {/* 4. PURCHASE INWARD PAPER INVOICE CAMERA ATTACHMENT */} + {/* ========================================================================= */} + {mobTab === 'purchase_photo' && ( +
+
+
+ Inward Bill Camera Attachment +
+ Audit Proof +
+

+ Take a direct photo of the distributor's delivery challan or paper tax invoice and attach it to the inward record. +

+ +
+ + + + +
+ +
+ + {/* Captured Photo Preview */} + {capturedImageBase64 && ( +
+
Preview of Captured Bill:
+ Captured Invoice +
+ )} + + +
+ + {/* View Attached Invoices */} +
+
+ Recent Purchases with Photo Status +
+
+ {recentPurchases.slice(0, 5).map(p => ( +
+
+
{p.supplier_invoice_no}
+
{p.supplier_name || 'Distributor'} • ₹{p.net_amount}
+
+
+ {p.invoice_photo ? ( + + View Photo + + ) : ( + No Photo + )} +
+
+ ))} +
+
+
+ )} +
+ + {/* Bottom Fixed Navigation Bar */} +
+ + + + + + + +
+
+ ); +} + const root = ReactDOM.createRoot(document.getElementById('root')); root.render(