clqms-be/app/Views/v2/master/valuesets/valueset_nested_crud.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

364 lines
12 KiB
PHP

<!-- Nested ValueSet CRUD Modal -->
<div
x-show="showValueSetModal"
x-cloak
class="modal-overlay"
style="z-index: 1000;"
@click.self="$root.closeValueSetModal()"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div
class="modal-content p-0 max-w-5xl w-full max-h-[90vh] overflow-hidden"
@click.stop
x-data="valueSetItems()"
x-init="selectedDef = $root.selectedDef; if(selectedDef) { fetchList(1); fetchDefsList(); }"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="p-6 border-b flex items-center justify-between" style="background: rgb(var(--color-bg)); border-color: rgb(var(--color-border));">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-xl flex items-center justify-center text-white shadow-md" style="background: rgb(var(--color-primary));">
<i class="fa-solid fa-list-ul"></i>
</div>
<div>
<h3 class="font-bold text-lg" style="color: rgb(var(--color-text));" x-text="selectedDef?.VSName || 'Value Items'"></h3>
<p class="text-xs uppercase font-bold opacity-40" style="color: rgb(var(--color-text-muted));">Manage Category Items</p>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-sm" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i> Add Item
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="$root.closeValueSetModal()">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
<!-- Search Bar -->
<div class="p-4 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"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm w-full pl-10"
x-model="keyword"
@keyup.enter="fetchList(1)"
/>
</div>
</div>
<!-- Content Area -->
<div class="overflow-y-auto" style="max-height: calc(90vh - 200px);">
<!-- Loading Overlay -->
<div x-show="loading" class="py-20 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>
<!-- Table Section -->
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-20">ID</th>
<th>Value / Key</th>
<th>Definition</th>
<th class="text-center">Order</th>
<th class="text-center w-32">Actions</th>
</tr>
</thead>
<tbody>
<!-- Empty State -->
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="py-20 text-center">
<div class="flex flex-col items-center gap-2 opacity-30" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-4xl"></i>
<p class="font-bold italic">No items found in this category</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Item
</button>
</div>
</td>
</tr>
</template>
<!-- Data Rows -->
<template x-for="v in list" :key="v.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="v.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="v.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="v.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="v.VOrder || 0"></span>
</td>
<td>
<div class="flex items-center justify-center gap-1">
<button class="btn btn-ghost btn-sm btn-square" @click="editValue(v.VID)" title="Edit">
<i class="fa-solid fa-pen text-sky-500"></i>
</button>
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(v)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Stats Footer -->
<div class="p-4 flex items-center justify-between" style="border-top: 1px solid rgb(var(--color-border));" x-show="list && list.length > 0">
<span class="text-sm" style="color: rgb(var(--color-text-muted));" x-text="list.length + ' items'"></span>
</div>
</div>
<!-- Item Form Dialog -->
<?= $this->include('v2/master/valuesets/valueset_dialog') ?>
<!-- Delete Modal -->
<div x-show="showDeleteModal" x-cloak class="modal-overlay" style="z-index: 1100;">
<div
class="card p-8 max-w-md w-full shadow-2xl"
x-show="showDeleteModal"
x-transition
>
<div class="w-16 h-16 rounded-2xl bg-rose-500/10 flex items-center justify-center text-rose-500 mx-auto mb-6">
<i class="fa-solid fa-triangle-exclamation text-2xl"></i>
</div>
<h3 class="text-xl font-bold text-center mb-2" style="color: rgb(var(--color-text));">Confirm Removal</h3>
<p class="text-center text-sm mb-8" style="color: rgb(var(--color-text-muted));">
Are you sure you want to delete <span class="font-bold text-rose-500" x-text="deleteTarget?.VValue"></span>?
</p>
<div class="flex gap-3">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1 bg-rose-600 text-white hover:bg-rose-700 shadow-lg shadow-rose-600/20" @click="deleteValue()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm !border-white/20 !border-t-white"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function valueSetItems() {
return {
loading: false,
list: [],
selectedDef: null,
keyword: "",
totalItems: 0,
// For dropdown population
defsList: [],
loadingDefs: false,
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async fetchList(page = 1) {
if (!this.selectedDef) return;
this.loading = true;
try {
const params = new URLSearchParams();
params.append('VSetID', this.selectedDef.VSetID);
if (this.keyword) params.append('search', this.keyword);
if (this.selectedDef) params.append('VSetID', this.selectedDef.VSetID);
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.list = data.data || [];
this.totalItems = this.list.length;
} catch (err) {
console.error(err);
this.list = [];
this.totalItems = 0;
this.showToast('Failed to load items', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
this.loadingDefs = true;
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 = [];
} finally {
this.loadingDefs = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: this.selectedDef?.VSetID || "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
// If no selectedDef, we need to load all defs for dropdown
if (!this.selectedDef && this.defsList.length === 0) {
this.fetchDefsList();
}
this.showModal = true;
},
async editValue(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/valueset/items/${id}`, {
credentials: 'include'
});
const data = await res.json();
if (data.data) {
this.form = { ...this.form, ...data.data };
this.showModal = true;
}
} catch (err) {
console.error(err);
this.showToast('Failed to load item data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VValue?.trim()) e.VValue = "Value is required";
if (!this.form.VSetID) e.VSetID = "Category is required";
this.errors = e;
return Object.keys(e).length === 0;
},
closeModal() {
this.showModal = false;
this.errors = {};
},
async save() {
if (!this.validate()) return;
this.saving = true;
try {
const method = this.isEditing ? 'PUT' : 'POST';
const url = this.isEditing ? `${BASEURL}api/valueset/items/${this.form.VID}` : `${BASEURL}api/valueset/items`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.form),
credentials: 'include'
});
if (res.ok) {
this.closeModal();
await this.fetchList(1);
this.showToast(this.isEditing ? 'Item updated successfully' : 'Item created successfully', 'success');
} else {
const errorData = await res.json().catch(() => ({ message: 'Unknown error' }));
this.errors = { general: errorData.message || 'Failed to save' };
}
} catch (err) {
console.error('Save failed:', err);
this.errors = { general: err.message || 'An error occurred while saving' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(v) {
this.deleteTarget = v;
this.showDeleteModal = true;
},
async deleteValue() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/valueset/items/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList(1);
this.showToast('Item deleted successfully', 'success');
} else {
this.showToast('Failed to delete item', 'error');
}
} catch (err) {
console.error('Delete failed:', err);
this.showToast('Failed to delete item', 'error');
} finally {
this.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>