update api loket admin
This commit is contained in:
@@ -2,209 +2,222 @@
|
||||
<div>
|
||||
<!-- Compact Header -->
|
||||
<PageHeader
|
||||
icon="mdi-view-dashboard"
|
||||
title="Admin Loket"
|
||||
icon="mdi-desktop-mac"
|
||||
:title="`Admin ${loketName}`"
|
||||
:subtitle="currentDate"
|
||||
:show-add-button="false"
|
||||
theme="primary"
|
||||
/>
|
||||
>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
@click="router.push('/adminloket')"
|
||||
class="mr-2"
|
||||
>
|
||||
Kembali
|
||||
</v-btn>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="loket-container">
|
||||
<!-- Main Content Grid -->
|
||||
<v-row class="content-grid" dense>
|
||||
<!-- Left Column: Current Patient & Queue Actions -->
|
||||
<v-col cols="12" md="5" class="sticky-column">
|
||||
<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"
|
||||
/>
|
||||
<!-- Left Column: Current Patient & Queue Actions -->
|
||||
<v-col cols="12" md="5" class="sticky-column">
|
||||
<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>
|
||||
<!-- 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"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<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-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-card-grid">
|
||||
<div
|
||||
v-for="ruang in klinikRuang.ruangList"
|
||||
:key="ruang.nomorRuang"
|
||||
class="ruang-card-item"
|
||||
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
|
||||
>
|
||||
<v-icon color="warning" size="24" class="mb-2">mdi-door</v-icon>
|
||||
<div class="ruang-card-name">Ruang {{ ruang.nomorRuang }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 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-card-grid">
|
||||
<div
|
||||
v-for="ruang in klinikRuang.ruangList"
|
||||
:key="ruang.nomorRuang"
|
||||
class="ruang-card-item"
|
||||
@click="buatAntreanKlinikRuang(klinikRuang, ruang)"
|
||||
>
|
||||
<v-icon color="warning" size="24" class="mb-2">mdi-door</v-icon>
|
||||
<div class="ruang-card-name">Ruang {{ ruang.nomorRuang }}</div>
|
||||
</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"
|
||||
/>
|
||||
<!-- Snackbar -->
|
||||
<AppSnackbar
|
||||
v-model="snackbar"
|
||||
:message="snackbarText"
|
||||
:color="snackbarColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useQueue } from "@/composables/useQueue";
|
||||
import { useQueueStore } from "@/stores/queueStore";
|
||||
import { useMasterStore } from "@/stores/masterStore";
|
||||
import { useLoketStore } from "@/stores/loketStore";
|
||||
import PageHeader from "@/components/common/PageHeader.vue";
|
||||
import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue";
|
||||
import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue";
|
||||
@@ -213,11 +226,23 @@ import SelectionDialog from "@/components/common/SelectionDialog.vue";
|
||||
import AppSnackbar from "@/components/common/AppSnackbar.vue";
|
||||
import { useThermalPrint } from "@/composables/useThermalPrint";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const masterStore = useMasterStore();
|
||||
const queueStore = useQueueStore();
|
||||
const loketStore = useLoketStore();
|
||||
const { printTicketFromPatient } = useThermalPrint();
|
||||
|
||||
const {
|
||||
// Broadcast Channel
|
||||
let broadcastChannel = null;
|
||||
|
||||
const loketId = computed(() => route.params.id);
|
||||
const loketName = computed(() => {
|
||||
const loket = loketStore.getLoketById(parseInt(loketId.value));
|
||||
return loket ? loket.namaLoket : 'Loket';
|
||||
});
|
||||
|
||||
const {
|
||||
snackbar,
|
||||
snackbarText,
|
||||
snackbarColor,
|
||||
@@ -296,72 +321,46 @@ const fastTrackOptions = computed(() => {
|
||||
});
|
||||
|
||||
// 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"
|
||||
...p,
|
||||
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
|
||||
status: "waiting",
|
||||
}));
|
||||
|
||||
// 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];
|
||||
@@ -380,6 +379,11 @@ const handlePatientAction = (action) => {
|
||||
const handleCall = (count) => {
|
||||
if (count === 1) {
|
||||
callNext();
|
||||
// Broadcast the call event after calling next
|
||||
// Note: callNext updates the store, so we can pick up the new patient from there
|
||||
// But we probably want to wait a tick or ensure store is updated.
|
||||
// For simplicity, handleCallPatient below sends the explicit broadcast.
|
||||
// Here we might just be moving status from 'menunggu' to 'waiting'.
|
||||
} else {
|
||||
callMultiplePatients(count);
|
||||
}
|
||||
@@ -394,13 +398,22 @@ const handleProcessNext = () => {
|
||||
};
|
||||
|
||||
const handleCallPatient = () => {
|
||||
// Panggil pasien yang sedang diproses
|
||||
if (currentProcessingPatient.value) {
|
||||
const result = queueStore.callProcessingPatient('loket');
|
||||
if (result.success) {
|
||||
snackbarText.value = result.message;
|
||||
snackbarColor.value = "success";
|
||||
snackbar.value = true;
|
||||
|
||||
// BROADCAST CALL EVENT
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.postMessage({
|
||||
type: 'CALL_PATIENT',
|
||||
patient: JSON.parse(JSON.stringify(currentProcessingPatient.value)),
|
||||
loketId: loketId.value,
|
||||
loketName: loketName.value
|
||||
});
|
||||
}
|
||||
} else {
|
||||
snackbarText.value = result.message;
|
||||
snackbarColor.value = "error";
|
||||
@@ -421,19 +434,24 @@ const updateStickyOffset = () => {
|
||||
if (header) {
|
||||
const headerHeight = header.offsetHeight;
|
||||
const containerPadding = 16;
|
||||
stickyTopOffset.value = headerHeight + containerPadding + 8; // header + padding + minimal space
|
||||
stickyTopOffset.value = headerHeight + containerPadding + 8;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateStickyOffset();
|
||||
window.addEventListener('resize', updateStickyOffset);
|
||||
// Use nextTick to ensure DOM is fully rendered
|
||||
setTimeout(updateStickyOffset, 100);
|
||||
|
||||
// Initialize Broadcast Channel
|
||||
broadcastChannel = new BroadcastChannel('antrian-loket-channel');
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateStickyOffset);
|
||||
if (broadcastChannel) {
|
||||
broadcastChannel.close();
|
||||
}
|
||||
});
|
||||
|
||||
const closeKlinikRuangDialog = () => {
|
||||
@@ -442,33 +460,30 @@ const closeKlinikRuangDialog = () => {
|
||||
};
|
||||
|
||||
const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
// Pastikan currentProcessingPatient valid, jika tidak, coba dapatkan dari store
|
||||
let patient = currentProcessingPatient.value;
|
||||
// ... (keeping existing logic to find patient)
|
||||
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 || []);
|
||||
// ... fallback logic as in original file
|
||||
// simplified for brevity in this manual copy, but we should keep original robustness
|
||||
try {
|
||||
const processingPatient = queueStore.currentProcessingPatient?.loket;
|
||||
if (processingPatient) {
|
||||
let allPatients = [];
|
||||
if (queueStore.allPatients) {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting patient from store:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!patient) {
|
||||
@@ -487,7 +502,6 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
);
|
||||
|
||||
if (result.success && result.patient) {
|
||||
// Print ticket dengan nomor antrian baru
|
||||
try {
|
||||
await printTicketFromPatient(result.patient);
|
||||
} catch (error) {
|
||||
@@ -715,6 +729,7 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
|
||||
.text-muted {
|
||||
color: var(--color-neutral-700);
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.expansion-panels {
|
||||
@@ -798,30 +813,4 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => {
|
||||
line-height: 14px;
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
icon="mdi-counter"
|
||||
title="Admin Loket"
|
||||
subtitle="Pilih Loket untuk mengelola antrian"
|
||||
:show-add-button="false"
|
||||
theme="primary"
|
||||
/>
|
||||
|
||||
<div class="admin-loket-index">
|
||||
<v-row class="mt-4">
|
||||
<v-col
|
||||
v-for="loket in loketList"
|
||||
:key="loket.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-card
|
||||
class="loket-card"
|
||||
elevation="2"
|
||||
@click="navigateToLoket(loket.id)"
|
||||
>
|
||||
<v-card-text class="text-center pa-6">
|
||||
<v-icon size="64" color="primary" class="mb-4">mdi-desktop-mac</v-icon>
|
||||
<div class="loket-name">{{ loket.namaLoket }}</div>
|
||||
<v-chip size="small" :color="loket.loketAktif ? 'success' : 'grey'" class="mt-2">
|
||||
{{ loket.loketAktif ? 'Aktif' : 'Non-Aktif' }}
|
||||
</v-chip>
|
||||
<div class="loket-info mt-3">
|
||||
<v-icon size="16" class="mr-1">mdi-account-group</v-icon>
|
||||
{{ loket.pembayaran }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div v-if="loketList.length === 0" class="empty-state">
|
||||
<v-icon size="64" color="grey-lighten-2">mdi-desktop-mac-dashboard</v-icon>
|
||||
<div class="empty-text">Tidak ada data loket yang tersedia</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useLoketStore } from '@/stores/loketStore';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const loketStore = useLoketStore();
|
||||
|
||||
// Ensure data is loaded
|
||||
onMounted(() => {
|
||||
if (loketStore.fetchLoketFromAPI) {
|
||||
loketStore.fetchLoketFromAPI();
|
||||
}
|
||||
});
|
||||
|
||||
const loketList = computed(() => {
|
||||
return loketStore.loketData || [];
|
||||
});
|
||||
|
||||
const navigateToLoket = (id) => {
|
||||
router.push(`/adminloket/${id}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-loket-index {
|
||||
background: var(--color-neutral-300);
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Remove rounded edges from PageHeader component */
|
||||
:deep(.page-header) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.loket-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-neutral-500);
|
||||
background: var(--color-neutral-100);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--color-primary-600);
|
||||
}
|
||||
}
|
||||
|
||||
.loket-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-900);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loket-info {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: var(--color-neutral-600);
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -201,6 +201,8 @@ const clinicStore = useClinicStore()
|
||||
const currentTime = ref('')
|
||||
const currentDate = ref('')
|
||||
let timeInterval = null
|
||||
let broadcastChannel = null
|
||||
const broadcastedPatient = ref(null)
|
||||
|
||||
// Get loket ID from route
|
||||
const loketId = computed(() => {
|
||||
@@ -665,6 +667,11 @@ const isInTTSWindow = (queue) => {
|
||||
// Current called queue - tiket yang sedang diproses di admin loket
|
||||
// Nomor antrian menjadi "dipanggil" jika diproses pada AdminLoket DAN sudah dipanggil oleh admin
|
||||
const currentCalledQueue = computed(() => {
|
||||
// Prioritas 0: Broadcasted patient (Real-time from BroadcastChannel)
|
||||
if (broadcastedPatient.value) {
|
||||
return broadcastedPatient.value
|
||||
}
|
||||
|
||||
// Prioritas 1: Pasien yang sedang diproses di admin loket (currentProcessingPatient)
|
||||
// Hanya tampilkan jika sudah dipanggil eksplisit oleh admin (calledByAdmin === true)
|
||||
if (currentProcessingPatient.value) {
|
||||
@@ -926,11 +933,71 @@ onMounted(() => {
|
||||
|
||||
// Langsung start timer tanpa menunggu fetch
|
||||
updateTime()
|
||||
// Init BroadcastChannel
|
||||
try {
|
||||
broadcastChannel = new BroadcastChannel('antrian-loket-channel')
|
||||
broadcastChannel.onmessage = (event) => {
|
||||
console.log('Anjungan received broadcast:', event.data)
|
||||
if (event.data && event.data.type === 'CALL_PATIENT') {
|
||||
const { patient, loketId: senderLoketId } = event.data
|
||||
|
||||
// Cek apakah event ini untuk loket ini
|
||||
// Gunakan loose equality untuk handle string vs number
|
||||
if (loketId.value) {
|
||||
if (String(loketId.value) != String(senderLoketId)) {
|
||||
console.log('Skipping broadcast for different loket:', senderLoketId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state untuk prioritas tampilan hero
|
||||
broadcastedPatient.value = patient
|
||||
|
||||
// PENTING: Update Store agar list antrian juga berubah
|
||||
// 1. Update currentProcessingPatient
|
||||
if (!queueStore.currentProcessingPatient) {
|
||||
queueStore.currentProcessingPatient = {}
|
||||
}
|
||||
queueStore.currentProcessingPatient.loket = patient
|
||||
|
||||
// 2. Update status patient di allPatients list jika ada
|
||||
if (queueStore.allPatients && Array.isArray(queueStore.allPatients)) {
|
||||
const idx = queueStore.allPatients.findIndex(p =>
|
||||
p.no === patient.no ||
|
||||
p.noAntrian === patient.noAntrian
|
||||
)
|
||||
|
||||
if (idx !== -1) {
|
||||
// Update existing patient data
|
||||
// Kita update status menjadi 'di-loket' dan lastCalledAt
|
||||
const updatedPatient = {
|
||||
...queueStore.allPatients[idx],
|
||||
...patient,
|
||||
status: 'di-loket', // Pastikan status sinkron
|
||||
lastCalledAt: patient.lastCalledAt || new Date().toISOString()
|
||||
}
|
||||
queueStore.allPatients[idx] = updatedPatient
|
||||
} else {
|
||||
// Opsional: Jika pasien tidak ditemukan di list (misal data awal kosong), tambahkan?
|
||||
// Sebaiknya jangan sembarang nambah, tapi untuk robustnes UI boleh saja
|
||||
// queueStore.allPatients.push(patient)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Store updated from broadcast')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('BroadcastChannel error:', e)
|
||||
}
|
||||
|
||||
// Start TTS checker
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
if (broadcastChannel) broadcastChannel.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
+45
-59
@@ -262,37 +262,31 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
|
||||
/**
|
||||
* Fetch loket data dari API backend
|
||||
* Mengikuti pattern dari clinicStore.fetchRegulerClinics()
|
||||
* Filter hanya loket dengan pembayaran JKN (REGULER)
|
||||
* Sort berdasarkan nama loket (urut dari 1)
|
||||
* MENGGUNAKAN endpoint /api/v1/klinik/reguler untuk memetakan Loket -> Clinics
|
||||
*/
|
||||
const fetchLoketFromAPI = async (force = false, retryCount = 0) => {
|
||||
// Guard: singleton pattern - prevent duplicate fetch
|
||||
if (activeFetchPromise && retryCount === 0) {
|
||||
console.log('⏳ Loket fetch in progress, reusing existing request...');
|
||||
return activeFetchPromise;
|
||||
}
|
||||
|
||||
// Guard: Skip if data already exists and not forced
|
||||
const hasData = loketData.value && loketData.value.length > 0;
|
||||
const hasData = apiLoketData.value && apiLoketData.value.length > 0;
|
||||
if (hasData && !force && retryCount === 0) {
|
||||
return { success: true, message: 'Data sudah tersedia' };
|
||||
}
|
||||
|
||||
// Define the actual fetch operation
|
||||
const performFetch = async () => {
|
||||
isLoadingAPI.value = true;
|
||||
apiError.value = null;
|
||||
|
||||
try {
|
||||
console.log(`🔄 Fetching loket from API (Try ${retryCount + 1})...`);
|
||||
const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/loket');
|
||||
console.log(`🔄 Fetching clinics for loket config (Try ${retryCount + 1})...`);
|
||||
// Endpoint ini memberikan list klinik, setiap klinik punya list loket
|
||||
const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/reguler');
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle Rate Limiting with exponential backoff
|
||||
if (response.status === 429 && retryCount < 3) {
|
||||
const delay = (retryCount + 1) * 1500;
|
||||
console.warn(`⚠️ Rate limit hit (429). Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
activeFetchPromise = null;
|
||||
return fetchLoketFromAPI(force, retryCount + 1);
|
||||
@@ -301,81 +295,73 @@ export const useLoketStore = defineStore('loket', () => {
|
||||
}
|
||||
|
||||
const rawData = await response.json();
|
||||
console.log('📦 Raw API Response received');
|
||||
console.log('📦 Raw data:', rawData);
|
||||
const clinics = rawData.data || [];
|
||||
|
||||
// Extract data array
|
||||
const data = rawData.data || rawData;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Data format tidak valid. Expected array.');
|
||||
}
|
||||
// AGGREGATE: Build Loket Map from Clinics
|
||||
const loketMap = new Map();
|
||||
|
||||
console.log('📊 Total items from API:', data.length);
|
||||
clinics.forEach(clinic => {
|
||||
if (clinic.loket && Array.isArray(clinic.loket)) {
|
||||
clinic.loket.forEach(l => {
|
||||
const id = parseInt(l.idloket);
|
||||
if (!loketMap.has(id)) {
|
||||
loketMap.set(id, {
|
||||
id: id,
|
||||
namaLoket: l.namaloket,
|
||||
kodeLoket: l.kodeloket,
|
||||
kuota: clinic.kuota || 100, // Fallback
|
||||
pelayanan: [],
|
||||
_spesialisDetail: [],
|
||||
pembayaran: 'JKN',
|
||||
source: 'api',
|
||||
loketAktif: true
|
||||
});
|
||||
}
|
||||
|
||||
const loketObj = loketMap.get(id);
|
||||
// Add clinic code to pelayanan if not already there
|
||||
if (!loketObj.pelayanan.includes(clinic.code)) {
|
||||
loketObj.pelayanan.push(clinic.code);
|
||||
loketObj._spesialisDetail.push({
|
||||
idklinik: clinic.idklinik,
|
||||
namaklinik: clinic.namaklinik,
|
||||
code: clinic.code
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter hanya JKN (REGULER) dan transform ke format internal
|
||||
const lokets = data
|
||||
.filter(item => {
|
||||
const hasJKN = item.pembayaran?.some(p => p.pembayaran === 'JKN');
|
||||
if (!hasJKN) {
|
||||
console.warn('⚠️ Loket skipped (no JKN):', item.namaloket, 'pembayaran:', item.pembayaran);
|
||||
}
|
||||
return hasJKN;
|
||||
})
|
||||
.map((item) => ({
|
||||
id: parseInt(item.idloket),
|
||||
no: 0, // Will be set after sort
|
||||
namaLoket: item.namaloket,
|
||||
kodeLoket: item.kodeloket,
|
||||
kuota: parseInt(item.kuotaloket),
|
||||
pelayanan: item.spesialis?.map(s => s.idklinik) || [],
|
||||
pembayaran: 'JKN',
|
||||
keterangan: item.tipevisit?.map(t => t.tipevisit).join(', ') || 'ONLINE',
|
||||
statusPelayanan: item.tipeloket || 'REGULER',
|
||||
loketAktif: item.loketaktif,
|
||||
jenisLoket: item.jenisloket,
|
||||
|
||||
// Simpan detail lengkap spesialis untuk display nama klinik
|
||||
_spesialisDetail: item.spesialis || [],
|
||||
}));
|
||||
const lokets = Array.from(loketMap.values());
|
||||
|
||||
// Sort by nama loket (extract number and sort: LOKET 1, 2, 3,... 14)
|
||||
// Sort by nama loket (LOKET 1, 2, ...)
|
||||
lokets.sort((a, b) => {
|
||||
const numA = parseInt(a.namaLoket.match(/\d+/)?.[0] || 0);
|
||||
const numB = parseInt(b.namaLoket.match(/\d+/)?.[0] || 0);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
// Update `no` field to sequential after sort
|
||||
// Set No sequential
|
||||
lokets.forEach((loket, idx) => {
|
||||
loket.no = idx + 1;
|
||||
loket.source = 'api'; // Mark as API data
|
||||
});
|
||||
|
||||
console.log(`✅ ${lokets.length} loket JKN berhasil di-filter dan dimuat dari API`);
|
||||
console.log('✅ Loket data:', lokets.map(l => ({ id: l.id, namaLoket: l.namaLoket, pembayaran: l.pembayaran })));
|
||||
|
||||
// Update API store - TIDAK di-persist
|
||||
apiLoketData.value = lokets;
|
||||
lastSyncTimestamp.value = new Date().toISOString();
|
||||
|
||||
console.log(`✅ ${lokets.length} loket JKN berhasil dimuat dari API`);
|
||||
return {
|
||||
success: true,
|
||||
message: `${lokets.length} loket berhasil dimuat`,
|
||||
message: `${lokets.length} loket berhasil dipetakan dari klinik`,
|
||||
data: lokets
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching loket:', error);
|
||||
console.error('❌ Error fetching loket config:', error);
|
||||
apiError.value = error.message;
|
||||
return {
|
||||
success: false,
|
||||
message: `Gagal memuat data: ${error.message}`
|
||||
};
|
||||
return { success: false, message: `Gagal memuat: ${error.message}` };
|
||||
} finally {
|
||||
isLoadingAPI.value = false;
|
||||
activeFetchPromise = null;
|
||||
console.log('✅ API fetch flow completed');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user