clqms-be/app/Views/v2/valueset/valueset_index.php
mahdahar 42a5260f9a 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.
2026-01-14 16:45:58 +07:00

372 lines
14 KiB
PHP

<?= $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() ?>