update api loket admin

This commit is contained in:
Fanrouver
2026-01-26 14:37:05 +07:00
parent 6c0835227d
commit 083fe3e364
4 changed files with 488 additions and 320 deletions
+250 -261
View File
@@ -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>
+126
View File
@@ -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>
+67
View File
@@ -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
View File
@@ -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');
}
};