From fbbecee3b47ebd15c5a69819f1ed9058a0d7d801 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Sat, 18 Apr 2026 06:56:38 +0530 Subject: [PATCH] feat(ipd-charges): group delete, sort order, Up/Down buttons, foldable billing panels - Safe delete for empty IPD charge groups with CSRF-safe AJAX - Migration: add sort_order to ipd_item_type (backfilled) - Sort order UI on create/edit/list views; persist in controller/model - Up/Down swap buttons on group list (AJAX swap endpoint + CSRF refresh) - Fix 500 on edit screen: restore missing getItemTypeById() in ItemIpdModel - IpdBillingModel: propagate sort_order ordering to ipd-charges and bill-details tabs - Billing IPD Charges tab: Bedside Clinical/Nursing Charge and Doctor Visit Charge panels now collapsible, start collapsed --- app/Config/Routes.php | 3 + app/Controllers/ItemIpd.php | 67 +++++ ...04-18-000043_AddSortOrderToIpdItemType.php | 58 ++++ app/Models/IpdBillingModel.php | 35 ++- app/Models/ItemIpdModel.php | 118 +++++++- .../Charges/IPD_Charges/item_type_create.php | 4 + .../Charges/IPD_Charges/item_type_profile.php | 4 + .../IPD_Charges/item_type_search_v.php | 91 +++++- app/Views/billing/ipd/panel_ipd_charges.php | 266 ++++++++++-------- 9 files changed, 513 insertions(+), 133 deletions(-) create mode 100644 app/Database/Migrations/2026-04-18-000043_AddSortOrderToIpdItemType.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4777cc0..912ebd8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1071,6 +1071,8 @@ $routes->group('item-ipd', static function($routes) { $routes->post('update', 'ItemIpd::updateRecord'); $routes->post('create-type', 'ItemIpd::createItemTypeRecord'); $routes->post('update-type', 'ItemIpd::updateItemTypeRecord'); + $routes->post('delete-type', 'ItemIpd::deleteItemTypeRecord'); + $routes->post('change-sort-type', 'ItemIpd::changeItemTypeSort'); $routes->post('insurance/add', 'ItemIpd::addInsuranceItemRecord'); $routes->post('insurance/remove', 'ItemIpd::removeInsuranceItem'); }); @@ -1143,6 +1145,7 @@ $routes->group('Item_IPD', static function($routes) { $routes->post('UpdateRecord', 'ItemIpd::updateRecord'); $routes->post('CreateItemTypeRecord', 'ItemIpd::createItemTypeRecord'); $routes->post('UpdateItemTypeRecord', 'ItemIpd::updateItemTypeRecord'); + $routes->post('change_sort_itemtype', 'ItemIpd::changeItemTypeSort'); $routes->post('AddInsuranceItemRecord', 'ItemIpd::addInsuranceItemRecord'); $routes->post('remove_record_item', 'ItemIpd::removeInsuranceItem'); }); diff --git a/app/Controllers/ItemIpd.php b/app/Controllers/ItemIpd.php index b37c237..2e66321 100644 --- a/app/Controllers/ItemIpd.php +++ b/app/Controllers/ItemIpd.php @@ -7,12 +7,20 @@ use App\Models\ItemIpdModel; class ItemIpd extends BaseController { + private function getPostedItemTypeSortOrder(): int + { + $sortOrder = $this->request->getPost('sort_order'); + + return max(0, (int) $sortOrder); + } + public function searchItemType(): string { $model = new ItemIpdModel(); return view('Setting/Charges/IPD_Charges/item_type_search_v', [ 'data' => $model->getItemTypes(), + 'supportsSortOrder' => $model->supportsItemTypeSortOrder(), ]); } @@ -222,6 +230,25 @@ class ItemIpd extends BaseController ]); } + public function deleteItemTypeRecord() + { + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['deleted' => 0, 'error_text' => 'Invalid request']); + } + + $model = new ItemIpdModel(); + $id = (int) $this->request->getPost('itype_id'); + + $deleted = $model->deleteItemType($id); + + return $this->response->setJSON([ + 'deleted' => $deleted ? 1 : 0, + 'error_text' => $deleted ? '' : 'Cannot delete: group has charges or does not exist.', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + public function createItemTypeRecord() { if (! $this->request->isAJAX()) { @@ -233,6 +260,10 @@ class ItemIpd extends BaseController 'group_desc' => $this->request->getPost('input_Item_type'), ]; + if ($model->supportsItemTypeSortOrder()) { + $data['sort_order'] = $this->getPostedItemTypeSortOrder(); + } + $insertId = $model->insertItemType($data); return $this->response->setJSON([ @@ -256,6 +287,10 @@ class ItemIpd extends BaseController 'group_desc' => $this->request->getPost('input_Item_type'), ]; + if ($model->supportsItemTypeSortOrder()) { + $data['sort_order'] = $this->getPostedItemTypeSortOrder(); + } + $updated = $model->updateItemType($data, $itemTypeId); return $this->response->setJSON([ @@ -267,4 +302,36 @@ class ItemIpd extends BaseController ]); } + public function changeItemTypeSort() + { + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['moved' => 0, 'error_text' => 'Invalid request']); + } + + $model = new ItemIpdModel(); + + if (! $model->supportsItemTypeSortOrder()) { + return $this->response->setJSON([ + 'moved' => 0, + 'error_text' => 'Sort order is not available in this environment.', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + $currentId = (int) $this->request->getPost('current_id'); + $currentSort = max(0, (int) $this->request->getPost('current_sort')); + $targetId = (int) $this->request->getPost('target_id'); + $targetSort = max(0, (int) $this->request->getPost('target_sort')); + + $moved = $model->swapItemTypeSortOrder($currentId, $currentSort, $targetId, $targetSort); + + return $this->response->setJSON([ + 'moved' => $moved ? 1 : 0, + 'error_text' => $moved ? '' : 'Could not update sort order.', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + } diff --git a/app/Database/Migrations/2026-04-18-000043_AddSortOrderToIpdItemType.php b/app/Database/Migrations/2026-04-18-000043_AddSortOrderToIpdItemType.php new file mode 100644 index 0000000..4565351 --- /dev/null +++ b/app/Database/Migrations/2026-04-18-000043_AddSortOrderToIpdItemType.php @@ -0,0 +1,58 @@ +db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getNumRows() > 0; + } + + private function fieldExists(string $table, string $field): bool + { + return $this->db->query('SHOW COLUMNS FROM `' . $table . '` LIKE ' . $this->db->escape($field))->getNumRows() > 0; + } + + public function up() + { + if (! $this->tableExists('ipd_item_type')) { + return; + } + + if (! $this->fieldExists('ipd_item_type', 'sort_order')) { + $this->forge->addColumn('ipd_item_type', [ + 'sort_order' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + 'null' => false, + 'after' => 'group_desc', + ], + ]); + } + + $rows = $this->db->table('ipd_item_type') + ->select('itype_id') + ->orderBy('group_desc', 'ASC') + ->get() + ->getResult(); + + $sortOrder = 1; + foreach ($rows as $row) { + $this->db->table('ipd_item_type') + ->where('itype_id', (int) ($row->itype_id ?? 0)) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + } + + public function down() + { + if ($this->tableExists('ipd_item_type') && $this->fieldExists('ipd_item_type', 'sort_order')) { + $this->forge->dropColumn('ipd_item_type', 'sort_order'); + } + } +} \ No newline at end of file diff --git a/app/Models/IpdBillingModel.php b/app/Models/IpdBillingModel.php index 9bbd47a..7ea635f 100644 --- a/app/Models/IpdBillingModel.php +++ b/app/Models/IpdBillingModel.php @@ -8,6 +8,17 @@ class IpdBillingModel extends Model { protected $table = 'v_ipd_list'; + private ?bool $ipdItemTypeHasSortOrder = null; + + private function ipdItemTypeHasSortOrder(): bool + { + if ($this->ipdItemTypeHasSortOrder === null) { + $this->ipdItemTypeHasSortOrder = $this->db->fieldExists('sort_order', 'ipd_item_type'); + } + + return $this->ipdItemTypeHasSortOrder; + } + public function getCurrentAdmissions(): array { $builder = $this->baseIpdListQuery(); @@ -172,10 +183,16 @@ class IpdBillingModel extends Model public function getIpdCharges(int $ipdId): array { - return $this->db->table('ipd_invoice_item i') + $builder = $this->db->table('ipd_invoice_item i') ->select('i.*, t.group_desc') ->join('ipd_item_type t', 'i.item_type = t.itype_id', 'left') - ->where('i.ipd_id', $ipdId) + ->where('i.ipd_id', $ipdId); + + if ($this->ipdItemTypeHasSortOrder()) { + $builder->orderBy('COALESCE(t.sort_order, 0)', 'ASC', false); + } + + return $builder ->orderBy('i.id', 'DESC') ->get() ->getResult(); @@ -183,11 +200,17 @@ class IpdBillingModel extends Model public function getIpdChargesGrouped(int $ipdId): array { - return $this->db->table('ipd_invoice_item i') + $builder = $this->db->table('ipd_invoice_item i') ->select('t.group_desc, i.*, sum(i.item_amount) as xAmount') ->join('ipd_item_type t', 'i.item_type = t.itype_id', 'left') ->where('i.ipd_id', $ipdId) - ->groupBy('i.item_type,i.id') + ->groupBy('i.item_type,i.id'); + + if ($this->ipdItemTypeHasSortOrder()) { + $builder->orderBy('COALESCE(t.sort_order, 0)', 'ASC', false); + } + + return $builder ->orderBy('i.item_type', 'ASC') ->orderBy('i.id', 'ASC') ->get() @@ -365,6 +388,10 @@ class IpdBillingModel extends Model $builder->where('i.package_id', 0); } + if ($this->ipdItemTypeHasSortOrder()) { + $builder->orderBy('COALESCE(t.sort_order, 0)', 'ASC', false); + } + return $builder ->orderBy('t.group_desc', 'ASC') ->orderBy('i.id', 'ASC') diff --git a/app/Models/ItemIpdModel.php b/app/Models/ItemIpdModel.php index 7c92219..9836582 100644 --- a/app/Models/ItemIpdModel.php +++ b/app/Models/ItemIpdModel.php @@ -7,34 +7,89 @@ use Config\Database; class ItemIpdModel extends Model { + /** + * @var array + */ + private array $fieldExistsCache = []; + public function __construct() { parent::__construct(); $this->db = Database::connect(); } + public function supportsItemTypeSortOrder(): bool + { + return $this->fieldExists('ipd_item_type', 'sort_order'); + } + + private function fieldExists(string $table, string $field): bool + { + $cacheKey = $table . '.' . $field; + + if (! array_key_exists($cacheKey, $this->fieldExistsCache)) { + $this->fieldExistsCache[$cacheKey] = $this->db->fieldExists($field, $table); + } + + return $this->fieldExistsCache[$cacheKey]; + } + + private function applyItemTypeOrdering($builder, string $alias = '') + { + $prefix = $alias !== '' ? $alias . '.' : ''; + + if ($this->supportsItemTypeSortOrder()) { + $builder->orderBy('COALESCE(' . $prefix . 'sort_order, 0)', 'ASC', false); + } + + return $builder->orderBy($prefix . 'group_desc', 'ASC'); + } + + private function filterItemTypeData(array $data): array + { + if (! $this->supportsItemTypeSortOrder()) { + unset($data['sort_order']); + } + + return $data; + } + public function getItemTypes(): array { - return $this->db->table('ipd_item_type') - ->orderBy('group_desc', 'ASC') + $select = ['t.itype_id', 't.group_desc', 'COUNT(m.id) AS item_count']; + $groupBy = ['t.itype_id', 't.group_desc']; + + if ($this->supportsItemTypeSortOrder()) { + $select[] = 'COALESCE(t.sort_order, 0) AS sort_order'; + $groupBy[] = 't.sort_order'; + } + + $builder = $this->db->table('ipd_item_type t') + ->select(implode(', ', $select), false) + ->join('ipd_items m', 'm.itype = t.itype_id', 'left') + ->groupBy(implode(', ', $groupBy), false); + + return $this->applyItemTypeOrdering($builder, 't') ->get() ->getResult(); } public function getItemTypesList(): array { - return $this->db->table('ipd_item_type') - ->orderBy('group_desc', 'ASC') + $builder = $this->db->table('ipd_item_type'); + + return $this->applyItemTypeOrdering($builder) ->get() ->getResult(); } public function getItemTypeById(int $id): array { - return $this->db->table('ipd_item_type') - ->where('itype_id', $id) - ->get() - ->getResult(); + $builder = $this->db->table('ipd_item_type'); + $builder->where('itype_id', $id); + $this->applyItemTypeOrdering($builder); + + return $builder->get()->getResult(); } public function getItemsByType(int $typeId): array @@ -68,7 +123,7 @@ class ItemIpdModel extends Model 't.group_desc', ]); $builder->join('ipd_item_type t', 'm.itype = t.itype_id', 'inner'); - $builder->orderBy('t.group_desc', 'ASC'); + $this->applyItemTypeOrdering($builder, 't'); return $builder->get()->getResult(); } @@ -171,7 +226,7 @@ class ItemIpdModel extends Model public function insertItemType(array $data): int { - $this->db->table('ipd_item_type')->insert($data); + $this->db->table('ipd_item_type')->insert($this->filterItemTypeData($data)); return (int) $this->db->insertID(); } @@ -180,7 +235,48 @@ class ItemIpdModel extends Model { return (bool) $this->db->table('ipd_item_type') ->where('itype_id', $id) - ->update($data); + ->update($this->filterItemTypeData($data)); + } + + public function swapItemTypeSortOrder(int $currentId, int $currentSort, int $targetId, int $targetSort): bool + { + if (! $this->supportsItemTypeSortOrder() || $currentId <= 0 || $targetId <= 0 || $currentId === $targetId) { + return false; + } + + $this->db->transStart(); + + $this->db->table('ipd_item_type') + ->where('itype_id', $currentId) + ->update(['sort_order' => 0]); + + $this->db->table('ipd_item_type') + ->where('itype_id', $targetId) + ->update(['sort_order' => $currentSort]); + + $this->db->table('ipd_item_type') + ->where('itype_id', $currentId) + ->update(['sort_order' => $targetSort]); + + $this->db->transComplete(); + + return $this->db->transStatus(); + } + + public function deleteItemType(int $id): bool + { + // Only delete if no items are linked + $count = (int) $this->db->table('ipd_items') + ->where('itype', $id) + ->countAllResults(); + + if ($count > 0) { + return false; + } + + return (bool) $this->db->table('ipd_item_type') + ->where('itype_id', $id) + ->delete(); } public function insertItemInsurance(array $data): int diff --git a/app/Views/Setting/Charges/IPD_Charges/item_type_create.php b/app/Views/Setting/Charges/IPD_Charges/item_type_create.php index f4c339b..83c6aa4 100644 --- a/app/Views/Setting/Charges/IPD_Charges/item_type_create.php +++ b/app/Views/Setting/Charges/IPD_Charges/item_type_create.php @@ -17,6 +17,10 @@ +
+ + +
diff --git a/app/Views/Setting/Charges/IPD_Charges/item_type_profile.php b/app/Views/Setting/Charges/IPD_Charges/item_type_profile.php index 3b99b98..2678a83 100644 --- a/app/Views/Setting/Charges/IPD_Charges/item_type_profile.php +++ b/app/Views/Setting/Charges/IPD_Charges/item_type_profile.php @@ -19,6 +19,10 @@ +
+ + +
diff --git a/app/Views/Setting/Charges/IPD_Charges/item_type_search_v.php b/app/Views/Setting/Charges/IPD_Charges/item_type_search_v.php index f1d5778..4373b02 100644 --- a/app/Views/Setting/Charges/IPD_Charges/item_type_search_v.php +++ b/app/Views/Setting/Charges/IPD_Charges/item_type_search_v.php @@ -7,19 +7,39 @@
+ + - + + - + $row) : + $currentId = (int) ($row->itype_id ?? 0); + $currentSort = (int) ($row->sort_order ?? 0); + $prevRow = $rows[$index - 1] ?? null; + $nextRow = $rows[$index + 1] ?? null; + ?> + @@ -27,7 +47,8 @@ - + +
Charges Group NameSort OrderAction
group_desc ?? '') ?>sort_order ?? 0))) ?> + item_count ?? 0) === 0): ?> + + + + + + + +
Charges Group NameSort OrderAction
@@ -43,4 +64,68 @@ new simpleDatatables.DataTable(table); })(); + + function deleteItemTypeGroup(itypeId, btn) { + if (!confirm('Delete this empty charges group? This cannot be undone.')) { + return; + } + btn.disabled = true; + var csrfName = $('#csrf_name').val(); + var csrfVal = $('#csrf_val').val(); + var payload = {itype_id: itypeId}; + payload[csrfName] = csrfVal; + + $.post('', payload, function(res) { + if (res.deleted) { + $(btn).closest('tr').remove(); + } else { + alert(res.error_text || 'Could not delete group.'); + btn.disabled = false; + } + if (res.csrfName && res.csrfHash) { + $('#csrf_name').val(res.csrfName); + $('#csrf_val').val(res.csrfHash); + } + }, 'json').fail(function() { + alert('Request failed.'); + btn.disabled = false; + }); + } + + function moveItemTypeGroup(currentId, currentSort, targetId, targetSort, btn) { + btn.disabled = true; + + var csrfName = $('#csrf_name').val(); + var csrfVal = $('#csrf_val').val(); + var payload = { + current_id: currentId, + current_sort: currentSort, + target_id: targetId, + target_sort: targetSort + }; + + payload[csrfName] = csrfVal; + + $.post('', payload, function(res) { + if (res.csrfName && res.csrfHash) { + $('#csrf_name').val(res.csrfName); + $('#csrf_val').val(res.csrfHash); + } + + if (!res.moved) { + alert(res.error_text || 'Could not update sort order.'); + btn.disabled = false; + return; + } + + if (typeof load_form_div === 'function') { + load_form_div('', 'maindiv', 'IPD Charge Master'); + } else { + window.location.reload(); + } + }, 'json').fail(function() { + alert('Request failed.'); + btn.disabled = false; + }); + } diff --git a/app/Views/billing/ipd/panel_ipd_charges.php b/app/Views/billing/ipd/panel_ipd_charges.php index a94380d..317084f 100644 --- a/app/Views/billing/ipd/panel_ipd_charges.php +++ b/app/Views/billing/ipd/panel_ipd_charges.php @@ -32,6 +32,28 @@ $doctorVisitFeeMap = $doctor_visit_fee_map ?? []; .ipd-charge-accordion .accordion-body { background: #f8fafc; } + .ipd-charge-section-toggle { + width: 100%; + border: 0; + background: transparent; + padding: 0; + text-align: left; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; + color: inherit; + } + .ipd-charge-section-toggle:focus { + outline: none; + box-shadow: none; + } + .ipd-charge-section-toggle .bi { + transition: transform 0.2s ease; + } + .ipd-charge-section-toggle.collapsed .bi { + transform: rotate(-90deg); + }
@@ -158,128 +180,142 @@ $doctorVisitFeeMap = $doctor_visit_fee_map ?? [];
-
Bedside Clinical / Nursing Charge
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
-
Doctor Visit Charge
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
-
-
- - -
-
-