387 lines
19 KiB
PHP
387 lines
19 KiB
PHP
|
|
<?= $this->extend('layouts/v2') ?>
|
||
|
|
|
||
|
|
<?= $this->section('content') ?>
|
||
|
|
|
||
|
|
<div class="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]" x-data="valueSetManager()">
|
||
|
|
|
||
|
|
<!-- Left Column: Value Sets (Defs) - Fixed width -->
|
||
|
|
<div class="w-full lg:w-80 flex flex-col bg-base-100 rounded-box shadow-xl shrink-0">
|
||
|
|
|
||
|
|
<!-- Header -->
|
||
|
|
<div class="p-4 border-b border-base-200">
|
||
|
|
<div class="flex justify-between items-center mb-4">
|
||
|
|
<h3 class="font-bold text-lg flex items-center gap-2">
|
||
|
|
<i data-lucide="book-open" class="w-5 h-5 text-primary"></i>
|
||
|
|
Value Sets
|
||
|
|
</h3>
|
||
|
|
<div class="tooltip tooltip-bottom" data-tip="New Value Set">
|
||
|
|
<button @click="openDefModal()" class="btn btn-sm btn-circle btn-ghost">
|
||
|
|
<i data-lucide="plus" class="w-5 h-5"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Search -->
|
||
|
|
<label class="input input-sm input-bordered flex items-center gap-2">
|
||
|
|
<input type="text" class="grow" placeholder="Search..." x-model="searchDef" />
|
||
|
|
<i data-lucide="search" class="w-4 h-4 opacity-70"></i>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- List -->
|
||
|
|
<div class="flex-1 overflow-y-auto p-2 scrollbar-thin">
|
||
|
|
<template x-if="filteredDefs.length === 0">
|
||
|
|
<div class="text-center p-8 text-base-content/50 text-sm">
|
||
|
|
No results found
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<ul class="menu p-0 gap-1 w-full">
|
||
|
|
<template x-for="def in filteredDefs" :key="def.VSetID">
|
||
|
|
<li>
|
||
|
|
<a @click="selectDef(def)"
|
||
|
|
:class="{ 'active': selectedDef?.VSetID === def.VSetID }"
|
||
|
|
class="flex flex-col items-start gap-1 py-3 group transition-colors">
|
||
|
|
|
||
|
|
<!-- Top Line: Name & Edit -->
|
||
|
|
<div class="flex justify-between w-full items-center">
|
||
|
|
<span class="font-medium truncate w-full" x-text="def.VSName || def.VSetID"></span>
|
||
|
|
<!-- Hover Actions -->
|
||
|
|
<button @click.stop="editDef(def)"
|
||
|
|
class="btn btn-xs btn-ghost btn-square opacity-0 group-hover:opacity-100 transition-opacity"
|
||
|
|
title="Edit Definition">
|
||
|
|
<i data-lucide="pencil" class="w-3 h-3"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Bottom Line: ID Badge -->
|
||
|
|
<div class="flex justify-between w-full items-center">
|
||
|
|
<span class="badge badge-xs badge-neutral badge-outline font-mono opacity-70" x-text="'ID: ' + def.VSetID"></span>
|
||
|
|
</div>
|
||
|
|
</a>
|
||
|
|
</li>
|
||
|
|
</template>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Right Column: Values -->
|
||
|
|
<div class="flex-1 flex flex-col bg-base-100 rounded-box shadow-xl overflow-hidden relative">
|
||
|
|
|
||
|
|
<!-- Empty State -->
|
||
|
|
<div x-show="!selectedDef" class="absolute inset-0 flex flex-col items-center justify-center text-base-content/30 bg-base-100 z-10">
|
||
|
|
<i data-lucide="layout-list" class="w-24 h-24 mb-4 opacity-20"></i>
|
||
|
|
<h3 class="font-bold text-xl">Select a Value Set</h3>
|
||
|
|
<p>Choose a definition from the left to manage its values</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Content (Only when selected) -->
|
||
|
|
<template x-if="selectedDef">
|
||
|
|
<div class="flex flex-col h-full animate-in fade-in duration-200">
|
||
|
|
|
||
|
|
<!-- Main Toolbar/Header -->
|
||
|
|
<div class="p-6 border-b border-base-200 bg-base-100/50 backdrop-blur-sm">
|
||
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||
|
|
<div>
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<h2 class="text-2xl font-bold font-display" x-text="selectedDef.VSName"></h2>
|
||
|
|
<span class="badge badge-primary badge-outline font-mono" x-text="'ID: ' + selectedDef.VSetID"></span>
|
||
|
|
</div>
|
||
|
|
<p class="text-base-content/70 mt-1 max-w-2xl" x-text="selectedDef.VSDesc || 'No description provided'"></p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex gap-2 shrink-0">
|
||
|
|
<button @click="openValueModal()" class="btn btn-primary gap-2 shadow-lg hover:translate-y-[-1px] transition-transform">
|
||
|
|
<i data-lucide="plus" class="w-4 h-4"></i>
|
||
|
|
Add Value
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Table -->
|
||
|
|
<div class="flex-1 overflow-x-auto bg-base-100">
|
||
|
|
<table class="table table-pin-rows table-lg">
|
||
|
|
<thead>
|
||
|
|
<tr class="bg-base-200/50 text-base-content/70">
|
||
|
|
<th class="w-24">VID</th>
|
||
|
|
<th class="w-1/4">Value</th>
|
||
|
|
<th>Description</th>
|
||
|
|
<th class="w-24 text-center">Order</th>
|
||
|
|
<th class="w-24 text-right">Actions</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<template x-for="val in values" :key="val.VID">
|
||
|
|
<tr class="hover group transition-colors border-base-100">
|
||
|
|
<td class="font-mono text-xs opacity-50" x-text="val.VID"></td>
|
||
|
|
|
||
|
|
<td>
|
||
|
|
<span class="font-bold" x-text="val.VValue"></span>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td class="text-base-content/80" x-text="val.VDesc || '-'"></td>
|
||
|
|
|
||
|
|
<td class="text-center">
|
||
|
|
<span class="badge badge-ghost badge-sm" x-show="val.SortOrder" x-text="val.SortOrder"></span>
|
||
|
|
</td>
|
||
|
|
|
||
|
|
<td class="text-right">
|
||
|
|
<div class="flex justify-end gap-1 opacity-50 group-hover:opacity-100 transition-opacity">
|
||
|
|
<button @click="editValue(val)" class="btn btn-sm btn-ghost btn-square text-primary" title="Edit Value">
|
||
|
|
<i data-lucide="pencil" class="w-4 h-4"></i>
|
||
|
|
</button>
|
||
|
|
<button @click="deleteValue(val)" class="btn btn-sm btn-ghost btn-square text-error" title="Delete Value">
|
||
|
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</template>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
|
||
|
|
<!-- Empty Table State -->
|
||
|
|
<div x-show="values.length === 0" class="flex flex-col items-center justify-center py-20 text-base-content/40">
|
||
|
|
<i data-lucide="inbox" class="w-12 h-12 mb-2 opacity-50"></i>
|
||
|
|
<p>No values found for this set.</p>
|
||
|
|
<button @click="openValueModal()" class="btn btn-link btn-sm mt-2">Add first value</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Footer count -->
|
||
|
|
<div class="p-3 border-t border-base-200 text-xs text-base-content/50 text-right bg-base-100">
|
||
|
|
<span x-text="values.length"></span> values
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Def Modal -->
|
||
|
|
<dialog class="modal" :class="{ 'modal-open': isDefModalOpen }">
|
||
|
|
<div class="modal-box transform transition-all">
|
||
|
|
<form method="dialog">
|
||
|
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="isDefModalOpen = false">✕</button>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<h3 class="font-bold text-xl mb-6 flex items-center gap-2">
|
||
|
|
<div class="bg-primary/10 p-2 rounded-lg text-primary">
|
||
|
|
<i data-lucide="book-open" class="w-6 h-6"></i>
|
||
|
|
</div>
|
||
|
|
<span x-text="isEditDef ? 'Edit Definition' : 'New Definition'"></span>
|
||
|
|
</h3>
|
||
|
|
|
||
|
|
<form @submit.prevent="saveDef">
|
||
|
|
<div class="space-y-4">
|
||
|
|
<!-- ID Field (Readonly/Hidden Logic) -->
|
||
|
|
<div class="form-control" x-show="isEditDef">
|
||
|
|
<label class="label"><span class="label-text font-medium">System ID</span></label>
|
||
|
|
<input type="text" x-model="defForm.VSetID" class="input input-sm input-bordered bg-base-200 font-mono" disabled />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label"><span class="label-text font-medium">Name</span></label>
|
||
|
|
<input type="text" x-model="defForm.VSName" class="input input-bordered focus:input-primary w-full" placeholder="e.g. Gender" required />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label"><span class="label-text font-medium">Description</span></label>
|
||
|
|
<textarea x-model="defForm.VSDesc" class="textarea textarea-bordered focus:textarea-primary h-24" placeholder="Describe the purpose of this value set..." required></textarea>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="modal-action">
|
||
|
|
<button type="button" class="btn" @click="isDefModalOpen = false">Cancel</button>
|
||
|
|
<button type="submit" class="btn btn-primary px-8">
|
||
|
|
Save Definition
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
<form method="dialog" class="modal-backdrop">
|
||
|
|
<button @click="isDefModalOpen = false">close</button>
|
||
|
|
</form>
|
||
|
|
</dialog>
|
||
|
|
|
||
|
|
<!-- Value Modal -->
|
||
|
|
<dialog class="modal" :class="{ 'modal-open': isValueModalOpen }">
|
||
|
|
<div class="modal-box transform transition-all">
|
||
|
|
<form method="dialog">
|
||
|
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" @click="isValueModalOpen = false">✕</button>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
<h3 class="font-bold text-xl mb-6 flex items-center gap-2">
|
||
|
|
<div class="bg-secondary/10 p-2 rounded-lg text-secondary">
|
||
|
|
<i data-lucide="list" class="w-6 h-6"></i>
|
||
|
|
</div>
|
||
|
|
<span x-text="isEditValue ? 'Edit Value' : 'Add New Value'"></span>
|
||
|
|
</h3>
|
||
|
|
|
||
|
|
<form @submit.prevent="saveValue">
|
||
|
|
<div class="space-y-4">
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label"><span class="label-text font-medium">Display Value</span></label>
|
||
|
|
<input type="text" x-model="valueForm.VValue" class="input input-bordered focus:input-primary w-full" placeholder="e.g. Male" required />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="grid grid-cols-2 gap-4">
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label"><span class="label-text font-medium">Description</span></label>
|
||
|
|
<input type="text" x-model="valueForm.VDesc" class="input input-bordered w-full" placeholder="Optional" />
|
||
|
|
</div>
|
||
|
|
<div class="form-control">
|
||
|
|
<label class="label"><span class="label-text font-medium">Sort Order</span></label>
|
||
|
|
<input type="number" x-model="valueForm.SortOrder" class="input input-bordered w-full" placeholder="0" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="modal-action">
|
||
|
|
<button type="button" class="btn" @click="isValueModalOpen = false">Cancel</button>
|
||
|
|
<button type="submit" class="btn btn-primary px-8">Save Value</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
<form method="dialog" class="modal-backdrop">
|
||
|
|
<button @click="isValueModalOpen = false">close</button>
|
||
|
|
</form>
|
||
|
|
</dialog>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<?= $this->endSection() ?>
|
||
|
|
|
||
|
|
<?= $this->section('script') ?>
|
||
|
|
<script type="module">
|
||
|
|
import Alpine, { Utils } from '<?= base_url('/assets/js/app.js'); ?>';
|
||
|
|
|
||
|
|
document.addEventListener('alpine:init', () => {
|
||
|
|
Alpine.data('valueSetManager', () => ({
|
||
|
|
defs: [],
|
||
|
|
values: [],
|
||
|
|
searchDef: '',
|
||
|
|
selectedDef: null,
|
||
|
|
|
||
|
|
// Def Modal
|
||
|
|
isDefModalOpen: false,
|
||
|
|
isEditDef: false,
|
||
|
|
defForm: { VSetID: '', VSName: '', VSDesc: '' },
|
||
|
|
|
||
|
|
// Value Modal
|
||
|
|
isValueModalOpen: false,
|
||
|
|
isEditValue: false,
|
||
|
|
valueForm: { VID: null, VValue: '', VDesc: '', SortOrder: '' },
|
||
|
|
|
||
|
|
init() {
|
||
|
|
this.loadDefs();
|
||
|
|
},
|
||
|
|
|
||
|
|
async loadDefs() {
|
||
|
|
try {
|
||
|
|
const res = await Utils.api('<?= site_url('api/valuesetdef/') ?>');
|
||
|
|
this.defs = res.data || [];
|
||
|
|
setTimeout(() => window.lucide?.createIcons(), 50);
|
||
|
|
} catch(e) { console.error(e); }
|
||
|
|
},
|
||
|
|
|
||
|
|
get filteredDefs() {
|
||
|
|
if(!this.searchDef) return this.defs;
|
||
|
|
return this.defs.filter(d =>
|
||
|
|
(d.VSName && d.VSName.toLowerCase().includes(this.searchDef.toLowerCase())) ||
|
||
|
|
(d.VSetID && d.VSetID.toString().includes(this.searchDef))
|
||
|
|
);
|
||
|
|
},
|
||
|
|
|
||
|
|
async selectDef(def) {
|
||
|
|
this.selectedDef = def;
|
||
|
|
this.values = [];
|
||
|
|
// Load values for this def
|
||
|
|
try {
|
||
|
|
// Note: VSetID is an integer (e.g. 1)
|
||
|
|
const res = await Utils.api(`<?= site_url('api/valueset/valuesetdef/') ?>${def.VSetID}`);
|
||
|
|
this.values = res.data || [];
|
||
|
|
setTimeout(() => window.lucide?.createIcons(), 50);
|
||
|
|
} catch(e) {
|
||
|
|
this.values = [];
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
// --- Def Actions ---
|
||
|
|
openDefModal() {
|
||
|
|
this.isEditDef = false;
|
||
|
|
this.defForm = { VSetID: '', VSName: '', VSDesc: '' };
|
||
|
|
this.isDefModalOpen = true;
|
||
|
|
},
|
||
|
|
|
||
|
|
editDef(def) {
|
||
|
|
this.isEditDef = true;
|
||
|
|
this.defForm = { ...def };
|
||
|
|
this.isDefModalOpen = true;
|
||
|
|
// Since this might be triggered from the list, prevent event bubbling handled in @click.stop
|
||
|
|
},
|
||
|
|
|
||
|
|
async saveDef() {
|
||
|
|
try {
|
||
|
|
const method = this.isEditDef ? 'PATCH' : 'POST';
|
||
|
|
await Utils.api('<?= site_url('api/valuesetdef') ?>', {
|
||
|
|
method,
|
||
|
|
body: JSON.stringify(this.defForm)
|
||
|
|
});
|
||
|
|
Alpine.store('toast').success(this.isEditDef ? 'Updated definition' : 'Created definition');
|
||
|
|
this.isDefModalOpen = false;
|
||
|
|
this.loadDefs();
|
||
|
|
|
||
|
|
// If we edited the currently selected one, update it
|
||
|
|
if(this.selectedDef && this.defForm.VSetID === this.selectedDef.VSetID) {
|
||
|
|
this.selectedDef = { ...this.defForm };
|
||
|
|
}
|
||
|
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||
|
|
},
|
||
|
|
|
||
|
|
// --- Value Actions ---
|
||
|
|
openValueModal() {
|
||
|
|
this.isEditValue = false;
|
||
|
|
this.valueForm = { VID: null, VValue: '', VDesc: '', SortOrder: '', VSetID: this.selectedDef.VSetID };
|
||
|
|
this.isValueModalOpen = true;
|
||
|
|
},
|
||
|
|
|
||
|
|
editValue(val) {
|
||
|
|
this.isEditValue = true;
|
||
|
|
this.valueForm = { ...val };
|
||
|
|
this.isValueModalOpen = true;
|
||
|
|
},
|
||
|
|
|
||
|
|
async saveValue() {
|
||
|
|
try {
|
||
|
|
const method = this.isEditValue ? 'PATCH' : 'POST';
|
||
|
|
this.valueForm.VSetID = this.selectedDef.VSetID;
|
||
|
|
|
||
|
|
await Utils.api('<?= site_url('api/valueset') ?>', {
|
||
|
|
method,
|
||
|
|
body: JSON.stringify(this.valueForm)
|
||
|
|
});
|
||
|
|
Alpine.store('toast').success(this.isEditValue ? 'Updated value' : 'Added value');
|
||
|
|
this.isValueModalOpen = false;
|
||
|
|
this.selectDef(this.selectedDef); // Refresh values
|
||
|
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||
|
|
},
|
||
|
|
|
||
|
|
async deleteValue(val) {
|
||
|
|
if(!confirm('Delete this value?')) return;
|
||
|
|
try {
|
||
|
|
await Utils.api('<?= site_url('api/valueset') ?>', {
|
||
|
|
method: 'DELETE',
|
||
|
|
body: JSON.stringify({ VID: val.VID })
|
||
|
|
});
|
||
|
|
Alpine.store('toast').success('Deleted');
|
||
|
|
this.selectDef(this.selectedDef);
|
||
|
|
} catch(e) { Alpine.store('toast').error(e.message); }
|
||
|
|
}
|
||
|
|
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
Alpine.start();
|
||
|
|
</script>
|
||
|
|
<?= $this->endSection() ?>
|