clqms-be/app/Views/v2/master/valuesets/valuesets_index.php
mahdahar e36e390f71 refactor: consolidate ValueSet API and simplify seeders
- Consolidate ValueSet routes from multiple nested groups to flat structure
- Delete deprecated ValueSet\ namespaced controllers (ValueSetController, ValueSetDefController)
- Remove ValueSetSeeder and ValueSetCountrySeeder from DBSeeder
- Simplify seeders (LocationSeeder, OrganizationSeeder, PatientSeeder, TestSeeder)
  to use literal string values instead of ValueSet lookups
- Add new ValueSetController and ValueSetDefController in root namespace
- Update test files for new controller structure
The key changes are:
1. Routes: Consolidated from nested ValueSet\ namespace routes to flat ValueSetController routes with /items sub-endpoints
2. Controllers: Deleted old app/Controllers/ValueSet/ directory, created new root-level controllers
3. Seeders: Removed ValueSet dependencies, using literal values like 'ROOM', '1', 'TEST' instead of [12]['ROOM'] etc.
4. Tests: Updated tests to match new controller structure
2026-01-13 16:48:43 +07:00

679 lines
23 KiB
PHP

<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetManager()" x-init="init()">
<!-- Page Header -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-layer-group text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Value Set Manager</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage value set categories and their items</p>
</div>
</div>
</div>
<!-- Two Column Layout with Independent Scrolling -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- LEFT PANEL: ValueSetDef List -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Left Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-layer-group text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Categories</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">Value Set Definitions</p>
</div>
</div>
<button class="btn btn-primary btn-sm" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add
</button>
</div>
<!-- Search Bar -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm w-full input-with-icon"
x-model="defKeyword"
@keyup.enter="fetchDefs()"
/>
</div>
</div>
<!-- Loading State -->
<div x-show="defLoading" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading categories...</p>
</div>
<!-- Def List Table -->
<div class="overflow-y-auto flex-1" x-show="!defLoading" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!defList || defList.length === 0">
<tr>
<td colspan="4" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-folder-open text-4xl opacity-40"></i>
<p class="text-sm">No categories found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showDefForm()">
<i class="fa-solid fa-plus mr-1"></i> Add Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in defList" :key="def.VSetID">
<tr
class="hover:bg-opacity-50 cursor-pointer transition-colors"
:class="selectedDef?.VSetID === def.VSetID ? 'bg-primary/10' : ''"
@click="selectDef(def)"
>
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="def.VSetID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="def.VSName || '-'"></div>
<div class="text-xs opacity-50" x-text="def.VSDesc || ''"></div>
</td>
<td class="text-center">
<span class="badge badge-sm" x-text="(def.ItemCount || 0) + ' items'"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1" @click.stop>
<button class="btn btn-ghost btn-sm btn-square" @click="editDef(def.VSetID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteDef(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Left Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="defList && defList.length > 0">
<span style="color: rgb(var(--color-text-muted));" x-text="defList.length + ' categories'"></span>
</div>
</div>
<!-- RIGHT PANEL: ValueSet Items -->
<div class="card overflow-hidden flex flex-col" style="height: calc(100vh - 280px); min-height: 400px;">
<!-- Right Panel Header -->
<div class="p-4 border-b flex items-center justify-between" style="border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background: rgb(var(--color-secondary));">
<i class="fa-solid fa-list-ul text-white"></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));">Items</h3>
<p class="text-xs" style="color: rgb(var(--color-text-muted));">
<template x-if="selectedDef">
<span x-text="selectedDef.VSName + ' Items'"></span>
</template>
<template x-if="!selectedDef">
<span>Select a category to view items</span>
</template>
</p>
</div>
</div>
<button
class="btn btn-primary btn-sm"
@click="showValueForm()"
:disabled="!selectedDef"
>
<i class="fa-solid fa-plus mr-1"></i> Add Item
</button>
</div>
<!-- Search Bar (Right Panel) -->
<div class="p-3 border-b" style="border-color: rgb(var(--color-border));" x-show="selectedDef">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 opacity-50 text-gray-400" style="z-index: 10;"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full input-with-icon"
x-model="valueKeyword"
@keyup.enter="fetchValues()"
/>
</div>
</div>
<!-- Empty State - No Selection -->
<div x-show="!selectedDef" class="p-16 text-center" x-cloak>
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-hand-pointer text-5xl opacity-30"></i>
<p class="text-lg font-medium">Select a category</p>
<p class="text-sm opacity-60">Click on a category from the left panel to view and manage its items</p>
</div>
</div>
<!-- Loading State -->
<div x-show="valueLoading && selectedDef" class="p-12 text-center" x-cloak>
<div class="spinner spinner-lg mx-auto mb-4"></div>
<p style="color: rgb(var(--color-text-muted));">Loading items...</p>
</div>
<!-- Value List Table -->
<div class="overflow-y-auto flex-1" x-show="!valueLoading && selectedDef" x-cloak>
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Value</th>
<th>Description</th>
<th class="w-16 text-center">Order</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!valueList || valueList.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-2" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl opacity-40"></i>
<p class="text-sm">No items found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showValueForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<template x-for="value in valueList" :key="value.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="value.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="value.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="value.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="value.VOrder || 0"></span>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(value.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDeleteValue(value)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Right Panel Footer -->
<div class="p-3 flex items-center justify-between text-xs" style="border-top: 1px solid rgb(var(--color-border));" x-show="valueList && valueList.length > 0 && selectedDef">
<span style="color: rgb(var(--color-text-muted));" x-text="valueList.length + ' items'"></span>
</div>
</div>
</div>
<!-- Include Definition Form Dialog -->
<?= $this->include('v2/master/valuesets/valuesetdef_dialog') ?>
<!-- Include Value Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Category Confirmation Modal -->
<div
x-show="showDeleteDefModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteDefModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete category <strong x-text="deleteDefTarget?.VSName"></strong>?
This will also delete all items in this category and cannot be undone.
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteDefModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteDef()" :disabled="deletingDef">
<span x-show="deletingDef" class="spinner spinner-sm"></span>
<span x-show="!deletingDef">Delete</span>
</button>
</div>
</div>
</div>
<!-- Delete Value Confirmation Modal -->
<div
x-show="showDeleteValueModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteValueModal = false"
>
<div class="modal-content p-6 max-w-md">
<h3 class="font-bold text-lg mb-4 flex items-center gap-2" style="color: rgb(var(--color-error));">
<i class="fa-solid fa-exclamation-triangle"></i>
Confirm Delete
</h3>
<p class="mb-6" style="color: rgb(var(--color-text));">
Are you sure you want to delete item <strong x-text="deleteValueTarget?.VValue"></strong>?
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteValueModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteValue()" :disabled="deletingValue">
<span x-show="deletingValue" class="spinner spinner-sm"></span>
<span x-show="!deletingValue">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetManager() {
return {
// State - Definitions
defLoading: false,
defList: [],
defKeyword: "",
// State - Values
valueLoading: false,
valueList: [],
valueKeyword: "",
selectedDef: null,
// Definition Form
showDefModal: false,
isEditingDef: false,
savingDef: false,
defErrors: {},
defForm: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
// Value Form
showValueModal: false,
isEditingValue: false,
savingValue: false,
valueErrors: {},
valueForm: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
// Delete Definition
showDeleteDefModal: false,
deleteDefTarget: null,
deletingDef: false,
// Delete Value
showDeleteValueModal: false,
deleteValueTarget: null,
deletingValue: false,
// Dropdown data
defsList: [],
// Lifecycle
async init() {
await this.fetchDefs();
},
// ==================== DEFINITION METHODS ====================
async fetchDefs() {
this.defLoading = true;
try {
const params = new URLSearchParams();
if (this.defKeyword) params.append('search', this.defKeyword);
const res = await fetch(`${BASEURL}api/valuesetdef?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defList = data.data || [];
if (this.selectedDef) {
const updated = this.defList.find(d => d.VSetID === this.selectedDef.VSetID);
if (updated) {
this.selectedDef = updated;
}
}
} catch (err) {
console.error(err);
this.defList = [];
this.showToast('Failed to load categories', 'error');
} finally {
this.defLoading = false;
}
},
showDefForm() {
this.isEditingDef = false;
this.defForm = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.defErrors = {};
this.showDefModal = true;
},
async editDef(id) {
this.isEditingDef = true;
this.defErrors = {};
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.defForm = { ...this.defForm, ...data.data };
this.showDefModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load category data', 'error');
}
},
validateDef() {
const e = {};
if (!this.defForm.VSName?.trim()) e.VSName = "Category name is required";
this.defErrors = e;
return Object.keys(e).length === 0;
},
closeDefModal() {
this.showDefModal = false;
this.defErrors = {};
},
async saveDef() {
if (!this.validateDef()) return;
this.savingDef = true;
try {
const method = this.isEditingDef ? 'PUT' : 'POST';
const url = this.isEditingDef ? `${BASEURL}api/valuesetdef/${this.defForm.VSetID}` : `${BASEURL}api/valuesetdef`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.defForm),
credentials: 'include'
});
if (res.ok) {
this.closeDefModal();
await this.fetchDefs();
this.showToast(this.isEditingDef ? 'Category updated successfully' : 'Category created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.defErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.defErrors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.savingDef = false;
}
},
confirmDeleteDef(def) {
this.deleteDefTarget = def;
this.showDeleteDefModal = true;
},
async deleteDef() {
if (!this.deleteDefTarget) return;
this.deletingDef = true;
try {
const res = await fetch(`${BASEURL}api/valuesetdef/${this.deleteDefTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteDefModal = false;
if (this.selectedDef?.VSetID === this.deleteDefTarget.VSetID) {
this.selectedDef = null;
this.valueList = [];
}
await this.fetchDefs();
this.showToast('Category deleted successfully', 'success');
} else {
this.showToast('Failed to delete category', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete category', 'error');
} finally {
this.deletingDef = false;
this.deleteDefTarget = null;
}
},
// ==================== VALUE METHODS ====================
selectDef(def) {
this.selectedDef = def;
this.fetchValues();
},
async fetchValues() {
if (!this.selectedDef) return;
this.valueLoading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.valueKeyword) params.append('search', this.valueKeyword);
const res = await fetch(`${BASEURL}api/valueset/items?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.valueList = data.data || [];
} catch (err) {
console.error(err);
this.valueList = [];
this.showToast('Failed to load items', 'error');
} finally {
this.valueLoading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/valuesetdef?limit=1000`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.defsList = data.data || [];
} catch (err) {
console.error('Failed to fetch defs list:', err);
this.defsList = [];
}
},
showValueForm() {
if (!this.selectedDef) {
this.showToast('Please select a category first', 'warning');
return;
}
this.isEditingValue = false;
this.valueForm = {
VID: null,
VSetID: this.selectedDef.VSetID,
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.valueErrors = {};
this.showValueModal = true;
},
async editValue(id) {
this.isEditingValue = true;
this.valueErrors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/items/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.valueForm = { ...this.valueForm, ...data.data };
this.showValueModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validateValue() {
const e = {};
if (!this.valueForm.VValue?.trim()) e.VValue = "Value is required";
if (!this.valueForm.VSetID) e.VSetID = "Category is required";
this.valueErrors = e;
return Object.keys(e).length === 0;
},
closeValueModal() {
this.showValueModal = false;
this.valueErrors = {};
},
async saveValue() {
if (!this.validateValue()) return;
this.savingValue = true;
try {
const method = this.isEditingValue ? 'PUT' : 'POST';
const url = this.isEditingValue ? `${BASEURL}api/valueset/items/${this.valueForm.VID}` : `${BASEURL}api/valueset/items`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.valueForm),
credentials: 'include'
});
if (res.ok) {
this.closeValueModal();
await this.fetchValues();
await this.fetchDefs();
this.showToast(this.isEditingValue ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.valueErrors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error(err);
this.valueErrors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.savingValue = false;
}
},
confirmDeleteValue(value) {
this.deleteValueTarget = value;
this.showDeleteValueModal = true;
},
async deleteValue() {
if (!this.deleteValueTarget) return;
this.deletingValue = true;
try {
const res = await fetch(`${BASEURL}api/valueset/items/${this.deleteValueTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteValueModal = false;
await this.fetchValues();
await this.fetchDefs();
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error(err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deletingValue = false;
this.deleteValueTarget = null;
}
},
// ==================== UTILITIES ====================
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>