feat(valueset): restructure valueset UI and add result-specific CRUD

- Restructure valueset pages from single master page to separate views:
  - Library Valuesets (read-only lookup browser)
  - Result Valuesets (CRUD for valueset table)
  - Valueset Definitions (CRUD for valuesetdef table)
- Add new ResultValueSetController for result-specific valueset operations
- Move views from master/valuesets to result/valueset and result/valuesetdef
- Convert valueset sidebar to collapsible nested menu
- Add search filtering to ValueSetController index
- Remove deprecated welcome_message.php and old nested CRUD view
- Update routes to organize under /result namespace
Summary of changes: This commit reorganizes the valueset management UI by splitting the monolithic master/valuesets page into three distinct sections, adds a new controller for result-related valueset operations, and restructures the sidebar navigation for better usability.
This commit is contained in:
mahdahar 2026-01-14 16:45:58 +07:00
parent e36e390f71
commit 42a5260f9a
15 changed files with 1228 additions and 1105 deletions

5
.gitignore vendored
View File

@ -125,8 +125,3 @@ _modules/*
/results/
/phpunit*.xml
/public/.htaccess
#-------------------------
# Claude
#-------------------------
.claude

View File

@ -51,7 +51,10 @@ $routes->group('v2', ['filter' => 'auth'], function ($routes) {
// Master Data - Tests & ValueSets
$routes->get('master/tests', 'PagesController::masterTests');
$routes->get('master/valuesets', 'PagesController::masterValueSets');
$routes->get('valueset', 'PagesController::valueSetLibrary');
$routes->get('result/valueset', 'PagesController::resultValueSet');
$routes->get('result/valuesetdef', 'PagesController::resultValueSetDef');
});
// Faker
@ -158,12 +161,22 @@ $routes->group('api', function ($routes) {
$routes->delete('items/(:num)', 'ValueSetController::deleteItem/$1');
});
$routes->group('valuesetdef', function ($routes) {
$routes->get('/', 'ValueSetDefController::index');
$routes->get('(:num)', 'ValueSetDefController::show/$1');
$routes->post('/', 'ValueSetDefController::create');
$routes->put('(:num)', 'ValueSetDefController::update/$1');
$routes->delete('(:num)', 'ValueSetDefController::delete/$1');
$routes->group('result', function ($routes) {
$routes->group('valueset', function ($routes) {
$routes->get('/', 'Result\ResultValueSetController::index');
$routes->get('(:num)', 'Result\ResultValueSetController::show/$1');
$routes->post('/', 'Result\ResultValueSetController::create');
$routes->put('(:num)', 'Result\ResultValueSetController::update/$1');
$routes->delete('(:num)', 'Result\ResultValueSetController::delete/$1');
});
$routes->group('valuesetdef', function ($routes) {
$routes->get('/', 'ValueSetDefController::index');
$routes->get('(:num)', 'ValueSetDefController::show/$1');
$routes->post('/', 'ValueSetDefController::create');
$routes->put('(:num)', 'ValueSetDefController::update/$1');
$routes->delete('(:num)', 'ValueSetDefController::delete/$1');
});
});
// Counter

View File

@ -155,13 +155,35 @@ class PagesController extends BaseController
}
/**
* Master Data - Value Sets
* Value Set Library - Read-only
*/
public function masterValueSets()
public function valueSetLibrary()
{
return view('v2/master/valuesets/valuesets_index', [
'pageTitle' => 'Value Sets',
'activePage' => 'master-valuesets'
return view('v2/valueset/valueset_index', [
'pageTitle' => 'Value Set Library',
'activePage' => 'valueset-library'
]);
}
/**
* Result Valueset - CRUD for valueset table
*/
public function resultValueSet()
{
return view('v2/result/valueset/resultvalueset_index', [
'pageTitle' => 'Result Valuesets',
'activePage' => 'result-valueset'
]);
}
/**
* Result Valueset Definition - CRUD for valuesetdef table
*/
public function resultValueSetDef()
{
return view('v2/result/valuesetdef/resultvaluesetdef_index', [
'pageTitle' => 'Valueset Definitions',
'activePage' => 'result-valuesetdef'
]);
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Controllers\Result;
use App\Models\ValueSet\ValueSetModel;
use CodeIgniter\API\ResponseTrait;
class ResultValueSetController extends \CodeIgniter\Controller
{
use ResponseTrait;
protected $dbModel;
public function __construct()
{
$this->dbModel = new ValueSetModel();
}
public function index()
{
$search = $this->request->getGet('search') ?? $this->request->getGet('param') ?? null;
$VSetID = $this->request->getGet('VSetID') ?? null;
$rows = $this->dbModel->getValueSets($search, $VSetID);
return $this->respond([
'status' => 'success',
'data' => $rows
], 200);
}
public function show($id = null)
{
$row = $this->dbModel->getValueSet($id);
if (!$row) {
return $this->failNotFound("ValueSet item not found: $id");
}
return $this->respond([
'status' => 'success',
'data' => $row
], 200);
}
public function create()
{
$input = $this->request->getJSON(true);
if (!$input) {
return $this->failValidationErrors(['Invalid JSON input']);
}
$data = [
'SiteID' => $input['SiteID'] ?? 1,
'VSetID' => $input['VSetID'] ?? null,
'VOrder' => $input['VOrder'] ?? 0,
'VValue' => $input['VValue'] ?? '',
'VDesc' => $input['VDesc'] ?? '',
'VCategory' => $input['VCategory'] ?? null
];
if ($data['VSetID'] === null) {
return $this->failValidationErrors(['VSetID is required']);
}
try {
$id = $this->dbModel->insert($data, true);
if (!$id) {
return $this->failValidationErrors($this->dbModel->errors());
}
$newRow = $this->dbModel->getValueSet($id);
return $this->respondCreated([
'status' => 'success',
'message' => 'ValueSet item created',
'data' => $newRow
]);
} catch (\Exception $e) {
return $this->failServerError('Failed to create: ' . $e->getMessage());
}
}
public function update($id = null)
{
$input = $this->request->getJSON(true);
if (!$input) {
return $this->failValidationErrors(['Invalid JSON input']);
}
$existing = $this->dbModel->getValueSet($id);
if (!$existing) {
return $this->failNotFound("ValueSet item not found: $id");
}
$data = [];
if (isset($input['VSetID'])) $data['VSetID'] = $input['VSetID'];
if (isset($input['VOrder'])) $data['VOrder'] = $input['VOrder'];
if (isset($input['VValue'])) $data['VValue'] = $input['VValue'];
if (isset($input['VDesc'])) $data['VDesc'] = $input['VDesc'];
if (isset($input['SiteID'])) $data['SiteID'] = $input['SiteID'];
if (isset($input['VCategory'])) $data['VCategory'] = $input['VCategory'];
if (empty($data)) {
return $this->respond([
'status' => 'success',
'message' => 'No changes to update',
'data' => $existing
], 200);
}
try {
$updated = $this->dbModel->update($id, $data);
if (!$updated) {
return $this->failValidationErrors($this->dbModel->errors());
}
$newRow = $this->dbModel->getValueSet($id);
return $this->respond([
'status' => 'success',
'message' => 'ValueSet item updated',
'data' => $newRow
], 200);
} catch (\Exception $e) {
return $this->failServerError('Failed to update: ' . $e->getMessage());
}
}
public function delete($id = null)
{
$existing = $this->dbModel->getValueSet($id);
if (!$existing) {
return $this->failNotFound("ValueSet item not found: $id");
}
try {
$this->dbModel->delete($id);
return $this->respond([
'status' => 'success',
'message' => 'ValueSet item deleted'
], 200);
} catch (\Exception $e) {
return $this->failServerError('Failed to delete: ' . $e->getMessage());
}
}
}

View File

@ -19,10 +19,19 @@ class ValueSetController extends \CodeIgniter\Controller
public function index(?string $lookupName = null)
{
$search = $this->request->getGet('search') ?? null;
if ($lookupName === null) {
$all = ValueSet::getAll();
$result = [];
foreach ($all as $name => $entry) {
if ($search) {
$nameLower = strtolower($name);
$searchLower = strtolower($search);
if (strpos($nameLower, $searchLower) === false) {
continue;
}
}
$count = count($entry['values'] ?? []);
$result[$name] = $count;
}

View File

@ -173,14 +173,39 @@
</a>
</li>
<!-- Value Sets -->
<!-- Value Sets (Nested Group) -->
<li>
<a href="<?= base_url('/v2/master/valuesets') ?>"
:class="isActive('master/valuesets') ? 'active' : ''"
class="group">
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Value Sets</span>
</a>
<div x-data="{
isOpen: valuesetOpen,
toggle() { this.isOpen = !this.isOpen; $root.layout().valuesetOpen = this.isOpen }
}" x-init="$watch('valuesetOpen', v => isOpen = v)">
<button @click="isOpen = !isOpen"
class="group w-full flex items-center justify-between"
:class="isParentActive('valueset') ? 'text-primary font-medium' : ''">
<div class="flex items-center gap-3">
<i class="fa-solid fa-list-check w-5 text-center transition-transform group-hover:scale-110"></i>
<span x-show="sidebarOpen">Value Sets</span>
</div>
<i x-show="sidebarOpen" class="fa-solid fa-chevron-down text-xs transition-transform" :class="isOpen && 'rotate-180'"></i>
</button>
<ul x-show="isOpen && sidebarOpen" x-collapse class="ml-8 mt-2 space-y-1">
<li>
<a href="<?= base_url('/v2/valueset') ?>"
:class="isActive('valueset') && !isParentActive('result/valueset') && !isParentActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Library Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valueset') ?>"
:class="isActive('result/valueset') ? 'active' : ''"
class="text-sm">Result Valuesets</a>
</li>
<li>
<a href="<?= base_url('/v2/result/valuesetdef') ?>"
:class="isActive('result/valuesetdef') ? 'active' : ''"
class="text-sm">Valueset Definitions</a>
</li>
</ul>
</div>
</li>
<!-- Settings -->
@ -319,6 +344,7 @@
lightMode: localStorage.getItem('theme') !== 'dark',
orgOpen: false,
specimenOpen: false,
valuesetOpen: false,
currentPath: window.location.pathname,
init() {
@ -333,6 +359,7 @@
// Auto-expand menus based on active path
this.orgOpen = this.currentPath.includes('organization');
this.specimenOpen = this.currentPath.includes('specimen');
this.valuesetOpen = this.currentPath.includes('valueset');
// Watch sidebar state to persist
this.$watch('sidebarOpen', val => localStorage.setItem('sidebarOpen', val));

View File

@ -1,363 +0,0 @@
<!-- 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>

View File

@ -1,678 +0,0 @@
<?= $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() ?>

View File

@ -1,4 +1,4 @@
<!-- Value Set Item Form Modal -->
<!-- Result Value Set Item Form Modal -->
<div
x-show="showModal"
x-cloak
@ -21,7 +21,6 @@
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-list-plus" style="color: rgb(var(--color-primary));"></i>
@ -32,10 +31,8 @@
</button>
</div>
<!-- Form -->
<div class="space-y-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
@ -43,8 +40,7 @@
</div>
</div>
<!-- Category Selection (only show if no selectedDef) -->
<div x-show="!selectedDef" class="space-y-4">
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Category Assignment</h4>
<div>
@ -63,7 +59,6 @@
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Item Details</h4>
@ -109,7 +104,6 @@
</div>
</div>
<!-- System Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
@ -143,7 +137,6 @@
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">

View File

@ -0,0 +1,322 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSet()" x-init="init()">
<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-amber-600 to-orange-800 flex items-center justify-center shadow-lg">
<i class="fa-solid fa-list-ul text-2xl text-white"></i>
</div>
<div>
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Result Valuesets</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset items from database</p>
</div>
</div>
</div>
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search valuesets..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Item
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div x-show="loading" 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 valuesets...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category</th>
<th>Value</th>
<th>Description</th>
<th class="w-20 text-center">Order</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!list || list.length === 0">
<tr>
<td colspan="6" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-inbox text-5xl opacity-40"></i>
<p class="text-lg">No valuesets found</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>
<template x-for="item in list" :key="item.VID">
<tr class="hover:bg-opacity-50">
<td>
<span class="badge badge-ghost font-mono text-xs" x-text="item.VID || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VCategoryName || '-'"></div>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.VValue || '-'"></div>
</td>
<td>
<span class="text-sm opacity-70" x-text="item.VDesc || '-'"></span>
</td>
<td class="text-center">
<span class="font-mono text-sm" x-text="item.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="editItem(item.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(item)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<?= $this->include('v2/result/valueset/resultvalueset_dialog') ?>
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = 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="deleteTarget?.VValue"></strong>?
</p>
<div class="flex gap-2">
<button class="btn btn-ghost flex-1" @click="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteItem()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function resultValueSet() {
return {
loading: false,
list: [],
keyword: "",
defsList: [],
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
await this.fetchDefsList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
this.showToast('Failed to load valuesets', 'error');
} finally {
this.loading = false;
}
},
async fetchDefsList() {
try {
const res = await fetch(`${BASEURL}api/result/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 = [];
}
},
showForm() {
this.isEditing = false;
this.form = {
VID: null,
VSetID: "",
VOrder: 0,
VValue: "",
VDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/valueset/${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/result/valueset/${this.form.VID}` : `${BASEURL}api/result/valueset`;
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();
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(err);
this.errors = { general: 'Failed to save item' };
this.showToast('Failed to save item', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(item) {
this.deleteTarget = item;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valueset/${this.deleteTarget.VID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
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.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,4 +1,4 @@
<!-- Value Set Definition Form Modal -->
<!-- Result Value Set Definition Form Modal -->
<div
x-show="showModal"
x-cloak
@ -21,7 +21,6 @@
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-xl flex items-center gap-2" style="color: rgb(var(--color-text));">
<i class="fa-solid fa-layer-group-plus" style="color: rgb(var(--color-primary));"></i>
@ -32,10 +31,8 @@
</button>
</div>
<!-- Form -->
<div class="space-y-6">
<!-- General Error -->
<div x-show="errors.general" class="p-4 rounded-lg bg-rose-50 border border-rose-200" style="display: none;">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-rose-500"></i>
@ -43,7 +40,6 @@
</div>
</div>
<!-- Basic Information Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">Basic Information</h4>
@ -75,7 +71,6 @@
</div>
</div>
<!-- Additional Info Section -->
<div class="space-y-4">
<h4 class="font-semibold text-sm uppercase tracking-wider opacity-60" style="color: rgb(var(--color-text));">System Information</h4>
@ -109,7 +104,6 @@
</div>
<!-- Actions -->
<div class="flex gap-3 mt-8 pt-6" style="border-top: 1px solid rgb(var(--color-border));">
<button class="btn btn-ghost flex-1" @click="closeModal()">Cancel</button>
<button class="btn btn-primary flex-1" @click="save()" :disabled="saving">

View File

@ -0,0 +1,298 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="resultValueSetDef()" x-init="init()">
<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-rose-600 to-pink-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));">Valueset Definitions</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage valueset categories and definitions</p>
</div>
</div>
</div>
<div class="card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-4 items-center justify-between">
<div class="flex w-full sm:w-auto gap-2">
<input
type="text"
placeholder="Search definitions..."
class="input flex-1 sm:w-80"
x-model="keyword"
@keyup.enter="fetchList()"
/>
<button class="btn btn-primary" @click="fetchList()">
<i class="fa-solid fa-search"></i>
</button>
</div>
<button class="btn btn-primary w-full sm:w-auto" @click="showForm()">
<i class="fa-solid fa-plus mr-2"></i>
Add Category
</button>
</div>
</div>
</div>
<div class="card overflow-hidden">
<div x-show="loading" 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 definitions...</p>
</div>
<div class="overflow-x-auto" x-show="!loading">
<table class="table">
<thead>
<tr>
<th class="w-16">ID</th>
<th>Category Name</th>
<th>Description</th>
<th class="w-20 text-center">Items</th>
<th class="w-24 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-if="!list || list.length === 0">
<tr>
<td colspan="5" class="text-center py-12">
<div class="flex flex-col items-center gap-3" style="color: rgb(var(--color-text-muted));">
<i class="fa-solid fa-folder-open text-5xl opacity-40"></i>
<p class="text-lg">No definitions found</p>
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
<i class="fa-solid fa-plus mr-1"></i> Add First Category
</button>
</div>
</td>
</tr>
</template>
<template x-for="def in list" :key="def.VSetID">
<tr class="hover:bg-opacity-50">
<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>
</td>
<td>
<span class="text-sm opacity-70" x-text="def.VSDesc || '-'"></span>
</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">
<button class="btn btn-ghost btn-sm btn-square" @click="editItem(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="confirmDelete(def)" title="Delete">
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<?= $this->include('v2/result/valuesetdef/resultvaluesetdef_dialog') ?>
<div
x-show="showDeleteModal"
x-cloak
class="modal-overlay"
@click.self="showDeleteModal = 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="deleteTarget?.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="showDeleteModal = false">Cancel</button>
<button class="btn flex-1" style="background: rgb(var(--color-error)); color: white;" @click="deleteItem()" :disabled="deleting">
<span x-show="deleting" class="spinner spinner-sm"></span>
<span x-show="!deleting">Delete</span>
</button>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function resultValueSetDef() {
return {
loading: false,
list: [],
keyword: "",
showModal: false,
isEditing: false,
saving: false,
errors: {},
form: {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
},
showDeleteModal: false,
deleteTarget: null,
deleting: false,
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/result/valuesetdef?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || [];
} catch (err) {
console.error(err);
this.list = [];
this.showToast('Failed to load definitions', 'error');
} finally {
this.loading = false;
}
},
showForm() {
this.isEditing = false;
this.form = {
VSetID: null,
VSName: "",
VSDesc: "",
SiteID: 1
};
this.errors = {};
this.showModal = true;
},
async editItem(id) {
this.isEditing = true;
this.errors = {};
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${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 category data', 'error');
}
},
validate() {
const e = {};
if (!this.form.VSName?.trim()) e.VSName = "Category name 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/result/valuesetdef/${this.form.VSetID}` : `${BASEURL}api/result/valuesetdef`;
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();
this.showToast(this.isEditing ? 'Category updated successfully' : 'Category 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(err);
this.errors = { general: 'Failed to save category' };
this.showToast('Failed to save category', 'error');
} finally {
this.saving = false;
}
},
confirmDelete(def) {
this.deleteTarget = def;
this.showDeleteModal = true;
},
async deleteItem() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const res = await fetch(`${BASEURL}api/result/valuesetdef/${this.deleteTarget.VSetID}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
if (res.ok) {
this.showDeleteModal = false;
await this.fetchList();
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.deleting = false;
this.deleteTarget = null;
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -0,0 +1,371 @@
<?= $this->extend("v2/layout/main_layout"); ?>
<?= $this->section("content") ?>
<div x-data="valueSetLibrary()" x-init="init()" class="relative">
<!-- Header & Stats -->
<div class="card-glass p-6 animate-fadeIn mb-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-emerald-600 to-teal-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 Library</h2>
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Browse predefined value sets from library</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-primary));" x-text="Object.keys(list).length"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Value Sets</p>
</div>
<div class="w-px h-8 bg-current opacity-10"></div>
<div class="text-center">
<p class="text-2xl font-bold" style="color: rgb(var(--color-secondary));" x-text="totalItems"></p>
<p class="text-xs uppercase tracking-wider opacity-60">Total Items</p>
</div>
</div>
</div>
</div>
<!-- 2-Column Layout: Left Sidebar (Categories) + Right Content (Values) -->
<div class="grid grid-cols-12 gap-4" style="height: calc(100vh - 200px);">
<!-- LEFT PANEL: Value Set Categories -->
<div class="col-span-4 xl:col-span-3 flex flex-col card-glass overflow-hidden">
<!-- Left Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<h3 class="font-semibold text-sm uppercase tracking-wider opacity-60 mb-3">Categories</h3>
<div class="flex items-center gap-2 bg-base-200 rounded-lg px-3 border border-dashed border-base-content/20">
<i class="fa-solid fa-search text-xs opacity-50"></i>
<input
type="text"
placeholder="Search categories..."
class="input input-sm bg-transparent border-0 p-2 flex-1 min-w-0 focus:outline-none"
x-model.debounce.300ms="keyword"
@input="fetchList()"
/>
<button
x-show="keyword"
@click="keyword = ''; fetchList()"
class="btn btn-ghost btn-xs btn-square"
x-cloak
>
<i class="fa-solid fa-times text-xs"></i>
</button>
</div>
</div>
<!-- Categories List -->
<div class="flex-1 overflow-y-auto">
<!-- Skeleton Loading -->
<div x-show="loading && !Object.keys(list).length" class="p-4 space-y-2" x-cloak>
<template x-for="i in 5">
<div class="p-3 animate-pulse rounded-lg bg-current opacity-5">
<div class="h-4 w-3/4 rounded bg-current opacity-10 mb-2"></div>
<div class="h-3 w-1/4 rounded bg-current opacity-10"></div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="!loading && !Object.keys(list).length" class="p-8 text-center opacity-40" x-cloak>
<i class="fa-solid fa-folder-open text-3xl mb-2"></i>
<p class="text-sm">No categories found</p>
</div>
<!-- Category Items -->
<div x-show="!loading && Object.keys(list).length > 0" class="p-2" x-cloak>
<template x-for="(count, name) in filteredList" :key="name">
<div
class="p-3 rounded-lg cursor-pointer transition-all mb-1 group"
:class="selectedCategory === name ? 'bg-primary/10 border border-primary/30' : 'hover:bg-black/5 border border-transparent'"
@click="selectCategory(name)"
>
<div class="flex items-center justify-between">
<div class="truncate flex-1">
<div
class="font-medium text-sm transition-colors"
:class="selectedCategory === name ? 'text-primary' : 'opacity-80'"
x-text="formatName(name)"
></div>
<div class="text-xs opacity-40 font-mono truncate" x-text="name"></div>
</div>
<div class="flex items-center gap-2 ml-2">
<span
class="badge badge-sm"
:class="selectedCategory === name ? 'badge-primary' : 'badge-ghost'"
x-text="count"
></span>
<i
class="fa-solid fa-chevron-right text-xs transition-transform"
:class="selectedCategory === name ? 'opacity-100 rotate-0' : 'opacity-0 group-hover:opacity-50'"
></i>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Left Panel Footer -->
<div class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));">
<span x-text="Object.keys(list).length"></span> categories
</div>
</div>
<!-- RIGHT PANEL: Value Set Values -->
<div class="col-span-8 xl:col-span-9 flex flex-col card-glass overflow-hidden">
<!-- Right Panel Header -->
<div class="p-4 border-b shrink-0" style="border-color: rgb(var(--color-border));">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-lg flex items-center justify-center transition-transform"
:class="selectedCategory ? 'bg-primary/10' : 'bg-black/5'"
>
<i
class="fa-solid text-lg"
:class="selectedCategory ? 'fa-table-list text-primary' : 'fa-list opacity-20'"
></i>
</div>
<div>
<h3 class="font-bold" style="color: rgb(var(--color-text));" x-text="selectedCategory ? formatName(selectedCategory) : 'Select a Category'"></h3>
<p x-show="selectedCategory" class="text-xs font-mono opacity-50" x-text="selectedCategory"></p>
</div>
</div>
</div>
<!-- Filter Input (when category selected) -->
<div x-show="selectedCategory" class="mt-3" x-transition>
<div class="flex items-center gap-2 bg-black/5 rounded-lg px-3 border border-dashed">
<i class="fa-solid fa-filter text-xs opacity-40"></i>
<input
type="text"
placeholder="Filter items..."
class="input input-sm bg-transparent border-0 p-2 flex-1 focus:outline-none"
x-model="itemFilter"
/>
</div>
</div>
</div>
<!-- Right Panel Content -->
<div class="flex-1 overflow-y-auto">
<!-- No Category Selected State -->
<div x-show="!selectedCategory" class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-arrow-left text-5xl mb-4"></i>
<p class="text-lg">Select a category from the left to view values</p>
</div>
<!-- Loading State -->
<div x-show="itemLoading" class="h-full flex flex-col items-center justify-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>
<!-- Values Table -->
<div x-show="!itemLoading && selectedCategory">
<template x-if="!items[selectedCategory]?.length">
<div class="h-full flex flex-col items-center justify-center opacity-30" x-cloak>
<i class="fa-solid fa-box-open text-5xl mb-4"></i>
<p>This category has no items</p>
</div>
</template>
<template x-if="items[selectedCategory]?.length">
<table class="table table-zebra w-full">
<thead class="sticky top-0 bg-inherit shadow-sm z-10">
<tr>
<th class="w-24">Key</th>
<th>Value / Label</th>
<th class="w-20 text-center">Actions</th>
</tr>
</thead>
<tbody>
<template x-for="item in filteredItems" :key="item.value">
<tr class="group">
<td class="font-mono text-xs">
<span class="badge badge-ghost px-2 py-1" x-text="item.value || '-'"></span>
</td>
<td>
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="item.label || '-'"></div>
</td>
<td class="text-center">
<button
class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity"
@click="copyToClipboard(item.label)"
title="Copy label"
>
<i class="fa-solid fa-copy"></i>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
<!-- Filter Empty State -->
<template x-if="filteredItems.length === 0 && items[selectedCategory]?.length && itemFilter">
<div class="p-12 text-center opacity-40" x-cloak>
<i class="fa-solid fa-magnifying-glass text-4xl mb-3"></i>
<p>No items match your filter</p>
</div>
</template>
</div>
</div>
<!-- Right Panel Footer -->
<div x-show="selectedCategory" class="p-3 border-t text-xs text-center opacity-40" style="border-color: rgb(var(--color-border));" x-transition>
Showing <span x-text="filteredItems.length"></span> of <span x-text="items[selectedCategory]?.length || 0"></span> items
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("script") ?>
<script>
function valueSetLibrary() {
return {
loading: false,
itemLoading: false,
list: {},
items: {},
keyword: "",
sortBy: 'name',
selectedCategory: null,
itemFilter: "",
get totalItems() {
return Object.values(this.list).reduce((acc, count) => acc + count, 0);
},
get filteredList() {
if (!this.keyword) return this.list;
const filter = this.keyword.toLowerCase();
return Object.fromEntries(
Object.entries(this.list).filter(([name]) =>
this.matchesPartial(name, filter)
)
);
},
matchesPartial(name, filter) {
const nameLower = name.toLowerCase().replace(/_/g, ' ');
let nameIndex = 0;
for (let i = 0; i < filter.length; i++) {
const char = filter[i];
const foundIndex = nameLower.indexOf(char, nameIndex);
if (foundIndex === -1) return false;
nameIndex = foundIndex + 1;
}
return true;
},
get filteredItems() {
if (!this.items[this.selectedCategory]) return [];
const filter = this.itemFilter.toLowerCase();
return this.items[this.selectedCategory].filter(item => {
const label = (item.label || "").toLowerCase();
const value = (item.value || "").toLowerCase();
return label.includes(filter) || value.includes(filter);
});
},
async init() {
await this.fetchList();
},
async fetchList() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.keyword) params.append('search', this.keyword);
const res = await fetch(`${BASEURL}api/valueset?${params}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.list = data.data || {};
this.sortList();
} catch (err) {
console.error(err);
this.list = {};
this.showToast('Failed to load value sets', 'error');
} finally {
this.loading = false;
}
},
sortList() {
const entries = Object.entries(this.list);
entries.sort((a, b) => {
if (this.sortBy === 'count') return b[1] - a[1];
return a[0].localeCompare(b[0]);
});
this.list = Object.fromEntries(entries);
},
async selectCategory(name) {
if (this.selectedCategory === name) {
return;
}
this.selectedCategory = name;
this.itemFilter = "";
if (!this.items[name]) {
await this.fetchItems(name);
}
},
async fetchItems(name) {
this.itemLoading = true;
try {
const res = await fetch(`${BASEURL}api/valueset/${name}`, {
credentials: 'include'
});
if (!res.ok) throw new Error("HTTP error");
const data = await res.json();
this.items[name] = data.data || [];
} catch (err) {
console.error(err);
this.items[name] = [];
this.showToast('Failed to load items', 'error');
} finally {
this.itemLoading = false;
}
},
formatName(name) {
if (!name) return '';
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
},
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('Copied to clipboard', 'success');
} catch (err) {
console.error(err);
}
},
showToast(message, type = 'info') {
if (this.$root && this.$root.showToast) {
this.$root.showToast(message, type);
} else {
alert(message);
}
}
}
}
</script>
<?= $this->endSection() ?>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hello World Page</title>
</head>
<body>
<h1>Hello World!</h1>
<p>This is a simple HTML page.</p>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hello World Page</title>
</head>
<body>
<h1>Hello World!</h1>
<p>This is a simple HTML page.</p>
</body>
</html>