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:
parent
e36e390f71
commit
42a5260f9a
5
.gitignore
vendored
5
.gitignore
vendored
@ -125,8 +125,3 @@ _modules/*
|
||||
/results/
|
||||
/phpunit*.xml
|
||||
/public/.htaccess
|
||||
|
||||
#-------------------------
|
||||
# Claude
|
||||
#-------------------------
|
||||
.claude
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
144
app/Controllers/Result/ResultValueSetController.php
Normal file
144
app/Controllers/Result/ResultValueSetController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
@ -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() ?>
|
||||
@ -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">
|
||||
322
app/Views/v2/result/valueset/resultvalueset_index.php
Normal file
322
app/Views/v2/result/valueset/resultvalueset_index.php
Normal 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() ?>
|
||||
@ -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">
|
||||
298
app/Views/v2/result/valuesetdef/resultvaluesetdef_index.php
Normal file
298
app/Views/v2/result/valuesetdef/resultvaluesetdef_index.php
Normal 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() ?>
|
||||
371
app/Views/v2/valueset/valueset_index.php
Normal file
371
app/Views/v2/valueset/valueset_index.php
Normal 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() ?>
|
||||
@ -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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user