402 lines
15 KiB
PHP

<?= $this->extend('superuser/main'); ?>
<?= $this->section('content') ?>
<main class="p-4 flex-1 flex flex-col gap-2" x-data="dashboard">
<div class="card bg-base-100 shadow-xl h-full border border-base-200 overflow-hidden">
<div class="card-body p-0 h-full flex flex-col">
<!-- Header & Filters -->
<div class="p-4 border-b border-base-200 bg-base-50">
<div class="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<div class="flex-1">
<h2 class="text-2xl font-bold flex items-center gap-2 text-base-content">
<i class="fa fa-chart-bar text-primary"></i> Requests Overview
</h2>
</div>
<!-- Status Filters -->
<div class="join shadow-sm bg-base-100 rounded-lg">
<button @click="filterKey = 'Total'" :class="filterKey === 'Total' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">
All <span class="badge badge-sm badge-ghost ml-1" x-text="counters.Total"></span>
</button>
<button @click="filterKey = 'Pend'" :class="filterKey === 'Pend' ? 'btn-active btn-neutral text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Pending <span class="badge badge-sm ml-1" x-text="counters.Pend"></span>
</button>
<button @click="filterKey = 'Coll'" :class="filterKey === 'Coll' ? 'btn-active btn-warning text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Coll <span class="badge badge-sm badge-warning ml-1" x-text="counters.Coll"></span>
</button>
<button @click="filterKey = 'Recv'" :class="filterKey === 'Recv' ? 'btn-active btn-info text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Recv <span class="badge badge-sm badge-info ml-1" x-text="counters.Recv"></span>
</button>
<button @click="filterKey = 'Inc'" :class="filterKey === 'Inc' ? 'btn-active btn-error text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Inc <span class="badge badge-sm badge-error ml-1" x-text="counters.Inc"></span>
</button>
<button @click="filterKey = 'Fin'" :class="filterKey === 'Fin' ? 'btn-active btn-success text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Fin <span class="badge badge-sm badge-success ml-1" x-text="counters.Fin"></span>
</button>
<button @click="filterKey = 'Validated'" :class="filterKey === 'Validated' ? 'btn-active btn-primary text-white' : 'btn-ghost'" class="btn btn-sm join-item">
Val <span class="badge badge-sm badge-primary ml-1" x-text="validatedCount"></span>
</button>
</div>
</div>
<!-- Search & Date Filter -->
<div class="flex flex-col md:flex-row gap-3 items-end bg-base-100 p-3 rounded-lg border border-base-200 shadow-sm">
<div class="form-control">
<label class="label text-xs font-bold py-1 text-base-content/60">Date Range</label>
<div class="join">
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date1"/>
<span class="join-item btn btn-sm btn-ghost no-animation bg-base-200 font-normal px-2">-</span>
<input type="date" class="input input-sm input-bordered join-item" x-model="filter.date2"/>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-primary" @click='fetchList()'><i class='fa fa-search'></i> Search</button>
<button class="btn btn-sm btn-neutral" @click='reset()'><i class='fa fa-sync-alt'></i> Reset</button>
</div>
<span class="flex-1"></span>
<div class="form-control w-full md:w-auto">
<label class='input input-sm input-bordered'>
<i class="fa fa-filter"></i>
<input type="text" placeholder="Type to filter..." x-model="filterTable" />
</label>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<template x-if="isLoading">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'><div class="skeleton h-4 w-20"></div></th>
<th style='width:15%;'><div class="skeleton h-4 w-32"></div></th>
<th style='width:7%;'><div class="skeleton h-4 w-16"></div></th>
<th style='width:7%;'><div class="skeleton h-4 w-16"></div></th>
<th style='width:8%;'><div class="skeleton h-4 w-16"></div></th>
<th style='width:8%;'><div class="skeleton h-4 w-20"></div></th>
<th style='width:15%;'><div class="skeleton h-4 w-32"></div></th>
<th style='width:3%;'><div class="skeleton h-4 w-12"></div></th>
<th style='width:5%;'><div class="skeleton h-4 w-16"></div></th>
<th style='width:5%;'><div class="skeleton h-4 w-16"></div></th>
<th style='width:4%;'><div class="skeleton h-4 w-12"></div></th>
</tr>
</thead>
<tbody>
<template x-for="i in 5" :key="i">
<tr>
<td colspan="11"><div class="skeleton h-4 w-full"></div></td>
</tr>
</template>
</tbody>
</table>
</template>
<template x-if="!isLoading && !list.length">
<div class="text-center py-10">
<i class="fa fa-inbox text-4xl mb-2 opacity-50"></i>
<p>No records found</p>
</div>
</template>
<template x-if="!isLoading && list.length">
<table class="table table-xs table-zebra w-full">
<thead class="bg-base-100 sticky top-0 z-10">
<tr>
<th style='width:7%;'>Order Datetime</th>
<th style='width:15%;'>Patient Name</th>
<th style='width:7%;'>No Lab</th>
<th style='width:7%;'>No Register</th>
<th style='width:8%;'>Reff</th>
<th style='width:8%;'>Doctor</th>
<th style='width:15%;'>Tests</th>
<th style='width:3%;'>ResTo</th>
<th style='width:5%;'>Val</th>
<th style='width:5%;'>Result</th>
<th style='width:4%;'>Status</th>
</tr>
</thead>
<tbody>
<template x-for="req in filtered" :key="req.SP_ACCESSNUMBER">
<tr class="hover:bg-base-300">
<td x-text="req.REQDATE"></td>
<td x-text="req.Name"></td>
<td x-text="req.SP_ACCESSNUMBER"></td>
<td x-text="req.HOSTORDERNUMBER"></td>
<td x-text="req.REFF"></td>
<td x-text="req.DOC"></td>
<td x-text="req.TESTS"></td>
<td x-text="req.ODR_CRESULT_TO"></td>
<td>
<div class='flex gap-1 items-center'>
<div class='w-15'>
<p>1: <span x-text="req.VAL1USER"></span></p>
<p>2: <span x-text="req.VAL2USER"></span></p>
</div>
<template x-if="req.ISVAL == 1 && req.ISPENDING != 1">
<div class='text-center'>
<template x-if="req.VAL1USER == '<?=session('userid');?>' || req.VAL2USER == '<?=session('userid');?>'">
<button class="btn btn-xs btn-outline btn-secondary" @click="openUnvalDialog(req.SP_ACCESSNUMBER)"><i class="fa-solid fa-rotate-right"></i></button>
</template>
</div>
</template>
</div>
</td>
<td>
<template x-if="req.STATS !== 'PartColl' && req.STATS !== 'Coll' && req.STATS !== 'Pend'">
<button class="btn btn-xs btn-outline btn-primary" @click="openPreviewDialog(req.SP_ACCESSNUMBER, 'preview')">Preview</button>
</template>
</td>
<td><button x-text="req.STATS === 'Fin' ? 'Final' : req.STATS" class="btn btn-xs"
:class="statusColor[req.STATS]" @click="openSampleDialog(req.SP_ACCESSNUMBER)"></button></td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
</div>
<?php echo $this->include('superuser/dialog_sample'); ?>
<?php echo $this->include('superuser/dialog_unval'); ?>
<?php echo $this->include('superuser/dialog_preview'); ?>
</main>
<?= $this->endSection(); ?>
<?= $this->section('script') ?>
<script type="module">
import Alpine from '<?=base_url("js/app.js");?>';
document.addEventListener('alpine:init', () => {
Alpine.data("dashboard", ()=> ({
// dashboard
today: "",
filter: { date1: "", date2: "" },
list: [],
isLoading: false,
counters: { Pend: 0, Coll: 0, Recv: 0, Inc: 0, Fin: 0, Total: 0 },
statusColor: {
Pend: 'bg-white text-black font-bold',
PartColl: 'bg-orange-300 text-white font-bold',
Coll: 'bg-orange-500 text-white font-bold',
PartRecv: 'bg-blue-200 text-black font-bold',
Recv: 'bg-blue-500 text-white font-bold',
Inc: 'bg-yellow-500 text-white font-bold',
Fin: 'bg-green-500 text-white font-bold',
},
filterTable :"",
filterKey: 'Total',
statusMap: {
Total: [],
Pend: ['Pend'],
Coll: ['Coll', 'PartColl'],
Recv: ['Recv', 'PartRecv'],
Inc: ['Inc'],
Fin: ['Fin'],
},
init() {
this.today = new Date().toISOString().slice(0, 10);
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
fetchList(){
this.isLoading = true;
this.list = [];
let statusOrder = { Pend: 1, PartColl: 2, Coll: 3, PartRecv: 4, Recv: 5, Inc: 6, Fin: 7 };
let param = new URLSearchParams(this.filter).toString();
for (let k in this.counters) { this.counters[k] = 0; }
fetch(`${BASEURL}/api/requests?${param}`, {
method: 'GET',
headers: {'Content-Type': 'application/json'},
}).then(res => res.json()).then(data => {
this.list = data.data ?? [];
this.filterKey = 'Total';
this.list.forEach(item => {
if (this.counters[item.STATS] !== undefined) { this.counters[item.STATS]++; this.counters.Total++; }
else {
if(item.STATS == 'PartColl') { this.counters.Coll++; }
else if(item.STATS == 'PartRecv') { this.counters.Recv++; }
this.counters.Total++;
}
});
this.list.sort((a, b) => {
let codeA = statusOrder[a.STATS] ?? 0;
let codeB = statusOrder[b.STATS] ?? 0;
return codeA - codeB;
});
}).finally(() => {
this.isLoading = false;
});
},
reset() {
this.filter.date1 = this.today;
this.filter.date2 = this.today;
this.fetchList();
},
isValidated (item) {
return item.ISVAL == 1 && item.ISPENDING != 1;
},
get filtered() {
let filteredList = this.list;
if (this.filterKey === 'Validated') {
filteredList = filteredList.filter(item => this.isValidated(item));
} else {
const validStatuses = this.statusMap[this.filterKey];
if (validStatuses.length > 0) {
filteredList = filteredList.filter(item => validStatuses.includes(item.STATS));
}
}
if (this.filterTable) {
const searchTerm = this.filterTable.toLowerCase();
filteredList = filteredList.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
return filteredList;
},
get validatedCount() {
return this.list.filter(r => this.isValidated(r)).length;
},
/*
sample dialog
*/
item : '',
isDialogSampleOpen : false,
isSampleLoading: false,
openSampleDialog (accessnumber) {
this.isDialogSampleOpen = true;
this.fetchItem(accessnumber)
},
closeSampleDialog () {
this.isDialogSampleOpen = false;
},
fetchItem(accessnumber){
this.isSampleLoading = true;
this.item = [];
fetch(`${BASEURL}/api/samples/${accessnumber}`, { method: 'GET', headers: {'Content-Type': 'application/json'}})
.then(res => res.json()).then(data => {
this.item = data.data ?? {};
if (!Array.isArray(this.item.samples)) this.item.samples = [];
}).finally(() => {
this.isSampleLoading = false;
});
},
collect(sampcode, accessnumber) {
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({samplenumber: sampcode, userid: '<?= session('userid'); ?>'})
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
uncollect(sampcode, accessnumber) {
if(!confirm(`Uncollect sample ${sampcode} from request ${accessnumber}?`)) { return ;}
fetch(`${BASEURL}/api/samples/collect/${accessnumber}`, {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({samplenumber: sampcode, userid: '<?= session('userid'); ?>'})
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
unreceive(sampcode, accessnumber) {
if(!confirm(`Unreceive sample ${sampcode} from request ${accessnumber}?`)) { return ;}
fetch(`${BASEURL}/api/samples/unreceive/${accessnumber}`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({samplenumber: sampcode, userid : '<?= session('userid'); ?>'})
})
.then(res => res.json()).then(data => {
this.fetchItem(accessnumber);
});
},
/*
preview dialog
*/
isDialogPreviewOpen : false,
reviewed: false,
previewAccessnumber : null,
previewType : 'preview',
openPreviewDialog (accessnumber, type) {
this.previewAccessnumber = accessnumber;
this.previewType = type;
this.isDialogPreviewOpen = true;
this.reviewed = false;
},
closePreviewDialog () {
this.isDialogPreviewOpen = false;
},
setPreviewType(type) {
this.previewType = type;
},
getPreviewUrl() {
let base = 'http://glenlis/spooler_db/main_dev.php';
let url = `${base}?acc=${this.previewAccessnumber}`;
if (this.previewType === 'ind') url += '&lang=ID';
if (this.previewType === 'eng') url += '&lang=EN';
if (this.previewType === 'pdf') url += '&output=pdf';
return url;
},
validate(accessnumber, userid) {
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ userid:`${userid}` })
}).then(response => {
this.closePreviewDialog();
this.fetchList();
console.log('Validate clicked for', this.previewAccessnumber, 'by user', userid);
});
},
/*
unvalidate dialog
*/
isDialogUnvalOpen : false,
unvalReason : '',
unvalAccessnumber : null,
openUnvalDialog (accessnumber) {
this.unvalReason = '';
this.isDialogUnvalOpen = true;
this.unvalAccessnumber = accessnumber;
},
unvalidate(accessnumber, userid) {
if(!confirm(`Unvalidate request ${accessnumber}?`)) { return ;}
fetch(`${BASEURL}/api/requests/validate/${accessnumber}`, {
method: "DELETE",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ userid:`${userid}`, comment: this.unvalReason.trim() })
}).then(response => {
this.closeUnvalDialog();
this.fetchList();
console.log(`Unvalidate clicked for ${accessnumber}, by user ${userid}`);
});
},
closeUnvalDialog () {
this.isDialogUnvalOpen = false;
},
}));
});
Alpine.start();
</script>
<?= $this->endSection(); ?>