2025-12-30 14:30:35 +07:00
|
|
|
<?= $this->extend("v2/layout/main_layout"); ?>
|
|
|
|
|
|
|
|
|
|
<?= $this->section("content") ?>
|
|
|
|
|
<div x-data="containers()" 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-pink-600 to-pink-900 flex items-center justify-center shadow-lg">
|
|
|
|
|
<i class="fa-solid fa-flask-vial text-2xl text-white"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="text-2xl font-bold" style="color: rgb(var(--color-text));">Container Definitions</h2>
|
|
|
|
|
<p class="text-sm" style="color: rgb(var(--color-text-muted));">Manage specimen collection containers and tubes</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Search & Actions Bar -->
|
|
|
|
|
<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 containers..."
|
|
|
|
|
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 Container
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Content Card -->
|
|
|
|
|
<div class="card overflow-hidden">
|
|
|
|
|
<!-- Loading State -->
|
|
|
|
|
<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 containers...</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Table -->
|
|
|
|
|
<div class="overflow-x-auto" x-show="!loading">
|
|
|
|
|
<table class="table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>ID</th>
|
|
|
|
|
<th>Container Name</th>
|
|
|
|
|
<th>Code</th>
|
|
|
|
|
<th>Color</th>
|
|
|
|
|
<th>Additive</th>
|
|
|
|
|
<th class="text-center">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<!-- Empty State -->
|
|
|
|
|
<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 containers found</p>
|
|
|
|
|
<button class="btn btn-primary btn-sm mt-2" @click="showForm()">
|
|
|
|
|
<i class="fa-solid fa-plus mr-1"></i> Add First Container
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- Container Rows -->
|
|
|
|
|
<template x-for="con in list" :key="con.ConDefID">
|
|
|
|
|
<tr class="hover:bg-opacity-50">
|
|
|
|
|
<td>
|
|
|
|
|
<span class="badge badge-ghost font-mono text-xs" x-text="con.ConDefID || '-'"></span>
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="font-medium" style="color: rgb(var(--color-text));" x-text="con.ConName || '-'"></div>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="font-mono text-sm" x-text="con.ConCode || '-'"></td>
|
|
|
|
|
<td>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<div class="w-3 h-3 rounded-full border border-slate-300" :style="`background-color: ${con.Color || 'transparent'}`"></div>
|
2026-01-12 16:53:41 +07:00
|
|
|
<span x-text="con.ColorText || con.Color || '-'"></span>
|
2025-12-30 14:30:35 +07:00
|
|
|
</div>
|
|
|
|
|
</td>
|
2026-01-12 16:53:41 +07:00
|
|
|
<td x-text="con.AdditiveText || con.Additive || '-'"></td>
|
2025-12-30 14:30:35 +07:00
|
|
|
<td class="text-center">
|
|
|
|
|
<div class="flex items-center justify-center gap-1">
|
|
|
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="editContainer(con.ConDefID)" title="Edit">
|
|
|
|
|
<i class="fa-solid fa-pen text-sky-500"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<button class="btn btn-ghost btn-sm btn-square" @click="confirmDelete(con)" title="Delete">
|
|
|
|
|
<i class="fa-solid fa-trash" style="color: rgb(var(--color-error));"></i>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</template>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Include Form Dialog -->
|
|
|
|
|
<?= $this->include('v2/master/specimen/container_dialog') ?>
|
|
|
|
|
|
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
|
|
|
<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 container <strong x-text="deleteTarget?.ConName"></strong>?
|
|
|
|
|
This action 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="deleteContainer()" :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 containers() {
|
|
|
|
|
return {
|
|
|
|
|
// State
|
|
|
|
|
loading: false,
|
|
|
|
|
list: [],
|
|
|
|
|
sitesList: [],
|
|
|
|
|
keyword: "",
|
|
|
|
|
|
|
|
|
|
// Form Modal
|
|
|
|
|
showModal: false,
|
|
|
|
|
isEditing: false,
|
|
|
|
|
saving: false,
|
|
|
|
|
errors: {},
|
|
|
|
|
form: {
|
|
|
|
|
ConDefID: null,
|
|
|
|
|
ConCode: "",
|
|
|
|
|
ConName: "",
|
|
|
|
|
ConDesc: "",
|
|
|
|
|
Additive: "",
|
|
|
|
|
ConClass: "",
|
|
|
|
|
Color: "",
|
|
|
|
|
SiteID: ""
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Delete Modal
|
|
|
|
|
showDeleteModal: false,
|
|
|
|
|
deleteTarget: null,
|
|
|
|
|
deleting: false,
|
|
|
|
|
|
2026-01-12 16:53:41 +07:00
|
|
|
// Lookup Options
|
|
|
|
|
colorOptions: [],
|
|
|
|
|
additiveOptions: [],
|
|
|
|
|
classOptions: [],
|
|
|
|
|
|
2025-12-30 14:30:35 +07:00
|
|
|
// Lifecycle
|
|
|
|
|
async init() {
|
|
|
|
|
await this.fetchList();
|
|
|
|
|
await this.fetchSites();
|
2026-01-12 16:53:41 +07:00
|
|
|
await this.fetchColorOptions();
|
|
|
|
|
await this.fetchAdditiveOptions();
|
|
|
|
|
await this.fetchClassOptions();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Fetch color options
|
|
|
|
|
async fetchColorOptions() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/valueset/container_cap_color`, {
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
this.colorOptions = data.data || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch color options:', err);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Fetch additive options
|
|
|
|
|
async fetchAdditiveOptions() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/valueset/additive`, {
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
this.additiveOptions = data.data || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch additive options:', err);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Fetch class options
|
|
|
|
|
async fetchClassOptions() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/valueset/container_class`, {
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error("HTTP error");
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
this.classOptions = data.data || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch class options:', err);
|
|
|
|
|
}
|
2025-12-30 14:30:35 +07:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Fetch container list
|
|
|
|
|
async fetchList() {
|
|
|
|
|
this.loading = true;
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (this.keyword) params.append('ConName', this.keyword);
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${BASEURL}api/specimen/container?${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 = [];
|
|
|
|
|
} finally {
|
|
|
|
|
this.loading = false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Fetch site list for dropdown
|
|
|
|
|
async fetchSites() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/organization/site`, {
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
this.sitesList = data.data || [];
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Failed to fetch sites:', err);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Show form for new container
|
|
|
|
|
showForm() {
|
|
|
|
|
this.isEditing = false;
|
|
|
|
|
this.form = {
|
|
|
|
|
ConDefID: null,
|
|
|
|
|
ConCode: "",
|
|
|
|
|
ConName: "",
|
|
|
|
|
ConDesc: "",
|
|
|
|
|
Additive: "",
|
|
|
|
|
ConClass: "",
|
|
|
|
|
Color: "",
|
|
|
|
|
SiteID: ""
|
|
|
|
|
};
|
|
|
|
|
this.errors = {};
|
|
|
|
|
this.showModal = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Edit container
|
|
|
|
|
async editContainer(id) {
|
|
|
|
|
this.isEditing = true;
|
|
|
|
|
this.errors = {};
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/specimen/container/${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);
|
|
|
|
|
alert('Failed to load container data');
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Validate form
|
|
|
|
|
validate() {
|
|
|
|
|
const e = {};
|
|
|
|
|
if (!this.form.ConName?.trim()) e.ConName = "Container name is required";
|
|
|
|
|
if (!this.form.ConCode?.trim()) e.ConCode = "Container code is required";
|
|
|
|
|
this.errors = e;
|
|
|
|
|
return Object.keys(e).length === 0;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Close modal
|
|
|
|
|
closeModal() {
|
|
|
|
|
this.showModal = false;
|
|
|
|
|
this.errors = {};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Save container
|
|
|
|
|
async save() {
|
|
|
|
|
if (!this.validate()) return;
|
|
|
|
|
|
|
|
|
|
this.saving = true;
|
|
|
|
|
try {
|
|
|
|
|
const method = this.isEditing ? 'PATCH' : 'POST';
|
|
|
|
|
const res = await fetch(`${BASEURL}api/specimen/container`, {
|
|
|
|
|
method: method,
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(this.form),
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
this.closeModal();
|
|
|
|
|
await this.fetchList();
|
|
|
|
|
} else {
|
|
|
|
|
alert(data.message || "Failed to save");
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
alert("Failed to save container");
|
|
|
|
|
} finally {
|
|
|
|
|
this.saving = false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Confirm delete
|
|
|
|
|
confirmDelete(con) {
|
|
|
|
|
this.deleteTarget = con;
|
|
|
|
|
this.showDeleteModal = true;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Delete container
|
|
|
|
|
async deleteContainer() {
|
|
|
|
|
if (!this.deleteTarget) return;
|
|
|
|
|
|
|
|
|
|
this.deleting = true;
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${BASEURL}api/specimen/container`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ ConDefID: this.deleteTarget.ConDefID }),
|
|
|
|
|
credentials: 'include'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
this.showDeleteModal = false;
|
|
|
|
|
await this.fetchList();
|
|
|
|
|
} else {
|
|
|
|
|
alert("Failed to delete");
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
alert("Failed to delete container");
|
|
|
|
|
} finally {
|
|
|
|
|
this.deleting = false;
|
|
|
|
|
this.deleteTarget = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
<?= $this->endSection() ?>
|