gdc_cmod/app/Views/phlebo/collect.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(); ?>