From 8b9c4725deb4138a744078bd540b100e19a9860c Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Tue, 9 Jun 2026 08:51:15 +0700 Subject: [PATCH] feat: implement queue display and management system for loket and clinic modules voice over --- pages/AdminLoket/[id].vue | 12 ++- .../AntrianKlinikRuang/[kodeKlinik].vue | 45 ++++++++++ pages/Anjungan/AntrianLoket/[id].vue | 90 ++++++++++++++++--- stores/queueStore.js | 10 +++ 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index 3acb669..018527b 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -357,6 +357,12 @@ const ruangStore = useRuangStore(); const { printTicketFromPatient } = useThermalPrint(); const config = useRuntimeConfig(); +useHead({ + script: [ + { src: "https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo" } + ] +}); + // Broadcast Channel let broadcastChannel = null; let resetCheckInterval = null; @@ -1018,7 +1024,11 @@ const confirmUnfinishedAndProcess = async () => { const executeProcess = async (item, action) => { if (action === "next") { - processNextQueue(); + await processNextQueue(); + // Auto-call setelah memproses antrean selanjutnya + setTimeout(() => { + handleCallPatient(); + }, 300); } else { await processPatient(item, action); // If action is process, auto-call the patient after a short delay diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index 6e11a39..b16dccf 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -160,6 +160,11 @@ useHead({ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover' } + ], + script: [ + { + src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo' + } ] }) @@ -230,6 +235,43 @@ const currentDate = ref('') let timeInterval = null const broadcastedKlinikPatient = ref(null) +let lastPlayedTime = 0; +let lastPlayedNo = ''; + +const playCallVoice = (patient, tipeLayanan = null, customRoomName = null) => { + if (!patient || (!patient.noantrian && !patient.noAntrian)) return; + + const now = Date.now(); + const noAntrianFull = patient.noantrian || patient.noAntrian; + + // Cegah double-play berbarengan dalam waktu 2 detik + if (noAntrianFull === lastPlayedNo && (now - lastPlayedTime < 2000)) { + return; + } + + lastPlayedNo = noAntrianFull; + lastPlayedTime = now; + + if (window.responsiveVoice) { + const noAntrianRaw = noAntrianFull.split(" |")[0]; + const formattedNo = noAntrianRaw.split('').join(' '); + + // For clinic, destination should include Ruang and Tipe Layanan + // Contoh: "Ruang 1, untuk Pemeriksaan Awal" + const ruang = customRoomName || patient.ruang || patient.namaRuang || (patient.nomorRuang ? `Ruang ${patient.nomorRuang}` : 'Klinik'); + const layananText = tipeLayanan ? `untuk ${tipeLayanan}` : ''; + const destination = `${ruang}, ${layananText}`; + + const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`; + + if (window.responsiveVoice.isPlaying()) { + window.responsiveVoice.cancel(); + } + + window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 }); + } +}; + // Watch for Klinik Calls (Cross-Device Sync) from queueStore watch(() => queueStore.lastKlinikCall, (newCall) => { if (!newCall || !newCall.noantrian) return; @@ -274,6 +316,9 @@ watch(() => queueStore.lastKlinikCall, (newCall) => { ruang: existingPatient?.ruang || existingPatient?.namaRuang || `Ruang ${newCall.nomorRuang || '1'}`, _source: 'websocket' }; + + // Mainkan suara panggilan + playCallVoice(broadcastedKlinikPatient.value, newCall.tipeLayanan, broadcastedKlinikPatient.value.ruang); } else { console.log(`⏭️ [Anjungan] Skipping call for clinic ${callKode} (this screen is for ${myKode})`); } diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue index ce8be35..0564326 100644 --- a/pages/Anjungan/AntrianLoket/[id].vue +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -209,6 +209,9 @@ useHead({ name: 'viewport', content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover' } + ], + script: [ + { src: 'https://code.responsivevoice.org/responsivevoice.js?key=ZeMK8Joo' } ] }) @@ -229,6 +232,45 @@ let pollInterval = null let broadcastChannel = null const broadcastedPatient = ref(null) +let lastPlayedTime = 0; +let lastPlayedNo = ''; + +const playCallVoice = (patient, customLoketName = null) => { + console.log("🔊 playCallVoice called for:", patient?.noAntrian); + if (!patient || !patient.noAntrian) return; + + const now = Date.now(); + // Cegah double-play berbarengan (dari WS dan BroadcastChannel) dalam waktu 2 detik + if (patient.noAntrian === lastPlayedNo && (now - lastPlayedTime < 2000)) { + console.log("🔊 Skipped double play (debounce)"); + return; + } + + lastPlayedNo = patient.noAntrian; + lastPlayedTime = now; + + console.log("🔊 responsiveVoice object exists?", !!window.responsiveVoice); + if (window.responsiveVoice) { + const noAntrianRaw = patient.noAntrian.split(" |")[0]; + const formattedNo = noAntrianRaw.split('').join(' '); + const destination = customLoketName || patient.loketName || loketData.value?.namaLoket || 'Loket'; + const textToSpeak = `Nomor antrean, ${formattedNo}, silahkan menuju ke, ${destination}`; + + console.log("🔊 Speaking:", textToSpeak); + try { + if (window.responsiveVoice.isPlaying()) { + window.responsiveVoice.cancel(); + } + + window.responsiveVoice.speak(textToSpeak, "Indonesian Female", { pitch: 1, rate: 0.85, volume: 1 }); + } catch (e) { + console.error("🔊 ResponsiveVoice Error:", e); + } + } else { + console.error("🔊 ResponsiveVoice is NOT loaded yet!"); + } +}; + // Get loket ID from route const loketId = computed(() => { const id = route.params.id @@ -685,16 +727,26 @@ const isInTTSWindow = (queue) => { // Nomor antrian menjadi "dipanggil" jika diproses pada AdminLoket DAN sudah dipanggil oleh admin const currentCalledQueue = computed(() => { const targetLoketId = String(loketId.value) + const CALL_DISPLAY_DURATION = 60000 // 60 seconds + const now = new Date() + + // Force reactivity to time passing + const _ = currentTime.value // Prioritas 0: Broadcasted patient (Real-time from BroadcastChannel) - // Check if the broadcast came from this loket if (broadcastedPatient.value) { const rawMsg = broadcastedPatient.value._rawMessage const msgLoketId = rawMsg?.loketId ? String(rawMsg.loketId) : null - // Only show if it matches this anjungan's loket ID if (msgLoketId === targetLoketId) { - return broadcastedPatient.value + const callTime = new Date(broadcastedPatient.value.lastCalledAt || now) + const timeDiff = now.getTime() - callTime.getTime() + + if (timeDiff <= CALL_DISPLAY_DURATION) { + return broadcastedPatient.value + } else { + broadcastedPatient.value = null // Clear if expired + } } } @@ -705,10 +757,15 @@ const currentCalledQueue = computed(() => { const patientLoketId = processingPatient.loketId ? String(processingPatient.loketId) : "1" if (patientLoketId === targetLoketId && processingPatient.calledByAdmin && processingPatient.noAntrian && processingPatient.status === 'di-loket') { - const klinikName = getKlinikNameFromPatient(processingPatient) - return { - ...processingPatient, - klinik: klinikName || processingPatient.klinik || 'Klinik' + const callTime = processingPatient.lastCalledAt ? new Date(processingPatient.lastCalledAt) : now + const timeDiff = now.getTime() - callTime.getTime() + + if (timeDiff <= CALL_DISPLAY_DURATION) { + const klinikName = getKlinikNameFromPatient(processingPatient) + return { + ...processingPatient, + klinik: klinikName || processingPatient.klinik || 'Klinik' + } } } } @@ -717,10 +774,15 @@ const currentCalledQueue = computed(() => { const allPatientsList = filteredPatientsForLoket.value const dbActive = allPatientsList.find(p => p.idvisit === 8 || p.idvisit === 7) if (dbActive) { - const klinikName = getKlinikNameFromPatient(dbActive) - return { - ...dbActive, - klinik: klinikName || dbActive.klinik || 'Klinik' + const callTime = dbActive.lastCalledAt ? new Date(dbActive.lastCalledAt) : now + const timeDiff = now.getTime() - callTime.getTime() + + if (timeDiff <= CALL_DISPLAY_DURATION) { + const klinikName = getKlinikNameFromPatient(dbActive) + return { + ...dbActive, + klinik: klinikName || dbActive.klinik || 'Klinik' + } } } @@ -914,6 +976,9 @@ watch(() => queueStore.lastGlobalCall, (newCall) => { ...newCall, _source: 'websocket' }; + + // Mainkan suara panggilan + playCallVoice(newCall, newCall.loketName); // Sync with store using isolated key if (queueStore.currentProcessingPatient) { @@ -1016,6 +1081,9 @@ onMounted(async () => { ...patient, // Spread patient data _rawMessage: event.data // Attach metadata for filtering }; + + // Mainkan suara panggilan + playCallVoice(patient, event.data.loketName); // Sync with store using isolated key if (queueStore.currentProcessingPatient) { diff --git a/stores/queueStore.js b/stores/queueStore.js index 33d80a3..d5aea9d 100644 --- a/stores/queueStore.js +++ b/stores/queueStore.js @@ -531,6 +531,11 @@ export const useQueueStore = defineStore('queue', () => { */ const syncApiPatientStatus = (patient, newStatus) => { if (!patient) return; + + // Bersihkan LocalStorage fallback jika status sudah bukan pending/terlambat + if (newStatus !== 'pending' && newStatus !== 'terlambat' && patient.barcode) { + localStorage.removeItem(`patient-status-${patient.barcode}`); + } // Allow syncing if strictly 'api' OR if we can find a matching ID in the API store // This handles cases where we have an 'onsite' patient locally that corresponds to an API patient @@ -2224,6 +2229,10 @@ const fetchPatientsForLoket = async (loketId, force = false) => { allPatients.value[patientIndex] = updatedPatient; // Set currentProcessingPatient with isolated key currentProcessingPatient.value[storageKey] = updatedPatient; + + if (shouldUpdateStatus) { + syncApiPatientStatus(updatedPatient, "di-loket"); + } // POST to external API when patient is being processed (sedang diproses) try { @@ -2261,6 +2270,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => { }; allPatients.value[patientIndex] = updatedPatient; currentProcessingPatient.value[storageKey] = updatedPatient; + syncApiPatientStatus(updatedPatient, "di-loket"); } else { currentProcessingPatient.value[storageKey] = patient; }