Files
web-antrean/pages/AdminLoket.vue
T
2026-01-15 10:29:32 +07:00

777 lines
21 KiB
Vue

<template>
<div class="loket-container">
<!-- Compact Header -->
<PageHeader
icon="mdi-view-dashboard"
title="Admin Loket"
:subtitle="currentDate"
:show-add-button="false"
theme="primary"
/>
<!-- Main Content Grid -->
<v-row class="content-grid" dense>
<!-- Left Column: Current Patient & Queue Actions -->
<v-col cols="12" md="5">
<div class="sticky-wrapper">
<!-- Current Patient Card -->
<CurrentPatientCard
:patient="currentProcessingPatient"
:has-next-queue="(diLoketPatients || []).length > 0"
:next-queue-info="nextQueueInfo"
@action="handlePatientAction"
@change-klinik="showChangeKlinikDialog = true"
@process-next="handleProcessNext"
@call="handleCallPatient"
@open-klinik-ruang="openKlinikRuangDialog"
@open-penunjang="openPenunjangDialog"
/>
<!-- Queue Actions Card -->
<QueueActionsCard
class="mt-3"
:total-quota="150"
:used-quota="quotaUsed"
:menunggu-count="menungguCount"
:has-next="!!nextPatient"
@call="handleCall"
/>
</div>
</v-col>
<!-- Right Column: Patient Table -->
<v-col cols="12" md="7">
<v-card class="patient-data-container" elevation="0">
<v-card-text class="pa-4">
<!-- Header with Filters -->
<div class="data-header mb-4">
<div class="section-label">CARI PASIEN</div>
<div class="filters">
<!-- Search Field -->
<v-text-field
v-model="searchQuery"
placeholder="Cari barcode, nomor antrian..."
density="compact"
hide-details
class="search-field"
prepend-inner-icon="mdi-magnify"
/>
</div>
</div>
<PatientDataTable
:items="allPatientsForStage"
v-model:selected-status="selectedStatus"
v-model:search-query="searchQuery"
v-model:selected-fast-track="selectedFastTrack"
:di-loket-count="diLoketCount"
:diproses-count="currentProcessingPatient ? 1 : 0"
:waiting-count="waitingCount"
:terlambat-count="(terlambatPatients || []).length"
:pending-count="(pendingPatients || []).length"
:show-diproses="false"
:fast-track-options="fastTrackOptions"
@action="handleTableAction"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Klinik Dialog -->
<SelectionDialog
v-model="showKlinikDialog"
title="Pilih Klinik"
:items="filteredKliniks"
v-model:search-query="klinikSearch"
search-placeholder="Cari klinik..."
@select="selectKlinik"
/>
<!-- Penunjang Dialog -->
<SelectionDialog
v-model="showPenunjangDialog"
title="Pilih Penunjang"
:items="filteredPenunjangs"
v-model:search-query="penunjangSearch"
search-placeholder="Cari penunjang..."
@select="selectPenunjang"
/>
<!-- Change Klinik Dialog -->
<SelectionDialog
v-model="showChangeKlinikDialog"
title="Ubah Klinik"
:items="filteredChangeKliniks"
v-model:search-query="changeKlinikSearch"
search-placeholder="Cari klinik..."
@select="changeKlinik"
/>
<!-- Dialog Klinik Ruang -->
<v-dialog v-model="showKlinikRuangDialog" max-width="900px" scrollable>
<v-card class="dialog-card">
<v-card-title class="dialog-header dialog-header-warning">
<span class="headline-4">Pilih Klinik Ruang</span>
<v-btn icon variant="text" size="small" class="btn-close" @click="closeKlinikRuangDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider/>
<v-card-text class="dialog-content">
<div v-if="currentProcessingPatient" class="patient-card mb-4">
<h4 class="headline-5 mb-3">Informasi Pasien</h4>
<v-row dense>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">Barcode</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.barcode }}</span>
</div>
</v-col>
<v-col cols="6">
<div class="detail-item">
<span class="caption-2 text-muted">No. Antrian</span>
<span class="body-2 text-semibold">{{ currentProcessingPatient.noAntrian }}</span>
</div>
</v-col>
</v-row>
</div>
<v-text-field
v-model="klinikRuangSearch"
placeholder="Cari Klinik Ruang..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
class="mb-4"
/>
<v-expansion-panels class="expansion-panels">
<v-expansion-panel
v-for="klinikRuang in filteredKlinikRuang"
:key="klinikRuang.id"
class="expansion-panel"
>
<v-expansion-panel-title class="expansion-title">
<div class="expansion-header">
<v-chip size="small" color="warning" class="mr-2">
{{ klinikRuang.kodeKlinik }}
</v-chip>
<span class="body-2 text-semibold">{{ klinikRuang.namaKlinik }}</span>
</div>
<template #actions>
<v-chip size="x-small" variant="outlined">
{{ klinikRuang.ruangList.length }} Ruang
</v-chip>
</template>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="ruang-list">
<div
v-for="ruang in klinikRuang.ruangList"
:key="ruang.nomorRuang"
class="ruang-item"
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
>
<v-icon color="warning" size="20">mdi-door</v-icon>
<div class="ruang-info">
<span class="body-3 text-semibold">Ruang {{ ruang.nomorRuang }} - {{ ruang.namaRuang }}</span>
<span class="caption-2 text-muted">Screen: {{ ruang.nomorScreen }}</span>
</div>
<v-icon size="20" color="grey">mdi-chevron-right</v-icon>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</v-dialog>
<!-- Snackbar -->
<AppSnackbar
v-model="snackbar"
:message="snackbarText"
:color="snackbarColor"
/>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { useQueue } from "@/composables/useQueue";
import { useQueueStore } from "@/stores/queueStore";
import { useMasterStore } from "@/stores/masterStore";
import PageHeader from "@/components/common/PageHeader.vue";
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
import PatientDataTable from "@/components/features/queue/TabelPatientData.vue";
import SelectionDialog from "@/components/common/SelectionDialog.vue";
import AppSnackbar from "@/components/common/AppSnackbar.vue";
import { useThermalPrint } from "@/composables/useThermalPrint";
const masterStore = useMasterStore();
const queueStore = useQueueStore();
const { printTicketFromPatient } = useThermalPrint();
const {
snackbar,
snackbarText,
snackbarColor,
showKlinikDialog,
showPenunjangDialog,
showChangeKlinikDialog,
klinikSearch,
penunjangSearch,
changeKlinikSearch,
currentProcessingPatient,
diLoketPatients,
terlambatPatients,
pendingPatients,
waitingPatients,
menungguPatients,
nextPatient,
quotaUsed,
filteredKliniks,
filteredPenunjangs,
filteredChangeKliniks,
callNext,
callMultiplePatients,
processPatient,
selectKlinik,
selectPenunjang,
openPenunjangDialog,
changeKlinik,
processNextQueue,
} = useQueue("loket");
const currentDate = ref(
new Date().toLocaleDateString("id-ID", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})
);
const selectedStatus = ref("all");
const searchQuery = ref("");
const selectedFastTrack = ref(null);
// Dialog Klinik Ruang
const showKlinikRuangDialog = ref(false);
const klinikRuangSearch = ref("");
// Watch dialog state to reset search when closed
watch(showKlinikRuangDialog, (newValue) => {
if (!newValue) {
klinikRuangSearch.value = "";
}
});
// Get klinik ruang data from masterStore
const klinikRuangList = computed(() => {
return masterStore.ruangData || [];
});
const filteredKlinikRuang = computed(() => {
if (!klinikRuangSearch.value) return klinikRuangList.value;
return klinikRuangList.value.filter(
(k) =>
k.namaKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase()) ||
k.kodeKlinik.toLowerCase().includes(klinikRuangSearch.value.toLowerCase())
);
});
// Fast Track options from all patients
const fastTrackOptions = computed(() => {
const normalizedValues = allPatientsForStage.value
.map((p) => (p.fastTrack ?? "").toString().trim().toUpperCase())
.filter((v) => v.length > 0);
const uniqueTracks = [...new Set(normalizedValues)];
return uniqueTracks.sort();
});
// Combine all patients with status - PRESERVE ALL PROPERTIES
// Hanya tampilkan pasien yang sudah dipanggil (bukan status "menunggu")
const allPatientsForStage = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
// Pasien yang sudah di loket (sudah check-in)
const diLoket = (diLoketPatients.value || []).map((p) => ({
...p, // Spread all properties first
// If this patient is currently being processed, mark as "diproses", otherwise "diloket"
status: p.no === currentPatientNo ? "diproses" : "diloket",
}));
// Pasien terlambat
const terlambat = (terlambatPatients.value || []).map((p) => ({
...p,
status: "terlambat",
}));
// Pasien pending
const pending = (pendingPatients.value || []).map((p) => ({
...p,
status: "pending",
}));
// Pasien dengan status "waiting" (sudah dipanggil tapi belum check-in)
// Ini adalah kategori baru "Menunggu" di tabel Admin Loket
const waiting = (waitingPatients.value || []).map((p) => ({
...p,
status: "waiting", // Tampilkan sebagai "waiting" atau "menunggu" di UI
}));
// Jangan tampilkan pasien dengan status "menunggu" (belum dipanggil) di tabel
// Hanya tampilkan pasien yang sudah dipanggil oleh admin loket
const combined = [...diLoket, ...waiting, ...terlambat, ...pending];
// Debug: check if fastTrack property exists
console.log("📊 AdminLoket - allPatientsForStage:", combined.length);
if (combined.length > 0) {
console.log("📊 First patient:", combined[0]);
console.log("📊 First patient has fastTrack?", "fastTrack" in combined[0]);
console.log("📊 First patient fastTrack value:", combined[0].fastTrack);
console.log(
"📊 All fastTrack values:",
combined.map((p) => p.fastTrack)
);
}
return combined;
});
// Count diLoket patients (excluding currently processing)
const diLoketCount = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
return (diLoketPatients.value || []).filter(p => p.no !== currentPatientNo).length;
});
// Count menunggu patients (belum dipanggil)
const menungguCount = computed(() => {
return (menungguPatients.value || []).length;
});
// Count waiting patients (sudah dipanggil tapi belum check-in)
const waitingCount = computed(() => {
return (waitingPatients.value || []).length;
});
// Next queue info for CurrentPatientCard
const nextQueueInfo = computed(() => {
const currentPatientNo = currentProcessingPatient.value?.no;
const nextPatient = (diLoketPatients.value || []).find(p => p.no !== currentPatientNo) || (diLoketPatients.value || [])[0];
if (nextPatient) {
return `Antrian berikutnya: ${nextPatient.noAntrian.split(" |")[0]}`;
}
return null;
});
const handlePatientAction = (action) => {
if (currentProcessingPatient.value) {
processPatient(currentProcessingPatient.value, action);
}
};
const handleCall = (count) => {
if (count === 1) {
callNext();
} else {
callMultiplePatients(count);
}
};
const handleTableAction = (item, action) => {
processPatient(item, action);
};
const handleProcessNext = () => {
processNextQueue();
};
const handleCallPatient = () => {
// TODO: Integrate text-to-speech library here
// Example: speak(`Nomor antrian ${currentProcessingPatient.value?.noAntrian.split(" |")[0]}, silakan menuju ke loket`)
if (currentProcessingPatient.value) {
console.log('Calling patient:', currentProcessingPatient.value);
// Placeholder for text-to-speech integration
}
};
const openKlinikRuangDialog = () => {
showKlinikRuangDialog.value = true;
};
const closeKlinikRuangDialog = () => {
showKlinikRuangDialog.value = false;
klinikRuangSearch.value = "";
};
const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
let patient = currentProcessingPatient.value;
if (!patient) {
try {
// Coba dapatkan pasien yang sedang diproses dari store
const processingPatient = queueStore.currentProcessingPatient?.loket;
if (processingPatient) {
// Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter)
// allPatients adalah ref yang di-export dari Pinia store, bisa diakses langsung atau dengan .value
let allPatients = [];
if (queueStore.allPatients) {
// Jika allPatients adalah ref (punya .value)
allPatients = Array.isArray(queueStore.allPatients)
? queueStore.allPatients
: (queueStore.allPatients.value || []);
}
const latestPatient = allPatients.find(
p => (p && p.no === processingPatient.no) ||
(p && p.barcode && p.barcode === processingPatient.barcode) ||
(p && p.noAntrian && p.noAntrian === processingPatient.noAntrian)
);
patient = latestPatient || processingPatient;
}
} catch (error) {
console.error('Error getting patient from store:', error);
}
}
if (!patient) {
snackbarText.value = "Tidak ada pasien yang sedang diproses";
snackbarColor.value = "error";
snackbar.value = true;
closeKlinikRuangDialog();
return;
}
const result = queueStore.createAntreanKlinikRuang(
klinikRuang,
ruang,
patient,
"loket"
);
if (result.success && result.patient) {
// Print ticket dengan nomor antrian baru
try {
await printTicketFromPatient(result.patient);
} catch (error) {
console.error('Error printing ticket:', error);
snackbarText.value = `${result.message}. Gagal print tiket.`;
snackbarColor.value = "warning";
snackbar.value = true;
closeKlinikRuangDialog();
return;
}
}
snackbarText.value = result.message;
snackbarColor.value = result.success ? "success" : "error";
snackbar.value = true;
closeKlinikRuangDialog();
};
</script>
<style scoped lang="scss">
.loket-container {
background: var(--color-neutral-300);
min-height: 100vh;
padding: 16px;
overflow-x: hidden; /* Prevent horizontal scroll */
width: 100%;
max-width: 100vw;
box-sizing: border-box;
}
.content-grid {
margin: 0 -8px;
}
.content-grid > .v-col {
padding: 8px;
}
.content-grid > .v-col:first-child {
padding-left: 0;
}
.content-grid > .v-col:last-child {
padding-right: 0;
}
.sticky-wrapper {
position: sticky;
top: 16px;
align-self: flex-start;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.status-filter {
flex: 1;
min-width: 300px;
}
.status-filter .v-chip {
font-size: 12px;
font-weight: 600;
height: 32px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
color: var(--color-neutral-600);
transition: all 0.2s ease;
}
.status-filter .v-chip:hover {
background: var(--color-neutral-300);
}
.status-filter .v-chip.active-chip {
background: var(--color-primary-600);
color: var(--color-neutral-100);
border-color: var(--color-primary-600);
}
.search-field {
max-width: 300px;
min-width: 250px;
}
.section-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
color: var(--color-neutral-600);
text-transform: uppercase;
}
.data-header {
display: flex;
flex-direction: column;
gap: 12px;
}
.patient-data-container {
border-radius: 12px;
border: 1px solid var(--color-neutral-500);
background: var(--color-neutral-100);
}
/* Dialog Klinik Ruang Styles */
.dialog-card {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.dialog-header {
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
color: var(--color-neutral-100);
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header-warning {
background: linear-gradient(135deg, var(--color-secondary-600) 0%, var(--color-secondary-700) 100%);
}
.headline-4 {
font-size: 20px;
line-height: 28px;
font-weight: 600;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.headline-5 {
font-size: 18px;
line-height: 24px;
font-weight: 600;
margin: 0;
color: var(--color-neutral-900);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.btn-close {
color: var(--color-neutral-100) !important;
transition: all 0.2s ease;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.2) !important;
transform: rotate(90deg);
}
.dialog-content {
padding: 24px !important;
background: var(--color-neutral-300);
}
.dialog-actions {
padding: 16px 24px;
background: var(--color-neutral-300);
}
.dialog-actions .v-btn {
text-transform: none;
font-weight: 600;
font-size: 16px;
line-height: 24px;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
min-width: 100px;
}
.btn-cancel {
border-color: var(--color-neutral-600) !important;
color: var(--color-neutral-800) !important;
}
.btn-submit {
background-color: var(--color-secondary-600) !important;
color: var(--color-neutral-100) !important;
}
.patient-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--color-neutral-400);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.caption-2 {
font-size: 12px;
line-height: 16px;
font-weight: 400;
}
.body-2 {
font-size: 16px;
line-height: 24px;
font-weight: 400;
}
.body-3 {
font-size: 14px;
line-height: 20px;
font-weight: 400;
}
.text-semibold {
font-weight: 600 !important;
}
.text-muted {
color: var(--color-neutral-700);
}
.expansion-panels {
background: transparent;
}
.expansion-panel {
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-400);
border-radius: 12px !important;
margin-bottom: 12px;
overflow: hidden;
}
.expansion-panel::before {
box-shadow: none !important;
}
.expansion-title {
background: var(--color-neutral-100);
min-height: 60px;
}
.expansion-header {
display: flex;
align-items: center;
}
.ruang-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.ruang-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-neutral-300);
border: 1px solid var(--color-neutral-400);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.ruang-item:hover {
border-color: var(--color-warning-600);
background: rgba(255, 185, 95, 0.15);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.ruang-item:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.ruang-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@media (max-width: 960px) {
.loket-container {
padding: 12px;
}
.sticky-wrapper {
position: relative;
top: 0;
max-height: none;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.status-filter {
min-width: 100%;
}
.search-field {
max-width: 100%;
min-width: 100%;
}
}
</style>