- Add BaseModel with automatic camel/snake case conversion - Add stringcase_helper with camel_to_snake(), snake_to_camel() functions - Update all models to extend BaseModel for consistent data handling - Update API controllers with standardized JSON response format - Remove legacy v1 PHP application directory - Consolidate documentation into AGENTS.md, delete VIEWS_RULES.md
232 lines
9.1 KiB
PHP
232 lines
9.1 KiB
PHP
<?= $this->extend("layout/main_layout"); ?>
|
|
<?= $this->section("content") ?>
|
|
<main x-data="testIndex()">
|
|
<!-- Page Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">Test Dictionary</h1>
|
|
<p class="text-sm text-slate-500 mt-1">Manage test types, methods, and units</p>
|
|
</div>
|
|
<button @click="showForm()" class="btn btn-primary">
|
|
<i class="fa-solid fa-plus mr-2"></i>Add Test
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Error Alert -->
|
|
<div x-show="error" x-transition class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fa-solid fa-circle-exclamation"></i>
|
|
<span x-text="error"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Card -->
|
|
<div class="bg-white rounded-xl border border-slate-100 shadow-sm p-4 mb-6">
|
|
<div class="flex gap-3">
|
|
<div class="flex-1 relative">
|
|
<i class="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></i>
|
|
<input type="text" x-model="keyword" @keyup.enter="fetchList()" class="w-full pl-10 pr-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Search tests...">
|
|
</div>
|
|
<button @click="fetchList()" class="btn btn-primary">
|
|
<i class="fa-solid fa-magnifying-glass mr-2"></i>Search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Data Table Card -->
|
|
<div class="bg-white rounded-xl border border-slate-100 shadow-sm overflow-hidden">
|
|
<!-- Loading State -->
|
|
<template x-if="loading">
|
|
<div class="p-12 text-center">
|
|
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mx-auto mb-4">
|
|
<i class="fa-solid fa-spinner fa-spin text-blue-500 text-xl"></i>
|
|
</div>
|
|
<p class="text-slate-500 text-sm">Loading tests...</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Empty State -->
|
|
<template x-if="!loading && (!list || list.length === 0)">
|
|
<div class="flex-1 flex items-center justify-center p-8">
|
|
<div class="text-center">
|
|
<div class="w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mx-auto mb-3">
|
|
<i class="fa-solid fa-vial text-slate-400 text-xl"></i>
|
|
</div>
|
|
<p class="text-slate-500 text-sm">No tests found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Data Table -->
|
|
<template x-if="!loading && list && list.length > 0">
|
|
<table class="w-full text-sm text-left">
|
|
<thead class="bg-slate-50 text-slate-500 text-xs uppercase tracking-wider">
|
|
<tr>
|
|
<th class="py-3 px-5 font-semibold">#</th>
|
|
<th class="py-3 px-5 font-semibold">Name</th>
|
|
<th class="py-3 px-5 font-semibold">Department</th>
|
|
<th class="py-3 px-5 font-semibold">Unit</th>
|
|
<th class="py-3 px-5 font-semibold">Method</th>
|
|
<th class="py-3 px-5 font-semibold text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-100">
|
|
<template x-for="(item, index) in list" :key="item.test_id">
|
|
<tr class="hover:bg-slate-50/50 transition-colors">
|
|
<td class="py-3 px-5" x-text="index + 1"></td>
|
|
<td class="py-3 px-5 font-medium text-slate-800" x-text="item.name"></td>
|
|
<td class="py-3 px-5 text-slate-600" x-text="item.dept_name || '-'"></td>
|
|
<td class="py-3 px-5 text-slate-600" x-text="item.unit || '-'"></td>
|
|
<td class="py-3 px-5 text-slate-600" x-text="item.method || '-'"></td>
|
|
<td class="py-3 px-5 text-right">
|
|
<button @click="showForm(item.test_id)" class="text-blue-600 hover:text-blue-800 mr-3">
|
|
<i class="fa-solid fa-pen-to-square"></i>
|
|
</button>
|
|
<button @click="deleteItem(item.test_id)" class="text-red-600 hover:text-red-800">
|
|
<i class="fa-solid fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Dialog Include -->
|
|
<?= $this->include('test/dialog_form'); ?>
|
|
</main>
|
|
<?= $this->endSection(); ?>
|
|
|
|
<?= $this->section("script") ?>
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data("testIndex", () => ({
|
|
loading: false,
|
|
showModal: false,
|
|
list: [],
|
|
form: {},
|
|
errors: {},
|
|
error: '',
|
|
keyword: '',
|
|
depts: <?= json_encode($depts ?? []) ?>,
|
|
|
|
init() {
|
|
this.fetchList();
|
|
},
|
|
|
|
async fetchList() {
|
|
this.loading = true;
|
|
this.error = '';
|
|
try {
|
|
const res = await fetch(`${window.BASEURL}/api/test`);
|
|
const data = await res.json();
|
|
if (data.status === 'success') {
|
|
this.list = data.data || [];
|
|
} else {
|
|
this.error = data.message || 'Failed to load tests';
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
this.error = 'Network error. Please try again.';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
showForm(id = null) {
|
|
this.errors = {};
|
|
if (id) {
|
|
const item = this.list.find(x => x.test_id === id);
|
|
if (item) {
|
|
this.form = {
|
|
test_id: item.test_id,
|
|
dept_ref_id: item.dept_ref_id,
|
|
name: item.name,
|
|
unit: item.unit || '',
|
|
method: item.method || '',
|
|
cva: item.cva || '',
|
|
ba: item.ba || '',
|
|
tea: item.tea || ''
|
|
};
|
|
}
|
|
} else {
|
|
this.form = {
|
|
dept_ref_id: '',
|
|
name: '',
|
|
unit: '',
|
|
method: '',
|
|
cva: '',
|
|
ba: '',
|
|
tea: ''
|
|
};
|
|
}
|
|
this.showModal = true;
|
|
},
|
|
|
|
closeModal() {
|
|
this.showModal = false;
|
|
this.errors = {};
|
|
this.form = {};
|
|
},
|
|
|
|
validate() {
|
|
this.errors = {};
|
|
if (!this.form.dept_ref_id) this.errors.dept_ref_id = 'Department is required';
|
|
if (!this.form.name) this.errors.name = 'Test name is required';
|
|
return Object.keys(this.errors).length === 0;
|
|
},
|
|
|
|
async save() {
|
|
if (!this.validate()) return;
|
|
this.loading = true;
|
|
try {
|
|
const url = this.form.test_id
|
|
? `${window.BASEURL}/api/test/${this.form.test_id}`
|
|
: `${window.BASEURL}/api/test`;
|
|
|
|
const res = await fetch(url, {
|
|
method: this.form.test_id ? 'PATCH' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.form)
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.status === 'success') {
|
|
this.closeModal();
|
|
this.fetchList();
|
|
} else {
|
|
this.error = data.message || 'Failed to save test';
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
this.error = 'Network error. Please try again.';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
async deleteItem(id) {
|
|
if (!confirm('Are you sure you want to delete this test?')) return;
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(`${window.BASEURL}/api/test/${id}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (data.status === 'success') {
|
|
this.fetchList();
|
|
} else {
|
|
this.error = data.message || 'Failed to delete test';
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
this.error = 'Network error. Please try again.';
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}));
|
|
});
|
|
</script>
|
|
<?= $this->endSection(); ?>
|
|
|