- 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.
372 lines
14 KiB
PHP
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() ?>
|