diff --git a/app/Config/AuthGroups.php b/app/Config/AuthGroups.php
index e12c8b3..0022c97 100644
--- a/app/Config/AuthGroups.php
+++ b/app/Config/AuthGroups.php
@@ -53,6 +53,18 @@ class AuthGroups extends ShieldAuthGroups
'title' => 'Developer',
'description' => 'Site programmers.',
],
+ 'stock_manager' => [
+ 'title' => 'Stock Manager',
+ 'description' => 'Manages stock masters, approvals, and procurement.',
+ ],
+ 'department_head' => [
+ 'title' => 'Department Head',
+ 'description' => 'Creates and tracks departmental stock indents.',
+ ],
+ 'storekeeper' => [
+ 'title' => 'Storekeeper',
+ 'description' => 'Handles item issuance and stock receiving operations.',
+ ],
'user' => [
'title' => 'User',
'description' => 'General users of the site. Often customers.',
@@ -129,6 +141,13 @@ class AuthGroups extends ShieldAuthGroups
'template.diagnosis_print' => 'Can access diagnosis print templates',
'template.document_print' => 'Can access document print templates',
'template.ipd_document' => 'Can access IPD document master templates',
+ 'hospital_stock.access' => 'Can access hospital stock module',
+ 'hospital_stock.master.manage' => 'Can manage hospital stock masters (category/item/supplier)',
+ 'hospital_stock.indent.create' => 'Can create stock indents',
+ 'hospital_stock.indent.approve' => 'Can approve stock indents',
+ 'hospital_stock.issue' => 'Can issue stock against approved indents',
+ 'hospital_stock.purchase.manage' => 'Can create/receive purchase orders',
+ 'hospital_stock.report.view' => 'Can view stock reports and alerts',
];
/**
@@ -150,6 +169,7 @@ class AuthGroups extends ShieldAuthGroups
'doctor_work.*',
'pharmacy.*',
'template.*',
+ 'hospital_stock.*',
],
'admin' => [
'admin.access',
@@ -163,6 +183,7 @@ class AuthGroups extends ShieldAuthGroups
'doctor_work.*',
'pharmacy.*',
'template.*',
+ 'hospital_stock.*',
],
'developer' => [
'admin.access',
@@ -176,6 +197,25 @@ class AuthGroups extends ShieldAuthGroups
'doctor_work.*',
'pharmacy.*',
'template.*',
+ 'hospital_stock.*',
+ ],
+ 'stock_manager' => [
+ 'hospital_stock.access',
+ 'hospital_stock.master.manage',
+ 'hospital_stock.indent.approve',
+ 'hospital_stock.purchase.manage',
+ 'hospital_stock.report.view',
+ ],
+ 'department_head' => [
+ 'hospital_stock.access',
+ 'hospital_stock.indent.create',
+ 'hospital_stock.report.view',
+ ],
+ 'storekeeper' => [
+ 'hospital_stock.access',
+ 'hospital_stock.issue',
+ 'hospital_stock.purchase.manage',
+ 'hospital_stock.report.view',
],
'user' => [],
'beta' => [
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 04342a1..30e1037 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -961,6 +961,29 @@ $routes->group('setting', static function($routes) {
$routes->get('admin/bed-assignments', 'Setting\\BedManagement::assignmentHistory');
$routes->post('admin/bed-assignments/save', 'Setting\\BedManagement::saveAssignment');
$routes->post('admin/bed-assignments/delete', 'Setting\\BedManagement::deleteAssignment');
+ $routes->get('admin/hospital-stock', 'Setting\\HospitalStock::index');
+ $routes->get('admin/hospital-stock/dashboard', 'Setting\\HospitalStock::dashboard');
+ $routes->get('admin/hospital-stock/masters', 'Setting\\HospitalStock::masters');
+ $routes->get('admin/hospital-stock/indents', 'Setting\\HospitalStock::indents');
+ $routes->get('admin/hospital-stock/purchase', 'Setting\\HospitalStock::purchase');
+ $routes->get('admin/hospital-stock/reports', 'Setting\\HospitalStock::reports');
+ $routes->post('admin/hospital-stock/category/save', 'Setting\\HospitalStock::saveCategory');
+ $routes->post('admin/hospital-stock/category/update', 'Setting\\HospitalStock::updateCategory');
+ $routes->post('admin/hospital-stock/category/delete', 'Setting\\HospitalStock::deleteCategory');
+ $routes->post('admin/hospital-stock/item/save', 'Setting\\HospitalStock::saveItem');
+ $routes->post('admin/hospital-stock/item/update', 'Setting\\HospitalStock::updateItem');
+ $routes->post('admin/hospital-stock/item/delete', 'Setting\\HospitalStock::deleteItem');
+ $routes->post('admin/hospital-stock/supplier/save', 'Setting\\HospitalStock::saveSupplier');
+ $routes->post('admin/hospital-stock/supplier/update', 'Setting\\HospitalStock::updateSupplier');
+ $routes->post('admin/hospital-stock/supplier/delete', 'Setting\\HospitalStock::deleteSupplier');
+ $routes->post('admin/hospital-stock/indent/create', 'Setting\\HospitalStock::createIndent');
+ $routes->post('admin/hospital-stock/indent/approve', 'Setting\\HospitalStock::approveIndent');
+ $routes->post('admin/hospital-stock/indent/issue', 'Setting\\HospitalStock::issueIndent');
+ $routes->post('admin/hospital-stock/po/create', 'Setting\\HospitalStock::createPurchaseOrder');
+ $routes->post('admin/hospital-stock/po/receive', 'Setting\\HospitalStock::receivePurchaseOrder');
+ $routes->get('admin/hospital-stock/report/department-consumption', 'Setting\\HospitalStock::reportDepartmentConsumption');
+ $routes->get('admin/hospital-stock/report/monthly-issue', 'Setting\\HospitalStock::reportMonthlyIssue');
+ $routes->get('admin/hospital-stock/report/near-expiry', 'Setting\\HospitalStock::reportNearExpiry');
$routes->get('template', 'Setting\\Template::index');
$routes->get('charges', 'Setting\\Charges::index');
});
diff --git a/app/Controllers/Setting/HospitalStock.php b/app/Controllers/Setting/HospitalStock.php
new file mode 100644
index 0000000..5b1f40d
--- /dev/null
+++ b/app/Controllers/Setting/HospitalStock.php
@@ -0,0 +1,1211 @@
+hasAnyPermission([
+ self::PERM_ACCESS,
+ self::PERM_MASTER_MANAGE,
+ self::PERM_INDENT_CREATE,
+ self::PERM_INDENT_APPROVE,
+ self::PERM_ISSUE,
+ self::PERM_PURCHASE_MANAGE,
+ self::PERM_REPORT_VIEW,
+ ])) {
+ return '
Access denied.
';
+ }
+
+ if (! $this->isModuleReady()) {
+ return 'Hospital stock tables are missing. Please run php spark migrate .
';
+ }
+
+ return view('Setting/Stock/index', [
+ 'canMasterManage' => $this->userCan(self::PERM_MASTER_MANAGE),
+ 'canIndentCreate' => $this->userCan(self::PERM_INDENT_CREATE),
+ 'canIndentApprove' => $this->userCan(self::PERM_INDENT_APPROVE),
+ 'canIssue' => $this->userCan(self::PERM_ISSUE),
+ 'canPurchaseManage' => $this->userCan(self::PERM_PURCHASE_MANAGE),
+ 'canReportView' => $this->userCan(self::PERM_REPORT_VIEW),
+ ]);
+ }
+
+ public function dashboard(): string
+ {
+ if (! $this->userCan(self::PERM_REPORT_VIEW)) {
+ return 'Access denied.
';
+ }
+
+ $stats = $this->getStats();
+
+ $newRequests = $this->db->table('hsm_indents')
+ ->where('status', 'pending')
+ ->orderBy('id', 'DESC')
+ ->limit(20)
+ ->get()
+ ->getResultArray();
+
+ $nearExpiryFrequent = $this->db->query(
+ "SELECT i.item_code, i.name, i.current_stock, i.expiry_date, IFNULL(SUM(ii.qty),0) AS used_qty
+ FROM hsm_items i
+ LEFT JOIN hsm_issue_items ii ON ii.item_id = i.id
+ LEFT JOIN hsm_issues hs ON hs.id = ii.issue_id AND DATE(hs.created_at) >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
+ WHERE i.expiry_date IS NOT NULL
+ AND DATE(i.expiry_date) BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY)
+ GROUP BY i.id, i.item_code, i.name, i.current_stock, i.expiry_date
+ HAVING used_qty > 0
+ ORDER BY i.expiry_date ASC, used_qty DESC
+ LIMIT 25"
+ )->getResultArray();
+
+ $lowStockNoRequest = $this->db->query(
+ "SELECT i.item_code, i.name, i.current_stock, i.reorder_level
+ FROM hsm_items i
+ WHERE i.current_stock <= i.reorder_level
+ AND NOT EXISTS (
+ SELECT 1
+ FROM hsm_indent_items hi
+ JOIN hsm_indents h ON h.id = hi.indent_id
+ WHERE hi.item_id = i.id
+ AND h.status IN ('pending','approved','partial_issued')
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM hsm_purchase_order_items pi
+ JOIN hsm_purchase_orders p ON p.id = pi.purchase_order_id
+ WHERE pi.item_id = i.id
+ AND p.status IN ('ordered','partial_received')
+ )
+ ORDER BY i.current_stock ASC
+ LIMIT 25"
+ )->getResultArray();
+
+ $dailyUseStatus = $this->db->query(
+ "SELECT i.item_code, i.name, i.current_stock,
+ IFNULL(SUM(CASE WHEN DATE(hs.created_at)=CURDATE() THEN ii.qty ELSE 0 END),0) AS issued_today,
+ IFNULL(SUM(CASE WHEN DATE(hs.created_at)>=DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN ii.qty ELSE 0 END),0) AS issued_7d
+ FROM hsm_items i
+ LEFT JOIN hsm_issue_items ii ON ii.item_id = i.id
+ LEFT JOIN hsm_issues hs ON hs.id = ii.issue_id
+ WHERE IFNULL(i.is_daily_use,0) = 1
+ GROUP BY i.id, i.item_code, i.name, i.current_stock
+ ORDER BY i.name ASC
+ LIMIT 30"
+ )->getResultArray();
+
+ return view('Setting/Stock/dashboard', [
+ 'stats' => $stats,
+ 'newRequests' => $newRequests,
+ 'nearExpiryFrequent' => $nearExpiryFrequent,
+ 'lowStockNoRequest' => $lowStockNoRequest,
+ 'dailyUseStatus' => $dailyUseStatus,
+ ]);
+ }
+
+ public function masters(): string
+ {
+ if (! $this->userCan(self::PERM_MASTER_MANAGE)) {
+ return 'Access denied.
';
+ }
+
+ $categories = (new HospitalStockCategoryModel())->orderBy('name', 'ASC')->findAll();
+ $items = $this->db->table('hsm_items i')
+ ->select('i.*, c.name AS category_name')
+ ->join('hsm_categories c', 'c.id = i.category_id', 'left')
+ ->orderBy('i.id', 'DESC')
+ ->get()
+ ->getResultArray();
+ $suppliers = (new HospitalStockSupplierModel())->orderBy('id', 'DESC')->findAll();
+
+ return view('Setting/Stock/masters', [
+ 'categories' => $categories,
+ 'items' => $items,
+ 'suppliers' => $suppliers,
+ ]);
+ }
+
+ public function indents(): string
+ {
+ if (! $this->hasAnyPermission([self::PERM_INDENT_CREATE, self::PERM_INDENT_APPROVE, self::PERM_ISSUE])) {
+ return 'Access denied.
';
+ }
+
+ $items = $this->db->table('hsm_items')->select('id, item_code, name')->where('status', 'active')->orderBy('name', 'ASC')->get()->getResultArray();
+ $indents = $this->db->table('hsm_indents')->orderBy('id', 'DESC')->limit(100)->get()->getResultArray();
+
+ $departments = [];
+ if ($this->db->tableExists('hc_department')) {
+ $departments = $this->db->table('hc_department')
+ ->select('iId, vName')
+ ->orderBy('vName', 'ASC')
+ ->get()
+ ->getResultArray();
+ }
+
+ return view('Setting/Stock/indents', [
+ 'items' => $items,
+ 'indents' => $indents,
+ 'departments' => $departments,
+ 'canIndentCreate' => $this->userCan(self::PERM_INDENT_CREATE),
+ 'canIndentApprove' => $this->userCan(self::PERM_INDENT_APPROVE),
+ 'canIssue' => $this->userCan(self::PERM_ISSUE),
+ ]);
+ }
+
+ public function purchase(): string
+ {
+ if (! $this->userCan(self::PERM_PURCHASE_MANAGE)) {
+ return 'Access denied.
';
+ }
+
+ $items = $this->db->table('hsm_items')->select('id, item_code, name')->where('status', 'active')->orderBy('name', 'ASC')->get()->getResultArray();
+ $suppliers = (new HospitalStockSupplierModel())->where('status', 'active')->orderBy('name', 'ASC')->findAll();
+ $purchaseOrders = $this->db->table('hsm_purchase_orders p')
+ ->select('p.*, s.name AS supplier_name')
+ ->join('hsm_suppliers s', 's.id = p.supplier_id', 'left')
+ ->orderBy('p.id', 'DESC')
+ ->limit(100)
+ ->get()
+ ->getResultArray();
+
+ return view('Setting/Stock/purchase', [
+ 'items' => $items,
+ 'suppliers' => $suppliers,
+ 'purchaseOrders' => $purchaseOrders,
+ ]);
+ }
+
+ public function reports(): string
+ {
+ if (! $this->userCan(self::PERM_REPORT_VIEW)) {
+ return 'Access denied.
';
+ }
+
+ $alerts = $this->db->table('hsm_items i')
+ ->select('i.item_code, i.name, i.current_stock, i.min_stock_level, i.reorder_level, i.expiry_date')
+ ->groupStart()
+ ->where('i.current_stock <= i.min_stock_level', null, false)
+ ->orWhere('i.current_stock <= i.reorder_level', null, false)
+ ->groupEnd()
+ ->orderBy('i.current_stock', 'ASC')
+ ->limit(100)
+ ->get()
+ ->getResultArray();
+
+ return view('Setting/Stock/reports', [
+ 'alerts' => $alerts,
+ ]);
+ }
+
+ public function saveCategory()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $name = trim((string) $this->request->getPost('name'));
+ $status = trim((string) ($this->request->getPost('status') ?? 'active'));
+
+ if ($name === '') {
+ return $this->jsonError('Category name is required.');
+ }
+
+ $exists = $this->db->query('SELECT id FROM hsm_categories WHERE LOWER(name) = ? LIMIT 1', [strtolower($name)])->getNumRows();
+ if ($exists > 0) {
+ return $this->jsonError('Category already exists.');
+ }
+
+ $id = (new HospitalStockCategoryModel())->insert([
+ 'name' => $name,
+ 'description' => $this->request->getPost('description'),
+ 'status' => in_array($status, ['active', 'inactive'], true) ? $status : 'active',
+ ]);
+
+ $this->auditLog('category', 'create', 'hsm_categories', (int) $id, 'Created category: ' . $name, ['name' => $name]);
+
+ return $this->jsonSuccess('Category saved successfully.', ['insertid' => $id]);
+ }
+
+ public function updateCategory()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ $name = trim((string) $this->request->getPost('name'));
+ if ($id <= 0 || $name === '') {
+ return $this->jsonError('Invalid category data.');
+ }
+
+ $exists = $this->db->query('SELECT id FROM hsm_categories WHERE LOWER(name)=? AND id!=? LIMIT 1', [strtolower($name), $id])->getNumRows();
+ if ($exists > 0) {
+ return $this->jsonError('Category already exists.');
+ }
+
+ (new HospitalStockCategoryModel())->update($id, [
+ 'name' => $name,
+ 'description' => $this->request->getPost('description'),
+ 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active',
+ ]);
+
+ $this->auditLog('category', 'update', 'hsm_categories', $id, 'Updated category: ' . $name);
+
+ return $this->jsonSuccess('Category updated successfully.');
+ }
+
+ public function deleteCategory()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ if ($id <= 0) {
+ return $this->jsonError('Invalid category id.');
+ }
+
+ $itemCount = $this->db->table('hsm_items')->where('category_id', $id)->countAllResults();
+ if ($itemCount > 0) {
+ return $this->jsonError('Cannot delete category with mapped items.');
+ }
+
+ (new HospitalStockCategoryModel())->delete($id);
+ $this->auditLog('category', 'delete', 'hsm_categories', $id, 'Deleted category id: ' . $id);
+
+ return $this->jsonSuccess('Category deleted successfully.');
+ }
+
+ public function saveItem()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $itemCode = trim((string) $this->request->getPost('item_code'));
+ $name = trim((string) $this->request->getPost('name'));
+ $categoryId = (int) $this->request->getPost('category_id');
+
+ if ($itemCode === '' || $name === '' || $categoryId <= 0) {
+ return $this->jsonError('Item code, name and category are required.');
+ }
+
+ $exists = $this->db->table('hsm_items')->where('item_code', $itemCode)->countAllResults();
+ if ($exists > 0) {
+ return $this->jsonError('Item code already exists.');
+ }
+
+ $model = new HospitalStockItemModel();
+ $id = $model->insert([
+ 'item_code' => $itemCode,
+ 'name' => $name,
+ 'category_id' => $categoryId,
+ 'item_type' => trim((string) ($this->request->getPost('item_type') ?? '')),
+ 'uom' => trim((string) ($this->request->getPost('uom') ?? 'Unit')),
+ 'purchase_uom' => trim((string) ($this->request->getPost('purchase_uom') ?? 'Unit')),
+ 'issue_uom' => trim((string) ($this->request->getPost('issue_uom') ?? 'Unit')),
+ 'issue_per_purchase' => (float) ($this->request->getPost('issue_per_purchase') ?? 1),
+ 'is_daily_use' => $this->request->getPost('is_daily_use') ? 1 : 0,
+ 'store_location' => trim((string) ($this->request->getPost('store_location') ?? '')),
+ 'barcode' => trim((string) ($this->request->getPost('barcode') ?? '')),
+ 'qr_code' => trim((string) ($this->request->getPost('qr_code') ?? '')),
+ 'current_stock' => (float) ($this->request->getPost('current_stock') ?? 0),
+ 'min_stock_level' => (float) ($this->request->getPost('min_stock_level') ?? 0),
+ 'reorder_level' => (float) ($this->request->getPost('reorder_level') ?? 0),
+ 'unit_cost' => (float) ($this->request->getPost('unit_cost') ?? 0),
+ 'expiry_date' => $this->request->getPost('expiry_date') ?: null,
+ 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active',
+ ]);
+
+ $openingStock = (float) ($this->request->getPost('current_stock') ?? 0);
+ if ($openingStock > 0) {
+ $this->appendStockLedger((int) $id, 'adjustment_in', 'hsm_items', (int) $id, $openingStock, 0, 'Opening stock');
+ }
+
+ $this->auditLog('item', 'create', 'hsm_items', (int) $id, 'Created item: ' . $name, ['item_code' => $itemCode]);
+
+ return $this->jsonSuccess('Item saved successfully.', ['insertid' => $id]);
+ }
+
+ public function updateItem()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ $itemCode = trim((string) $this->request->getPost('item_code'));
+ $name = trim((string) $this->request->getPost('name'));
+ $categoryId = (int) $this->request->getPost('category_id');
+
+ if ($id <= 0 || $itemCode === '' || $name === '' || $categoryId <= 0) {
+ return $this->jsonError('Invalid item data.');
+ }
+
+ $exists = $this->db->table('hsm_items')->where('item_code', $itemCode)->where('id !=', $id)->countAllResults();
+ if ($exists > 0) {
+ return $this->jsonError('Item code already exists.');
+ }
+
+ $current = $this->db->table('hsm_items')->where('id', $id)->get()->getRowArray();
+ if (! $current) {
+ return $this->jsonError('Item not found.');
+ }
+
+ $newStock = (float) ($this->request->getPost('current_stock') ?? 0);
+ $oldStock = (float) ($current['current_stock'] ?? 0);
+
+ (new HospitalStockItemModel())->update($id, [
+ 'item_code' => $itemCode,
+ 'name' => $name,
+ 'category_id' => $categoryId,
+ 'item_type' => trim((string) ($this->request->getPost('item_type') ?? '')),
+ 'uom' => trim((string) ($this->request->getPost('uom') ?? 'Unit')),
+ 'purchase_uom' => trim((string) ($this->request->getPost('purchase_uom') ?? 'Unit')),
+ 'issue_uom' => trim((string) ($this->request->getPost('issue_uom') ?? 'Unit')),
+ 'issue_per_purchase' => (float) ($this->request->getPost('issue_per_purchase') ?? 1),
+ 'is_daily_use' => $this->request->getPost('is_daily_use') ? 1 : 0,
+ 'store_location' => trim((string) ($this->request->getPost('store_location') ?? '')),
+ 'barcode' => trim((string) ($this->request->getPost('barcode') ?? '')),
+ 'qr_code' => trim((string) ($this->request->getPost('qr_code') ?? '')),
+ 'current_stock' => $newStock,
+ 'min_stock_level' => (float) ($this->request->getPost('min_stock_level') ?? 0),
+ 'reorder_level' => (float) ($this->request->getPost('reorder_level') ?? 0),
+ 'unit_cost' => (float) ($this->request->getPost('unit_cost') ?? 0),
+ 'expiry_date' => $this->request->getPost('expiry_date') ?: null,
+ 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active',
+ ]);
+
+ $diff = $newStock - $oldStock;
+ if ($diff > 0) {
+ $this->appendStockLedger($id, 'adjustment_in', 'hsm_items', $id, $diff, 0, 'Manual stock correction (+)');
+ } elseif ($diff < 0) {
+ $this->appendStockLedger($id, 'adjustment_out', 'hsm_items', $id, 0, abs($diff), 'Manual stock correction (-)');
+ }
+
+ $this->auditLog('item', 'update', 'hsm_items', $id, 'Updated item: ' . $name, ['item_code' => $itemCode]);
+
+ return $this->jsonSuccess('Item updated successfully.');
+ }
+
+ public function deleteItem()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ if ($id <= 0) {
+ return $this->jsonError('Invalid item id.');
+ }
+
+ $indentRows = $this->db->table('hsm_indent_items')->where('item_id', $id)->countAllResults();
+ $poRows = $this->db->table('hsm_purchase_order_items')->where('item_id', $id)->countAllResults();
+ $issueRows = $this->db->table('hsm_issue_items')->where('item_id', $id)->countAllResults();
+
+ if ($indentRows > 0 || $poRows > 0 || $issueRows > 0) {
+ return $this->jsonError('Cannot delete item linked with transactions.');
+ }
+
+ (new HospitalStockItemModel())->delete($id);
+ $this->auditLog('item', 'delete', 'hsm_items', $id, 'Deleted item id: ' . $id);
+
+ return $this->jsonSuccess('Item deleted successfully.');
+ }
+
+ public function saveSupplier()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $name = trim((string) $this->request->getPost('name'));
+ if ($name === '') {
+ return $this->jsonError('Supplier name is required.');
+ }
+
+ $id = (new HospitalStockSupplierModel())->insert([
+ 'name' => $name,
+ 'contact_person' => trim((string) ($this->request->getPost('contact_person') ?? '')),
+ 'phone' => trim((string) ($this->request->getPost('phone') ?? '')),
+ 'email' => trim((string) ($this->request->getPost('email') ?? '')),
+ 'address' => $this->request->getPost('address'),
+ 'gst_no' => trim((string) ($this->request->getPost('gst_no') ?? '')),
+ 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active',
+ ]);
+
+ $this->auditLog('supplier', 'create', 'hsm_suppliers', (int) $id, 'Created supplier: ' . $name, ['name' => $name]);
+
+ return $this->jsonSuccess('Supplier saved successfully.', ['insertid' => $id]);
+ }
+
+ public function updateSupplier()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ $name = trim((string) $this->request->getPost('name'));
+ if ($id <= 0 || $name === '') {
+ return $this->jsonError('Invalid supplier data.');
+ }
+
+ (new HospitalStockSupplierModel())->update($id, [
+ 'name' => $name,
+ 'contact_person' => trim((string) ($this->request->getPost('contact_person') ?? '')),
+ 'phone' => trim((string) ($this->request->getPost('phone') ?? '')),
+ 'email' => trim((string) ($this->request->getPost('email') ?? '')),
+ 'address' => $this->request->getPost('address'),
+ 'gst_no' => trim((string) ($this->request->getPost('gst_no') ?? '')),
+ 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active',
+ ]);
+
+ $this->auditLog('supplier', 'update', 'hsm_suppliers', $id, 'Updated supplier: ' . $name);
+
+ return $this->jsonSuccess('Supplier updated successfully.');
+ }
+
+ public function deleteSupplier()
+ {
+ if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $id = (int) $this->request->getPost('id');
+ if ($id <= 0) {
+ return $this->jsonError('Invalid supplier id.');
+ }
+
+ $poCount = $this->db->table('hsm_purchase_orders')->where('supplier_id', $id)->countAllResults();
+ if ($poCount > 0) {
+ return $this->jsonError('Cannot delete supplier linked with purchase orders.');
+ }
+
+ (new HospitalStockSupplierModel())->delete($id);
+ $this->auditLog('supplier', 'delete', 'hsm_suppliers', $id, 'Deleted supplier id: ' . $id);
+
+ return $this->jsonSuccess('Supplier deleted successfully.');
+ }
+
+ public function createIndent()
+ {
+ if ($deny = $this->denyUnless(self::PERM_INDENT_CREATE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $department = trim((string) $this->request->getPost('department_name'));
+ $itemIds = $this->request->getPost('item_id');
+ $qtys = $this->request->getPost('qty');
+
+ if ($department === '') {
+ return $this->jsonError('Department name is required.');
+ }
+
+ if (! is_array($itemIds) || ! is_array($qtys) || count($itemIds) === 0) {
+ return $this->jsonError('At least one item is required for indent.');
+ }
+
+ $lines = [];
+ foreach ($itemIds as $k => $itemIdRaw) {
+ $itemId = (int) $itemIdRaw;
+ $qty = (float) ($qtys[$k] ?? 0);
+ if ($itemId > 0 && $qty > 0) {
+ $lines[] = ['item_id' => $itemId, 'qty' => $qty];
+ }
+ }
+
+ if ($lines === []) {
+ return $this->jsonError('No valid indent rows found.');
+ }
+
+ $this->db->transStart();
+
+ $indentCode = $this->nextCode('HIND');
+ $userId = $this->currentUserId();
+
+ $this->db->table('hsm_indents')->insert([
+ 'indent_code' => $indentCode,
+ 'department_name' => $department,
+ 'requested_by' => $userId,
+ 'remarks' => $this->request->getPost('remarks'),
+ 'status' => 'pending',
+ ]);
+
+ $indentId = (int) $this->db->insertID();
+
+ foreach ($lines as $line) {
+ $this->db->table('hsm_indent_items')->insert([
+ 'indent_id' => $indentId,
+ 'item_id' => $line['item_id'],
+ 'requested_qty' => $line['qty'],
+ 'approved_qty' => 0,
+ 'issued_qty' => 0,
+ ]);
+ }
+
+ $this->auditLog('indent', 'create', 'hsm_indents', $indentId, 'Indent created: ' . $indentCode, ['department' => $department]);
+
+ $this->db->transComplete();
+ if (! $this->db->transStatus()) {
+ return $this->jsonError('Failed to create indent.');
+ }
+
+ return $this->jsonSuccess('Indent created successfully.', ['indent_id' => $indentId]);
+ }
+
+ public function approveIndent()
+ {
+ if ($deny = $this->denyUnless(self::PERM_INDENT_APPROVE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $indentId = (int) $this->request->getPost('indent_id');
+ if ($indentId <= 0) {
+ return $this->jsonError('Invalid indent ID.');
+ }
+
+ $indent = $this->db->table('hsm_indents')->where('id', $indentId)->get()->getRowArray();
+ if (! $indent) {
+ return $this->jsonError('Indent not found.');
+ }
+
+ if (! in_array((string) $indent['status'], ['pending'], true)) {
+ return $this->jsonError('Only pending indents can be approved.');
+ }
+
+ $this->db->transStart();
+
+ $items = $this->db->table('hsm_indent_items')->where('indent_id', $indentId)->get()->getResultArray();
+ foreach ($items as $row) {
+ $this->db->table('hsm_indent_items')
+ ->where('id', (int) $row['id'])
+ ->update(['approved_qty' => (float) ($row['requested_qty'] ?? 0)]);
+ }
+
+ $this->db->table('hsm_indents')->where('id', $indentId)->update([
+ 'status' => 'approved',
+ 'approved_by' => $this->currentUserId(),
+ 'approved_at' => date('Y-m-d H:i:s'),
+ ]);
+
+ $this->auditLog('indent', 'approve', 'hsm_indents', $indentId, 'Indent approved: ' . ($indent['indent_code'] ?? ''));
+
+ $this->db->transComplete();
+ if (! $this->db->transStatus()) {
+ return $this->jsonError('Failed to approve indent.');
+ }
+
+ return $this->jsonSuccess('Indent approved successfully.');
+ }
+
+ public function issueIndent()
+ {
+ if ($deny = $this->denyUnless(self::PERM_ISSUE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $indentId = (int) $this->request->getPost('indent_id');
+ if ($indentId <= 0) {
+ return $this->jsonError('Invalid indent ID.');
+ }
+
+ $indent = $this->db->table('hsm_indents')->where('id', $indentId)->get()->getRowArray();
+ if (! $indent) {
+ return $this->jsonError('Indent not found.');
+ }
+
+ if (! in_array((string) $indent['status'], ['approved', 'partial_issued'], true)) {
+ return $this->jsonError('Indent must be approved before issue.');
+ }
+
+ $this->db->transStart();
+
+ $issueCode = $this->nextCode('HISS');
+ $this->db->table('hsm_issues')->insert([
+ 'issue_code' => $issueCode,
+ 'indent_id' => $indentId,
+ 'department_name' => (string) ($indent['department_name'] ?? ''),
+ 'issued_by' => $this->currentUserId(),
+ 'remarks' => $this->request->getPost('remarks'),
+ ]);
+ $issueId = (int) $this->db->insertID();
+
+ $rows = $this->db->table('hsm_indent_items')->where('indent_id', $indentId)->get()->getResultArray();
+ $hasIssued = false;
+ $allFulfilled = true;
+
+ foreach ($rows as $row) {
+ $itemId = (int) ($row['item_id'] ?? 0);
+ $approvedQty = (float) ($row['approved_qty'] ?? 0);
+ $issuedQty = (float) ($row['issued_qty'] ?? 0);
+ $remaining = $approvedQty - $issuedQty;
+ if ($itemId <= 0 || $remaining <= 0) {
+ continue;
+ }
+
+ $item = $this->db->table('hsm_items')->where('id', $itemId)->get()->getRowArray();
+ $stock = (float) ($item['current_stock'] ?? 0);
+ $issueNow = min($stock, $remaining);
+
+ if ($issueNow <= 0) {
+ $allFulfilled = false;
+ continue;
+ }
+
+ $updated = $this->db->table('hsm_items')
+ ->set('current_stock', 'current_stock - ' . $issueNow, false)
+ ->where('id', $itemId)
+ ->where('current_stock >=', $issueNow)
+ ->update();
+
+ if (! $updated || $this->db->affectedRows() <= 0) {
+ $allFulfilled = false;
+ continue;
+ }
+
+ $this->db->table('hsm_indent_items')
+ ->set('issued_qty', 'issued_qty + ' . $issueNow, false)
+ ->where('id', (int) $row['id'])
+ ->update();
+
+ $this->db->table('hsm_issue_items')->insert([
+ 'issue_id' => $issueId,
+ 'item_id' => $itemId,
+ 'qty' => $issueNow,
+ ]);
+
+ $latest = $this->db->table('hsm_items')->select('current_stock')->where('id', $itemId)->get()->getRowArray();
+ $balance = (float) ($latest['current_stock'] ?? 0);
+
+ $this->appendStockLedger($itemId, 'issue', 'hsm_issues', $issueId, 0, $issueNow, 'Issued against indent: ' . ($indent['indent_code'] ?? ''));
+
+ if ($balance < ($remaining - $issueNow)) {
+ $allFulfilled = false;
+ }
+
+ $hasIssued = true;
+ }
+
+ if (! $hasIssued) {
+ $this->db->transRollback();
+ return $this->jsonError('No item could be issued (stock unavailable).');
+ }
+
+ $this->db->table('hsm_indents')->where('id', $indentId)->update([
+ 'status' => $allFulfilled ? 'issued' : 'partial_issued',
+ ]);
+
+ $this->auditLog('issue', 'create', 'hsm_issues', $issueId, 'Issue created: ' . $issueCode, ['indent_id' => $indentId]);
+
+ $this->db->transComplete();
+ if (! $this->db->transStatus()) {
+ return $this->jsonError('Failed to issue stock.');
+ }
+
+ return $this->jsonSuccess($allFulfilled ? 'Indent fully issued.' : 'Indent partially issued due to stock shortage.');
+ }
+
+ public function createPurchaseOrder()
+ {
+ if ($deny = $this->denyUnless(self::PERM_PURCHASE_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $supplierId = (int) $this->request->getPost('supplier_id');
+ $itemIds = $this->request->getPost('item_id');
+ $qtys = $this->request->getPost('qty');
+ $rates = $this->request->getPost('unit_cost');
+
+ if ($supplierId <= 0) {
+ return $this->jsonError('Supplier is required.');
+ }
+
+ if (! is_array($itemIds) || ! is_array($qtys) || count($itemIds) === 0) {
+ return $this->jsonError('At least one purchase item is required.');
+ }
+
+ $lines = [];
+ foreach ($itemIds as $k => $itemIdRaw) {
+ $itemId = (int) $itemIdRaw;
+ $qty = (float) ($qtys[$k] ?? 0);
+ $rate = (float) ($rates[$k] ?? 0);
+ if ($itemId > 0 && $qty > 0) {
+ $lines[] = ['item_id' => $itemId, 'qty' => $qty, 'unit_cost' => $rate];
+ }
+ }
+
+ if ($lines === []) {
+ return $this->jsonError('No valid purchase lines found.');
+ }
+
+ $this->db->transStart();
+
+ $poCode = $this->nextCode('HPO');
+ $this->db->table('hsm_purchase_orders')->insert([
+ 'po_code' => $poCode,
+ 'supplier_id' => $supplierId,
+ 'order_date' => $this->request->getPost('order_date') ?: date('Y-m-d'),
+ 'expected_date' => $this->request->getPost('expected_date') ?: null,
+ 'ordered_by' => $this->currentUserId(),
+ 'status' => 'ordered',
+ 'remarks' => $this->request->getPost('remarks'),
+ ]);
+
+ $poId = (int) $this->db->insertID();
+
+ foreach ($lines as $line) {
+ $this->db->table('hsm_purchase_order_items')->insert([
+ 'purchase_order_id' => $poId,
+ 'item_id' => $line['item_id'],
+ 'ordered_qty' => $line['qty'],
+ 'received_qty' => 0,
+ 'unit_cost' => $line['unit_cost'],
+ ]);
+ }
+
+ $this->auditLog('purchase', 'create', 'hsm_purchase_orders', $poId, 'PO created: ' . $poCode);
+
+ $this->db->transComplete();
+ if (! $this->db->transStatus()) {
+ return $this->jsonError('Failed to create purchase order.');
+ }
+
+ return $this->jsonSuccess('Purchase order created successfully.');
+ }
+
+ public function receivePurchaseOrder()
+ {
+ if ($deny = $this->denyUnless(self::PERM_PURCHASE_MANAGE)) {
+ return $deny;
+ }
+
+ if (! $this->request->isAJAX()) {
+ return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
+ }
+
+ $poId = (int) $this->request->getPost('purchase_order_id');
+ if ($poId <= 0) {
+ return $this->jsonError('Invalid purchase order ID.');
+ }
+
+ $po = $this->db->table('hsm_purchase_orders')->where('id', $poId)->get()->getRowArray();
+ if (! $po) {
+ return $this->jsonError('Purchase order not found.');
+ }
+
+ if (in_array((string) $po['status'], ['completed', 'cancelled'], true)) {
+ return $this->jsonError('This purchase order cannot be received.');
+ }
+
+ $this->db->transStart();
+
+ $rows = $this->db->table('hsm_purchase_order_items')->where('purchase_order_id', $poId)->get()->getResultArray();
+ $allDone = true;
+
+ foreach ($rows as $row) {
+ $itemId = (int) ($row['item_id'] ?? 0);
+ $ordered = (float) ($row['ordered_qty'] ?? 0);
+ $received = (float) ($row['received_qty'] ?? 0);
+ $remaining = $ordered - $received;
+ if ($itemId <= 0 || $remaining <= 0) {
+ continue;
+ }
+
+ $this->db->table('hsm_items')
+ ->set('current_stock', 'current_stock + ' . $remaining, false)
+ ->where('id', $itemId)
+ ->update();
+
+ $this->db->table('hsm_purchase_order_items')
+ ->set('received_qty', 'received_qty + ' . $remaining, false)
+ ->where('id', (int) $row['id'])
+ ->update();
+
+ if ((float) ($row['unit_cost'] ?? 0) > 0) {
+ $this->db->table('hsm_items')->where('id', $itemId)->update([
+ 'unit_cost' => (float) ($row['unit_cost'] ?? 0),
+ ]);
+ }
+
+ $this->appendStockLedger($itemId, 'purchase', 'hsm_purchase_orders', $poId, $remaining, 0, 'PO receive: ' . ($po['po_code'] ?? ''));
+ }
+
+ $recheck = $this->db->table('hsm_purchase_order_items')->where('purchase_order_id', $poId)->get()->getResultArray();
+ foreach ($recheck as $line) {
+ if ((float) ($line['received_qty'] ?? 0) < (float) ($line['ordered_qty'] ?? 0)) {
+ $allDone = false;
+ break;
+ }
+ }
+
+ $this->db->table('hsm_purchase_orders')->where('id', $poId)->update([
+ 'status' => $allDone ? 'completed' : 'partial_received',
+ ]);
+
+ $this->auditLog('purchase', 'receive', 'hsm_purchase_orders', $poId, 'PO stock received: ' . ($po['po_code'] ?? ''));
+
+ $this->db->transComplete();
+ if (! $this->db->transStatus()) {
+ return $this->jsonError('Failed to receive purchase order.');
+ }
+
+ return $this->jsonSuccess($allDone ? 'PO completed and stock updated.' : 'PO partially received.');
+ }
+
+ public function reportDepartmentConsumption(): string
+ {
+ if (! $this->userCan(self::PERM_REPORT_VIEW)) {
+ return 'Access denied.';
+ }
+
+ $from = trim((string) $this->request->getGet('from'));
+ $to = trim((string) $this->request->getGet('to'));
+ if ($from === '') {
+ $from = date('Y-m-01');
+ }
+ if ($to === '') {
+ $to = date('Y-m-d');
+ }
+
+ $rows = $this->db->table('hsm_issue_items ii')
+ ->select('i.department_name, m.item_code, m.name AS item_name, SUM(ii.qty) AS total_qty', false)
+ ->join('hsm_issues i', 'i.id = ii.issue_id', 'inner')
+ ->join('hsm_items m', 'm.id = ii.item_id', 'inner')
+ ->where('DATE(i.created_at) >=', $from)
+ ->where('DATE(i.created_at) <=', $to)
+ ->groupBy('i.department_name, m.item_code, m.name')
+ ->orderBy('i.department_name', 'ASC')
+ ->orderBy('m.name', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ return view('Setting/Stock/report_print', [
+ 'title' => 'Department-wise Consumption Report',
+ 'subtitle' => 'Period: ' . $from . ' to ' . $to,
+ 'columns' => ['Department', 'Item Code', 'Item Name', 'Total Issued Qty'],
+ 'rows' => array_map(static fn (array $r): array => [
+ (string) ($r['department_name'] ?? ''),
+ (string) ($r['item_code'] ?? ''),
+ (string) ($r['item_name'] ?? ''),
+ (string) ($r['total_qty'] ?? '0'),
+ ], $rows),
+ ]);
+ }
+
+ public function reportMonthlyIssue(): string
+ {
+ if (! $this->userCan(self::PERM_REPORT_VIEW)) {
+ return 'Access denied.';
+ }
+
+ $year = (int) ($this->request->getGet('year') ?: date('Y'));
+ if ($year < 2000 || $year > 2100) {
+ $year = (int) date('Y');
+ }
+
+ $rows = $this->db->table('hsm_issue_items ii')
+ ->select('DATE_FORMAT(i.created_at, "%Y-%m") AS issue_month, SUM(ii.qty) AS total_qty, COUNT(DISTINCT i.id) AS issue_count', false)
+ ->join('hsm_issues i', 'i.id = ii.issue_id', 'inner')
+ ->where('YEAR(i.created_at)', $year)
+ ->groupBy('DATE_FORMAT(i.created_at, "%Y-%m")', false)
+ ->orderBy('issue_month', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ return view('Setting/Stock/report_print', [
+ 'title' => 'Monthly Issue Report',
+ 'subtitle' => 'Year: ' . $year,
+ 'columns' => ['Month', 'Issue Transactions', 'Total Issued Qty'],
+ 'rows' => array_map(static fn (array $r): array => [
+ (string) ($r['issue_month'] ?? ''),
+ (string) ($r['issue_count'] ?? '0'),
+ (string) ($r['total_qty'] ?? '0'),
+ ], $rows),
+ ]);
+ }
+
+ public function reportNearExpiry(): string
+ {
+ if (! $this->userCan(self::PERM_REPORT_VIEW)) {
+ return 'Access denied.';
+ }
+
+ $days = (int) ($this->request->getGet('days') ?: 60);
+ if ($days <= 0 || $days > 3650) {
+ $days = 60;
+ }
+
+ $today = date('Y-m-d');
+ $toDate = date('Y-m-d', strtotime('+' . $days . ' days'));
+
+ $rows = $this->db->table('hsm_items i')
+ ->select('i.item_code, i.name, c.name AS category_name, i.current_stock, i.expiry_date, DATEDIFF(i.expiry_date, CURDATE()) AS days_left', false)
+ ->join('hsm_categories c', 'c.id = i.category_id', 'left')
+ ->where('i.current_stock >', 0)
+ ->where('i.expiry_date IS NOT NULL', null, false)
+ ->where('DATE(i.expiry_date) >=', $today)
+ ->where('DATE(i.expiry_date) <=', $toDate)
+ ->orderBy('i.expiry_date', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ return view('Setting/Stock/report_print', [
+ 'title' => 'Near Expiry Report',
+ 'subtitle' => 'Next ' . $days . ' day(s)',
+ 'columns' => ['Item Code', 'Item Name', 'Category', 'Current Stock', 'Expiry Date', 'Days Left'],
+ 'rows' => array_map(static fn (array $r): array => [
+ (string) ($r['item_code'] ?? ''),
+ (string) ($r['name'] ?? ''),
+ (string) ($r['category_name'] ?? ''),
+ (string) ($r['current_stock'] ?? '0'),
+ (string) ($r['expiry_date'] ?? ''),
+ (string) ($r['days_left'] ?? ''),
+ ], $rows),
+ ]);
+ }
+
+ private function appendStockLedger(int $itemId, string $txnType, string $refTable, int $refId, float $qtyIn, float $qtyOut, string $remarks = ''): void
+ {
+ $row = $this->db->table('hsm_items')->select('current_stock')->where('id', $itemId)->get()->getRowArray();
+ $balance = (float) ($row['current_stock'] ?? 0);
+
+ $this->db->table('hsm_stock_ledger')->insert([
+ 'item_id' => $itemId,
+ 'txn_type' => $txnType,
+ 'ref_table' => $refTable,
+ 'ref_id' => $refId,
+ 'qty_in' => $qtyIn,
+ 'qty_out' => $qtyOut,
+ 'balance_after' => $balance,
+ 'remarks' => $remarks,
+ 'created_by' => $this->currentUserId(),
+ ]);
+ }
+
+ private function getStats(): array
+ {
+ $totalItems = $this->db->table('hsm_items')->where('status', 'active')->countAllResults();
+ $totalCategories = $this->db->table('hsm_categories')->where('status', 'active')->countAllResults();
+ $totalSuppliers = $this->db->table('hsm_suppliers')->where('status', 'active')->countAllResults();
+ $pendingIndents = $this->db->table('hsm_indents')->whereIn('status', ['pending', 'approved', 'partial_issued'])->countAllResults();
+ $openPO = $this->db->table('hsm_purchase_orders')->whereIn('status', ['ordered', 'partial_received'])->countAllResults();
+
+ $stockValueRow = $this->db->table('hsm_items')
+ ->select('SUM(current_stock * unit_cost) AS stock_value', false)
+ ->get()
+ ->getRowArray();
+ $stockValue = (float) ($stockValueRow['stock_value'] ?? 0);
+
+ $lowStock = $this->db->table('hsm_items')
+ ->where('current_stock <= min_stock_level', null, false)
+ ->countAllResults();
+
+ return [
+ 'total_items' => $totalItems,
+ 'total_categories' => $totalCategories,
+ 'total_suppliers' => $totalSuppliers,
+ 'pending_indents' => $pendingIndents,
+ 'open_po' => $openPO,
+ 'low_stock' => $lowStock,
+ 'stock_value' => $stockValue,
+ ];
+ }
+
+ private function nextCode(string $prefix): string
+ {
+ $date = date('Ymd');
+ $like = $prefix . '-' . $date . '-';
+ $tableMap = [
+ 'HIND' => ['table' => 'hsm_indents', 'column' => 'indent_code'],
+ 'HISS' => ['table' => 'hsm_issues', 'column' => 'issue_code'],
+ 'HPO' => ['table' => 'hsm_purchase_orders', 'column' => 'po_code'],
+ ];
+
+ $map = $tableMap[$prefix] ?? null;
+ $seq = 1;
+
+ if ($map !== null && $this->db->tableExists($map['table'])) {
+ $row = $this->db->table($map['table'])
+ ->select($map['column'])
+ ->like($map['column'], $like, 'after')
+ ->orderBy($map['column'], 'DESC')
+ ->get(1)
+ ->getRowArray();
+
+ $lastCode = (string) ($row[$map['column']] ?? '');
+ if (preg_match('/-(\d{4})$/', $lastCode, $m) === 1) {
+ $seq = ((int) $m[1]) + 1;
+ }
+ }
+
+ return sprintf('%s-%s-%04d', $prefix, $date, $seq);
+ }
+
+ private function auditLog(string $module, string $action, string $entityTable, ?int $entityId, string $description, array $meta = []): void
+ {
+ $this->db->table('hsm_audit_log')->insert([
+ 'module' => $module,
+ 'action' => $action,
+ 'entity_table' => $entityTable,
+ 'entity_id' => $entityId,
+ 'description' => $description,
+ 'meta_json' => $meta ? json_encode($meta) : null,
+ 'created_by' => $this->currentUserId(),
+ ]);
+ }
+
+ private function currentUserId(): ?int
+ {
+ if (function_exists('auth')) {
+ $user = auth()->user();
+ if ($user && isset($user->id)) {
+ return (int) $user->id;
+ }
+ }
+
+ return null;
+ }
+
+ private function denyUnless(string $permission)
+ {
+ if ($this->userCan($permission)) {
+ return null;
+ }
+
+ return $this->response->setStatusCode(403)->setJSON([
+ 'update' => 0,
+ 'error_text' => 'Access denied.',
+ 'csrfName' => csrf_token(),
+ 'csrfHash' => csrf_hash(),
+ ]);
+ }
+
+ private function hasAnyPermission(array $permissions): bool
+ {
+ foreach ($permissions as $permission) {
+ if ($this->userCan($permission)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function userCan(string $permission): bool
+ {
+ if (! function_exists('auth')) {
+ return false;
+ }
+
+ $user = auth()->user();
+ if (! $user) {
+ return false;
+ }
+
+ if (method_exists($user, 'can') && $user->can($permission)) {
+ return true;
+ }
+
+ if (method_exists($user, 'inGroup') && $user->inGroup('superadmin', 'admin', 'developer')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private function jsonError(string $message)
+ {
+ return $this->response->setJSON([
+ 'update' => 0,
+ 'error_text' => $message,
+ 'csrfName' => csrf_token(),
+ 'csrfHash' => csrf_hash(),
+ ]);
+ }
+
+ private function jsonSuccess(string $message, array $extra = [])
+ {
+ return $this->response->setJSON(array_merge([
+ 'update' => 1,
+ 'showcontent' => $message,
+ 'csrfName' => csrf_token(),
+ 'csrfHash' => csrf_hash(),
+ ], $extra));
+ }
+
+ private function isModuleReady(): bool
+ {
+ return $this->db->tableExists('hsm_items')
+ && $this->db->tableExists('hsm_categories')
+ && $this->db->tableExists('hsm_indents')
+ && $this->db->tableExists('hsm_purchase_orders');
+ }
+}
diff --git a/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php b/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php
new file mode 100644
index 0000000..3324965
--- /dev/null
+++ b/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php
@@ -0,0 +1,305 @@
+createCategoriesTable();
+ $this->createSuppliersTable();
+ $this->createItemsTable();
+ $this->createIndentsTable();
+ $this->createIndentItemsTable();
+ $this->createIssuesTable();
+ $this->createIssueItemsTable();
+ $this->createPurchaseOrdersTable();
+ $this->createPurchaseOrderItemsTable();
+ $this->createStockLedgerTable();
+ $this->createAuditLogTable();
+ }
+
+ public function down()
+ {
+ $this->forge->dropTable('hsm_audit_log', true);
+ $this->forge->dropTable('hsm_stock_ledger', true);
+ $this->forge->dropTable('hsm_purchase_order_items', true);
+ $this->forge->dropTable('hsm_purchase_orders', true);
+ $this->forge->dropTable('hsm_issue_items', true);
+ $this->forge->dropTable('hsm_issues', true);
+ $this->forge->dropTable('hsm_indent_items', true);
+ $this->forge->dropTable('hsm_indents', true);
+ $this->forge->dropTable('hsm_items', true);
+ $this->forge->dropTable('hsm_suppliers', true);
+ $this->forge->dropTable('hsm_categories', true);
+ }
+
+ private function createCategoriesTable(): void
+ {
+ if ($this->hasTable('hsm_categories')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'name' => ['type' => 'VARCHAR', 'constraint' => 120],
+ 'description' => ['type' => 'TEXT', 'null' => true],
+ 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('name');
+ $this->forge->createTable('hsm_categories', true);
+ }
+
+ private function createSuppliersTable(): void
+ {
+ if ($this->hasTable('hsm_suppliers')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'name' => ['type' => 'VARCHAR', 'constraint' => 150],
+ 'contact_person' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true],
+ 'phone' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true],
+ 'email' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true],
+ 'address' => ['type' => 'TEXT', 'null' => true],
+ 'gst_no' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true],
+ 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('name');
+ $this->forge->createTable('hsm_suppliers', true);
+ }
+
+ private function createItemsTable(): void
+ {
+ if ($this->hasTable('hsm_items')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'item_code' => ['type' => 'VARCHAR', 'constraint' => 40],
+ 'name' => ['type' => 'VARCHAR', 'constraint' => 150],
+ 'category_id' => ['type' => 'INT', 'unsigned' => true],
+ 'uom' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'Unit'],
+ 'barcode' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true],
+ 'qr_code' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
+ 'current_stock' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'min_stock_level' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'reorder_level' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'unit_cost' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'expiry_date' => ['type' => 'DATE', 'null' => true],
+ 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('item_code');
+ $this->forge->addKey('name');
+ $this->forge->addKey('category_id');
+ $this->forge->createTable('hsm_items', true);
+ }
+
+ private function createIndentsTable(): void
+ {
+ if ($this->hasTable('hsm_indents')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'indent_code' => ['type' => 'VARCHAR', 'constraint' => 40],
+ 'department_name' => ['type' => 'VARCHAR', 'constraint' => 120],
+ 'requested_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'approved_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'approved_at' => ['type' => 'DATETIME', 'null' => true],
+ 'remarks' => ['type' => 'TEXT', 'null' => true],
+ 'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'approved', 'partial_issued', 'issued', 'rejected', 'cancelled'], 'default' => 'pending'],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('indent_code');
+ $this->forge->addKey('status');
+ $this->forge->createTable('hsm_indents', true);
+ }
+
+ private function createIndentItemsTable(): void
+ {
+ if ($this->hasTable('hsm_indent_items')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'indent_id' => ['type' => 'INT', 'unsigned' => true],
+ 'item_id' => ['type' => 'INT', 'unsigned' => true],
+ 'requested_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'approved_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'issued_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('indent_id');
+ $this->forge->addKey('item_id');
+ $this->forge->createTable('hsm_indent_items', true);
+ }
+
+ private function createIssuesTable(): void
+ {
+ if ($this->hasTable('hsm_issues')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'issue_code' => ['type' => 'VARCHAR', 'constraint' => 40],
+ 'indent_id' => ['type' => 'INT', 'unsigned' => true],
+ 'issued_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'department_name' => ['type' => 'VARCHAR', 'constraint' => 120],
+ 'remarks' => ['type' => 'TEXT', 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('issue_code');
+ $this->forge->addKey('indent_id');
+ $this->forge->createTable('hsm_issues', true);
+ }
+
+ private function createIssueItemsTable(): void
+ {
+ if ($this->hasTable('hsm_issue_items')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'issue_id' => ['type' => 'INT', 'unsigned' => true],
+ 'item_id' => ['type' => 'INT', 'unsigned' => true],
+ 'qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('issue_id');
+ $this->forge->addKey('item_id');
+ $this->forge->createTable('hsm_issue_items', true);
+ }
+
+ private function createPurchaseOrdersTable(): void
+ {
+ if ($this->hasTable('hsm_purchase_orders')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'po_code' => ['type' => 'VARCHAR', 'constraint' => 40],
+ 'supplier_id' => ['type' => 'INT', 'unsigned' => true],
+ 'order_date' => ['type' => 'DATE', 'null' => true],
+ 'expected_date' => ['type' => 'DATE', 'null' => true],
+ 'ordered_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'status' => ['type' => 'ENUM', 'constraint' => ['draft', 'ordered', 'partial_received', 'completed', 'cancelled'], 'default' => 'ordered'],
+ 'remarks' => ['type' => 'TEXT', 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('po_code');
+ $this->forge->addKey('supplier_id');
+ $this->forge->addKey('status');
+ $this->forge->createTable('hsm_purchase_orders', true);
+ }
+
+ private function createPurchaseOrderItemsTable(): void
+ {
+ if ($this->hasTable('hsm_purchase_order_items')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'purchase_order_id' => ['type' => 'INT', 'unsigned' => true],
+ 'item_id' => ['type' => 'INT', 'unsigned' => true],
+ 'ordered_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'received_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'unit_cost' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'expiry_date' => ['type' => 'DATE', 'null' => true],
+ 'batch_no' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('purchase_order_id');
+ $this->forge->addKey('item_id');
+ $this->forge->createTable('hsm_purchase_order_items', true);
+ }
+
+ private function createStockLedgerTable(): void
+ {
+ if ($this->hasTable('hsm_stock_ledger')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'item_id' => ['type' => 'INT', 'unsigned' => true],
+ 'txn_type' => ['type' => 'ENUM', 'constraint' => ['purchase', 'issue', 'adjustment_in', 'adjustment_out'], 'default' => 'adjustment_in'],
+ 'ref_table' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
+ 'ref_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'qty_in' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'qty_out' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'balance_after' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0],
+ 'remarks' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
+ 'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('item_id');
+ $this->forge->addKey('txn_type');
+ $this->forge->addKey('created_at');
+ $this->forge->createTable('hsm_stock_ledger', true);
+ }
+
+ private function createAuditLogTable(): void
+ {
+ if ($this->hasTable('hsm_audit_log')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'module' => ['type' => 'VARCHAR', 'constraint' => 60],
+ 'action' => ['type' => 'VARCHAR', 'constraint' => 60],
+ 'entity_table' => ['type' => 'VARCHAR', 'constraint' => 60],
+ 'entity_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'description' => ['type' => 'TEXT', 'null' => true],
+ 'meta_json' => ['type' => 'LONGTEXT', 'null' => true],
+ 'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('module');
+ $this->forge->addKey('entity_table');
+ $this->forge->createTable('hsm_audit_log', true);
+ }
+
+ private function hasTable(string $table): bool
+ {
+ $query = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table));
+
+ return $query->getNumRows() > 0;
+ }
+}
\ No newline at end of file
diff --git a/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php b/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php
new file mode 100644
index 0000000..0afd003
--- /dev/null
+++ b/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php
@@ -0,0 +1,69 @@
+db->tableExists('hsm_items')) {
+ return;
+ }
+
+ $columns = $this->getColumns('hsm_items');
+ $fields = [];
+
+ if (! in_array('item_type', $columns, true)) {
+ $fields['item_type'] = ['type' => 'VARCHAR', 'constraint' => 60, 'null' => true, 'after' => 'category_id'];
+ }
+ if (! in_array('purchase_uom', $columns, true)) {
+ $fields['purchase_uom'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'after' => 'uom'];
+ }
+ if (! in_array('issue_uom', $columns, true)) {
+ $fields['issue_uom'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'after' => 'purchase_uom'];
+ }
+ if (! in_array('issue_per_purchase', $columns, true)) {
+ $fields['issue_per_purchase'] = ['type' => 'DECIMAL', 'constraint' => '14,4', 'default' => 1, 'after' => 'issue_uom'];
+ }
+ if (! in_array('is_daily_use', $columns, true)) {
+ $fields['is_daily_use'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'issue_per_purchase'];
+ }
+ if (! in_array('store_location', $columns, true)) {
+ $fields['store_location'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true, 'after' => 'is_daily_use'];
+ }
+
+ if ($fields !== []) {
+ $this->forge->addColumn('hsm_items', $fields);
+ }
+
+ // Backfill sensible defaults for unit conversion.
+ $this->db->query("UPDATE hsm_items SET purchase_uom = IFNULL(NULLIF(purchase_uom,''), IFNULL(NULLIF(uom,''), 'Unit'))");
+ $this->db->query("UPDATE hsm_items SET issue_uom = IFNULL(NULLIF(issue_uom,''), IFNULL(NULLIF(uom,''), 'Unit'))");
+ $this->db->query("UPDATE hsm_items SET issue_per_purchase = IFNULL(NULLIF(issue_per_purchase,0), 1)");
+ }
+
+ public function down()
+ {
+ if (! $this->db->tableExists('hsm_items')) {
+ return;
+ }
+
+ foreach (['store_location', 'is_daily_use', 'issue_per_purchase', 'issue_uom', 'purchase_uom', 'item_type'] as $column) {
+ if (in_array($column, $this->getColumns('hsm_items'), true)) {
+ $this->forge->dropColumn('hsm_items', $column);
+ }
+ }
+ }
+
+ /**
+ * @return string[]
+ */
+ private function getColumns(string $table): array
+ {
+ $result = $this->db->query('SHOW COLUMNS FROM ' . $this->db->protectIdentifiers($table))->getResultArray();
+
+ return array_map(static fn (array $row): string => (string) ($row['Field'] ?? ''), $result);
+ }
+}
diff --git a/app/Models/HospitalStockCategoryModel.php b/app/Models/HospitalStockCategoryModel.php
new file mode 100644
index 0000000..5b50926
--- /dev/null
+++ b/app/Models/HospitalStockCategoryModel.php
@@ -0,0 +1,14 @@
+Bed Management
+
diff --git a/app/Views/Setting/Stock/dashboard.php b/app/Views/Setting/Stock/dashboard.php
new file mode 100644
index 0000000..4d2a9e4
--- /dev/null
+++ b/app/Views/Setting/Stock/dashboard.php
@@ -0,0 +1,98 @@
+
+
+
+
+
Items
= (int) ($stats['total_items'] ?? 0) ?>
+
Low Stock
= (int) ($stats['low_stock'] ?? 0) ?>
+
Pending Indents
= (int) ($stats['pending_indents'] ?? 0) ?>
+
Open PO
= (int) ($stats['open_po'] ?? 0) ?>
+
Stock Value
₹= number_format((float) ($stats['stock_value'] ?? 0), 2) ?>
+
+
+
+
+
New Request (Pending Indent)
+
+
+ Indent Department Date
+
+
+
+ = esc((string) ($r['indent_code'] ?? '')) ?>
+ = esc((string) ($r['department_name'] ?? '')) ?>
+ = esc((string) ($r['created_at'] ?? '')) ?>
+
+
+
+
+
+
+
+
Regularly Used Items Near Expiry
+
+
+ Item Used Qty Stock Expiry
+
+
+
+ = esc((string) ($r['item_code'] ?? '')) ?> - = esc((string) ($r['name'] ?? '')) ?>
+ = esc((string) ($r['used_qty'] ?? '0')) ?>
+ = esc((string) ($r['current_stock'] ?? '0')) ?>
+ = esc((string) ($r['expiry_date'] ?? '')) ?>
+
+
+
+
+
+
+
+
Short Item with No Replenishment Request
+
+
+ Item Stock Reorder
+
+
+
+ = esc((string) ($r['item_code'] ?? '')) ?> - = esc((string) ($r['name'] ?? '')) ?>
+ = esc((string) ($r['current_stock'] ?? '0')) ?>
+ = esc((string) ($r['reorder_level'] ?? '0')) ?>
+
+
+
+
+
+
+
+
Daily Use Item Status
+
+
+ Item Today 7d Use Stock
+
+
+
+ = esc((string) ($r['item_code'] ?? '')) ?> - = esc((string) ($r['name'] ?? '')) ?>
+ = esc((string) ($r['issued_today'] ?? '0')) ?>
+ = esc((string) ($r['issued_7d'] ?? '0')) ?>
+ = esc((string) ($r['current_stock'] ?? '0')) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/Setting/Stock/indents.php b/app/Views/Setting/Stock/indents.php
new file mode 100644
index 0000000..6990d37
--- /dev/null
+++ b/app/Views/Setting/Stock/indents.php
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+ Select item
+
+ = esc((string) (($it['item_code'] ?? '') . ' - ' . ($it['name'] ?? ''))) ?>
+
+
+
+
+
+
Add
+
+
+
+
+
Requests
+
+ Req No Date Department Status Requested By Action
+
+
+
+ = esc((string) ($in['indent_code'] ?? '')) ?>
+ = esc((string) ($in['created_at'] ?? '')) ?>
+ = esc((string) ($in['department_name'] ?? '')) ?>
+ = esc((string) ($in['status'] ?? 'pending')) ?>
+ = esc((string) ($in['requested_by'] ?? '')) ?>
+
+
+ Approve
+
+
+ Issue
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/Setting/Stock/index.php b/app/Views/Setting/Stock/index.php
new file mode 100644
index 0000000..745a810
--- /dev/null
+++ b/app/Views/Setting/Stock/index.php
@@ -0,0 +1,84 @@
+
+
+
Hospital Stock Management
+
+
+ Dashboard
+ Setting
+ Hospital Stock
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/Views/Setting/Stock/masters.php b/app/Views/Setting/Stock/masters.php
new file mode 100644
index 0000000..da19c64
--- /dev/null
+++ b/app/Views/Setting/Stock/masters.php
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
Category
+
+
+
+ Category Status Action
+
+
+
+ = esc((string) ($cat['name'] ?? '')) ?>
+ = esc((string) ($cat['status'] ?? 'active')) ?>
+
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
Item
+
+ = csrf_field() ?>
+
+
+
+
+
+
+ Code Name Type UOM Stock Action
+
+
+
+ = esc((string) ($it['item_code'] ?? '')) ?>
+ = esc((string) ($it['name'] ?? '')) ?>
+ = esc((string) ($it['item_type'] ?? '')) ?>
+ = esc((string) ($it['issue_uom'] ?? ($it['uom'] ?? ''))) ?>
+ = esc((string) ($it['current_stock'] ?? '0')) ?>
+
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/Setting/Stock/purchase.php b/app/Views/Setting/Stock/purchase.php
new file mode 100644
index 0000000..d237457
--- /dev/null
+++ b/app/Views/Setting/Stock/purchase.php
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+ = csrf_field() ?>
+
+
+ Supplier
+
+ = esc((string) ($sp['name'] ?? '')) ?>
+
+
+
+
+
+
+ Create PO
+
+
+
+
+
+ Select item
+
+ = esc((string) (($it['item_code'] ?? '') . ' - ' . ($it['name'] ?? ''))) ?>
+
+
+
+
+
+
+
Add
+
+
+
+
+
Purchase Orders
+
+ PO No Date Supplier Status Total Action
+
+
+
+ = esc((string) ($po['po_code'] ?? '')) ?>
+ = esc((string) ($po['order_date'] ?? '')) ?>
+ = esc((string) ($po['supplier_name'] ?? '')) ?>
+ = esc((string) ($po['status'] ?? 'ordered')) ?>
+ -
+
+
+ Receive
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/Setting/Stock/report_print.php b/app/Views/Setting/Stock/report_print.php
new file mode 100644
index 0000000..f4f270c
--- /dev/null
+++ b/app/Views/Setting/Stock/report_print.php
@@ -0,0 +1,56 @@
+
+
+
+
+
= esc((string) ($title ?? 'Report')) ?>
+
+
+
+
= esc((string) ($title ?? 'Report')) ?>
+
= esc((string) ($subtitle ?? '')) ?>
+
+
+
+
+
+
+ = esc((string) $col) ?>
+
+
+
+
+
+
+
+ = esc((string) $cell) ?>
+
+
+
+
+
+
+
No records found for selected criteria.
+
+
+
Generated at = esc(date('Y-m-d H:i:s')) ?> | Use browser print command.
+
+
+
+
diff --git a/app/Views/Setting/Stock/reports.php b/app/Views/Setting/Stock/reports.php
new file mode 100644
index 0000000..dbb5919
--- /dev/null
+++ b/app/Views/Setting/Stock/reports.php
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+ Reports open in print-ready format in a new tab.
+
+
+
Current Alert Items (Low / Reorder)
+
+
+
+ Item Code
+ Item Name
+ Current Stock
+ Min Stock
+ Reorder Level
+ Expiry Date
+
+
+
+
+
+ = esc((string) ($r['item_code'] ?? '')) ?>
+ = esc((string) ($r['name'] ?? '')) ?>
+ = esc((string) ($r['current_stock'] ?? '0')) ?>
+ = esc((string) ($r['min_stock_level'] ?? '0')) ?>
+ = esc((string) ($r['reorder_level'] ?? '0')) ?>
+ = esc((string) ($r['expiry_date'] ?? '')) ?>
+
+
+
+
+
+
+
+
diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php
index c42ba83..a9e4f4a 100644
--- a/app/Views/partials/sidebar.php
+++ b/app/Views/partials/sidebar.php
@@ -113,6 +113,12 @@ if (! $canDiagnosis && $user && method_exists($user, 'inGroup')) {
Bed Status
+
+
+
+ Hospital Stock
+
+
Billing