342 lines
15 KiB
PHP
342 lines
15 KiB
PHP
<?php
|
|
$config = include __DIR__ . '/../shared/config.php';
|
|
$roleConfig = $config['phlebo'];
|
|
?>
|
|
<?= $this->extend('shared/layout', ['roleConfig' => $roleConfig]); ?>
|
|
|
|
<?= $this->section('content'); ?>
|
|
<main class="p-2 flex-1 flex flex-col items-center overflow-hidden min-h-0" x-data="collectionPage">
|
|
<div class="card bg-base-100 shadow-lg border border-base-200 w-full max-w-4xl h-full">
|
|
<div class="card-body p-4 overflow-y-auto">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="card-title text-xl font-bold flex items-center gap-2">
|
|
<i class="fa fa-vial text-primary"></i> Specimen Collection
|
|
</h2>
|
|
<div class="text-xs text-base-content/60">
|
|
<i class="fa fa-user"></i> <?= esc(session('userid')) ?>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-control mb-3">
|
|
<label class="label py-1">
|
|
<span class="label-text text-sm font-medium">Access Number</span>
|
|
</label>
|
|
<div class="join w-full">
|
|
<input type="text"
|
|
x-model="accessnumber"
|
|
@keyup.enter="fetchPatientData()"
|
|
@keydown.esc="resetForm()"
|
|
placeholder="Scan or type access number and press Enter..."
|
|
class="input input-bordered input-sm join-item w-full"
|
|
x-ref="accessInput"
|
|
autofocus>
|
|
<button class="btn btn-primary btn-sm join-item" @click="fetchPatientData()">
|
|
<i class="fa fa-search"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template x-if="isLoading">
|
|
<div class="text-center py-6">
|
|
<span class="loading loading-spinner loading-md text-primary"></span>
|
|
<p class="mt-1 text-sm">Loading...</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="patient.name && !isLoading">
|
|
<div class="space-y-3">
|
|
<div class="bg-base-200 p-3 rounded-lg">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="font-bold text-sm text-primary">Patient Information</h3>
|
|
<span class="text-xs badge badge-sm" :class="patient.sex === 'M' ? 'badge-info' : 'badge-secondary'" x-text="patient.sex"></span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
|
<div class="truncate">
|
|
<span class="text-base-content/60">Name:</span>
|
|
<span class="font-medium" x-text="patient.name"></span>
|
|
</div>
|
|
<div>
|
|
<span class="text-base-content/60">Age:</span>
|
|
<span class="font-medium" x-text="patient.age + 'Y'"></span>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<span class="text-base-content/60">Access:</span>
|
|
<span class="font-mono font-medium" x-text="patient.accessnumber"></span>
|
|
</div>
|
|
</div>
|
|
<div x-show="patient.comment" class="mt-1 text-xs">
|
|
<span class="text-base-content/60">Note:</span>
|
|
<span class="font-medium" x-text="patient.comment"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between mb-1">
|
|
<h3 class="font-bold text-sm">Required Tubes</h3>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-xs btn-ghost" @click="selectAll()" x-show="hasUncollectedSamples()">
|
|
<i class="fa fa-check-square"></i> All
|
|
</button>
|
|
<button class="btn btn-xs btn-ghost" @click="deselectAll()" x-show="hasSelectedSamples()">
|
|
<i class="fa fa-square-o"></i> None
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto border rounded-lg">
|
|
<table class="table table-xs w-full">
|
|
<thead class="bg-base-200">
|
|
<tr>
|
|
<th class="w-10 text-center p-2">
|
|
<input type="checkbox"
|
|
class="checkbox checkbox-xs checkbox-primary"
|
|
@change="toggleAll($event.target.checked)"
|
|
:checked="allSelected()"
|
|
:indeterminate="someSelected()">
|
|
</th>
|
|
<th class="p-2">Code</th>
|
|
<th class="p-2">Tube</th>
|
|
<th class="w-20 text-center p-2">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="sample in samples" :key="sample.sampcode">
|
|
<tr :class="sample.colstatus == 1 ? 'bg-success/10' : (sample.selected ? 'bg-warning/10' : '')">
|
|
<td class="text-center p-2">
|
|
<input type="checkbox"
|
|
class="checkbox checkbox-xs checkbox-primary"
|
|
x-model="sample.selected"
|
|
:checked="sample.colstatus == 1"
|
|
:disabled="sample.colstatus == 1">
|
|
</td>
|
|
<td class="font-mono text-xs p-2" x-text="sample.sampcode"></td>
|
|
<td class="text-xs p-2" x-text="sample.name"></td>
|
|
<td class="text-center p-2">
|
|
<span x-show="sample.colstatus == 1" class="badge badge-success badge-xs">Done</span>
|
|
<span x-show="sample.colstatus != 1 && sample.selected" class="badge badge-warning badge-xs">Ready</span>
|
|
<span x-show="sample.colstatus != 1 && !sample.selected" class="badge badge-ghost badge-xs">-</span>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label py-1">
|
|
<span class="label-text text-xs">Comment</span>
|
|
</label>
|
|
<input type="text"
|
|
x-model="comment"
|
|
placeholder="Add comment..."
|
|
class="input input-bordered input-sm w-full">
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between pt-2 border-t">
|
|
<div class="text-xs text-base-content/60">
|
|
<span x-text="getSelectedCount()"></span> of <span x-text="samples.length"></span> selected
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-sm btn-ghost" @click="resetForm()">
|
|
<i class="fa fa-refresh"></i> Clear
|
|
</button>
|
|
<button class="btn btn-sm btn-primary"
|
|
@click="saveCollection()"
|
|
:disabled="isSaving || !hasSelectedSamples()"
|
|
:class="isSaving ? 'loading' : ''">
|
|
<i class="fa fa-save mr-1" x-show="!isSaving"></i>
|
|
<span x-text="isSaving ? 'Saving' : 'Save'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!patient.name && !isLoading && searched">
|
|
<div class="alert alert-warning alert-sm py-2">
|
|
<i class="fa fa-exclamation-triangle"></i>
|
|
<span class="text-sm">No patient found</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<?= $this->endSection(); ?>
|
|
|
|
<?= $this->section('script'); ?>
|
|
<script type="module">
|
|
import Alpine from '<?= base_url("js/app.js"); ?>';
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('collectionPage', () => ({
|
|
userid: '<?= session("userid") ?>',
|
|
accessnumber: '',
|
|
patient: {},
|
|
samples: [],
|
|
comment: '',
|
|
isLoading: false,
|
|
isSaving: false,
|
|
searched: false,
|
|
|
|
init() {
|
|
this.$nextTick(() => {
|
|
this.$refs.accessInput.focus();
|
|
});
|
|
},
|
|
|
|
async fetchPatientData() {
|
|
if (!this.accessnumber.trim()) return;
|
|
|
|
this.isLoading = true;
|
|
this.searched = true;
|
|
|
|
try {
|
|
const response = await fetch(`${BASEURL}/api/samples/${this.accessnumber}`, {
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.data) {
|
|
this.patient = {
|
|
name: data.data.patname || '',
|
|
age: data.data.age || '',
|
|
sex: data.data.gender || '',
|
|
accessnumber: data.data.accessnumber || this.accessnumber,
|
|
comment: data.data.comment || ''
|
|
};
|
|
this.comment = data.data.comment || '';
|
|
|
|
this.samples = (data.data.samples || []).map(s => ({
|
|
...s,
|
|
selected: s.colstatus == 1
|
|
}));
|
|
} else {
|
|
this.patient = {};
|
|
this.samples = [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showToast('Error loading data', 'error');
|
|
} finally {
|
|
this.isLoading = false;
|
|
}
|
|
},
|
|
|
|
hasSelectedSamples() {
|
|
return this.samples.some(s => s.selected && s.colstatus != 1);
|
|
},
|
|
|
|
hasUncollectedSamples() {
|
|
return this.samples.some(s => s.colstatus != 1 && !s.selected);
|
|
},
|
|
|
|
getSelectedCount() {
|
|
return this.samples.filter(s => s.selected && s.colstatus != 1).length;
|
|
},
|
|
|
|
allSelected() {
|
|
const uncollected = this.samples.filter(s => s.colstatus != 1);
|
|
return uncollected.length > 0 && uncollected.every(s => s.selected);
|
|
},
|
|
|
|
someSelected() {
|
|
const uncollected = this.samples.filter(s => s.colstatus != 1);
|
|
const selectedCount = uncollected.filter(s => s.selected).length;
|
|
return selectedCount > 0 && selectedCount < uncollected.length;
|
|
},
|
|
|
|
toggleAll(checked) {
|
|
this.samples.forEach(s => {
|
|
if (s.colstatus != 1) {
|
|
s.selected = checked;
|
|
}
|
|
});
|
|
},
|
|
|
|
selectAll() {
|
|
this.samples.forEach(s => {
|
|
if (s.colstatus != 1) s.selected = true;
|
|
});
|
|
},
|
|
|
|
deselectAll() {
|
|
this.samples.forEach(s => {
|
|
if (s.colstatus != 1) s.selected = false;
|
|
});
|
|
},
|
|
|
|
async saveCollection() {
|
|
this.isSaving = true;
|
|
|
|
try {
|
|
const samplesToCollect = this.samples.filter(s => s.selected && s.colstatus != 1);
|
|
|
|
for (const sample of samplesToCollect) {
|
|
await fetch(`${BASEURL}/api/samples/collect/${this.patient.accessnumber}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
samplenumber: sample.sampcode,
|
|
userid: this.userid
|
|
})
|
|
});
|
|
}
|
|
|
|
if (this.comment !== this.patient.comment) {
|
|
await this.saveComment();
|
|
}
|
|
|
|
this.showToast(`Collected ${samplesToCollect.length} sample(s)`, 'success');
|
|
|
|
setTimeout(() => {
|
|
this.resetForm();
|
|
}, 1000);
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showToast('Save failed', 'error');
|
|
} finally {
|
|
this.isSaving = false;
|
|
}
|
|
},
|
|
|
|
async saveComment() {
|
|
try {
|
|
await fetch(`${BASEURL}/api/requests/comment/${this.patient.accessnumber}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
comment: this.comment,
|
|
userid: this.userid
|
|
})
|
|
});
|
|
} catch (error) {
|
|
console.error('Error saving comment:', error);
|
|
}
|
|
},
|
|
|
|
resetForm() {
|
|
this.accessnumber = '';
|
|
this.patient = {};
|
|
this.samples = [];
|
|
this.comment = '';
|
|
this.searched = false;
|
|
this.$nextTick(() => {
|
|
this.$refs.accessInput.focus();
|
|
});
|
|
},
|
|
|
|
showToast(message, type = 'success') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `alert alert-${type} fixed top-4 right-4 z-50 alert-sm`;
|
|
toast.innerHTML = `<i class="fa ${type === 'error' ? 'fa-times-circle' : 'fa-check-circle'}"></i> ${message}`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 2500);
|
|
}
|
|
}));
|
|
});
|
|
|
|
Alpine.start();
|
|
</script>
|
|
<?= $this->endSection(); ?>
|