From f81dd57a1668d45cee0524a65aaebce98d49935c Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Fri, 5 Jun 2026 08:21:56 +0700 Subject: [PATCH] pindah klinik belum service --- .../queue/CheckInConfirmationDialog.vue | 156 +++++++++++++++++ components/features/queue/PatientCard.vue | 20 ++- .../features/queue/TabelPatientData.vue | 4 + .../queue/UnfinishedPatientDialog.vue | 157 +++++++++++++++++ composables/useThermalPrint.ts | 27 +-- pages/AdminLoket/[id].vue | 165 ++++++++++++++---- pages/Dashboard.vue | 133 ++++++++++---- server/api/patient-subspesialis.ts | 54 ++++++ stores/queueStore.js | 81 +++++++-- 9 files changed, 702 insertions(+), 95 deletions(-) create mode 100644 components/features/queue/CheckInConfirmationDialog.vue create mode 100644 components/features/queue/UnfinishedPatientDialog.vue create mode 100644 server/api/patient-subspesialis.ts diff --git a/components/features/queue/CheckInConfirmationDialog.vue b/components/features/queue/CheckInConfirmationDialog.vue new file mode 100644 index 0000000..ca5161a --- /dev/null +++ b/components/features/queue/CheckInConfirmationDialog.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/components/features/queue/PatientCard.vue b/components/features/queue/PatientCard.vue index 68f7ec1..51d00b2 100644 --- a/components/features/queue/PatientCard.vue +++ b/components/features/queue/PatientCard.vue @@ -34,15 +34,6 @@
- - {{ patient.ruang }} - {{ patient.klinik }} + + + {{ patient.ruang }} + { padding: 8px 12px; background: var(--color-neutral-200); border-radius: 8px; + display: inline-flex; + align-items: center; + border: 1px solid var(--color-neutral-400); border-left: 3px solid var(--color-primary-600); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .results-text { diff --git a/components/features/queue/UnfinishedPatientDialog.vue b/components/features/queue/UnfinishedPatientDialog.vue new file mode 100644 index 0000000..fb9f71a --- /dev/null +++ b/components/features/queue/UnfinishedPatientDialog.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/composables/useThermalPrint.ts b/composables/useThermalPrint.ts index 3e2b9ab..b7d006b 100644 --- a/composables/useThermalPrint.ts +++ b/composables/useThermalPrint.ts @@ -126,18 +126,25 @@ export const useThermalPrint = () => { // Format nomor antrian (hilangkan bagian "| Onsite - barcode") const noAntrianDisplay = data.noAntrian.split(' |')[0]; - // Format informasi ruang: "Poli Anak - Ruang A" + // Format informasi ruang: "Poli Anak - Ruang A" atau "Poli Anak - Gastro-Hepatologi" let ruangInfo = ''; - if (data.klinik && data.nomorRuang) { - // Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst) - const ruangNumber = parseInt(data.nomorRuang) || 1; - const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst - ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`; - } else if (data.klinik && data.ruang) { - // Fallback jika hanya ada nama ruang + + // 1. Jika ada data.ruang dan bukan sekadar kata "Ruang X" (berarti ini subspesialis) + if (data.ruang && !data.ruang.toLowerCase().startsWith('ruang')) { ruangInfo = `${data.klinik} - ${data.ruang}`; - } else if (data.klinik) { - // Hanya klinik jika tidak ada info ruang + } + // 2. Fallback: gunakan nomorRuang untuk dikonversi ke abjad (Ruang A, Ruang B, dll) + else if (data.klinik && data.nomorRuang) { + const ruangNumber = parseInt(data.nomorRuang) || 1; + const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A' + ruangInfo = `${data.klinik} - Ruang ${ruangLetter}`; + } + // 3. Fallback: gunakan data.ruang yang ada + else if (data.klinik && data.ruang) { + ruangInfo = `${data.klinik} - ${data.ruang}`; + } + // 4. Default: hanya nama klinik + else if (data.klinik) { ruangInfo = data.klinik; } diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index 1a8ae03..3acb669 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -304,6 +304,20 @@ + + + + + + { const result = await originalChangeKlinik(klinik); if (result.success) { - // Global broadcast to update all displays and other admins - broadcastUpdate(); + // If patient was moved to a different loket, include destination loket in broadcast + const broadcastData = result.moved && result.patient?.loketId + ? { loketId: result.patient.loketId } // Destination loket will get notified + : null; + broadcastUpdate(broadcastData); } }; @@ -569,10 +588,10 @@ const selectedFastTrack = ref(null); // Dialog Klinik Ruang const showKlinikRuangDialog = ref(false); const klinikRuangSearch = ref(""); -const activePanel = ref(null); // Tracks the open Klinik Ruang panel - -// Confirmation Replace Dialog +const activePanel = ref(null); // Additional Refs const showConfirmReplaceDialog = ref(false); +const showCheckInConfirmDialog = ref(false); +const showUnfinishedPatientDialog = ref(false); const pendingReplaceItem = ref(null); const pendingReplaceAction = ref(null); @@ -887,11 +906,38 @@ const nextQueueInfo = computed(() => { const handlePatientAction = async (action) => { if (currentProcessingPatient.value) { + if (action === 'check-in') { + // Periksa apakah tiket antrean ruang atau penunjang sudah dibuat + let allPatientsArray = []; + if (queueStore.allPatients) { + allPatientsArray = Array.isArray(queueStore.allPatients) + ? queueStore.allPatients + : (queueStore.allPatients.value || []); + } + + const hasGeneratedNextQueue = allPatientsArray.some(p => + (p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) || + (p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian) + ); + + if (!hasGeneratedNextQueue) { + showCheckInConfirmDialog.value = true; + return; // Hentikan proses, tunggu konfirmasi user + } + } + await processPatient(currentProcessingPatient.value, action); broadcastUpdate(); } }; +const confirmCheckIn = async () => { + if (currentProcessingPatient.value) { + await processPatient(currentProcessingPatient.value, 'check-in'); + broadcastUpdate(); + } +}; + const handleCall = async (count) => { if (count === 1) { callNext(loketId.value); @@ -932,14 +978,44 @@ const handleProcessNext = () => { const confirmAndProcess = (item, action) => { if (currentProcessingPatient.value) { + let allPatientsArray = []; + if (queueStore.allPatients) { + allPatientsArray = Array.isArray(queueStore.allPatients) + ? queueStore.allPatients + : (queueStore.allPatients.value || []); + } + + const hasGeneratedNextQueue = allPatientsArray.some(p => + (p.sourcePatientNo && p.sourcePatientNo === currentProcessingPatient.value.no) || + (p.referencePatient && p.referencePatient === currentProcessingPatient.value.noAntrian) + ); + pendingReplaceItem.value = item; pendingReplaceAction.value = action; - showConfirmReplaceDialog.value = true; + + if (hasGeneratedNextQueue) { + showUnfinishedPatientDialog.value = true; + } else { + showConfirmReplaceDialog.value = true; + } } else { executeProcess(item, action); } }; +const confirmUnfinishedAndProcess = async () => { + if (currentProcessingPatient.value) { + // Selesaikan pasien aktif + await processPatient(currentProcessingPatient.value, 'check-in'); + + // Proses pasien berikutnya / yang dipilih + executeProcess(pendingReplaceItem.value, pendingReplaceAction.value); + + pendingReplaceItem.value = null; + pendingReplaceAction.value = null; + } +}; + const executeProcess = async (item, action) => { if (action === "next") { processNextQueue(); @@ -966,47 +1042,70 @@ const handleConfirmReplace = () => { const { sendViaPost } = queueStore; -const broadcastUpdate = async (callData = null) => { +// Debounce timer to prevent multiple rapid broadcasts +let _broadcastDebounceTimer = null; + +const broadcastUpdate = async (extraData = null) => { + // extraData can be: + // - null: regular refresh (debounced) + // - { noAntrian, ... }: a CALL event (immediate, bypass debounce) + // - { loketId }: a MOVE event (immediate, bypass debounce, notify destination) + + const isCallEvent = extraData && extraData.noAntrian; // Patient call + const isMoveEvent = extraData && extraData.loketId && !extraData.noAntrian; // Klinik move + const isImmediate = isCallEvent || isMoveEvent; + + // Debounce only plain refresh events + if (!isImmediate) { + if (_broadcastDebounceTimer) return; // Already pending, skip + _broadcastDebounceTimer = setTimeout(() => { + _broadcastDebounceTimer = null; + }, 1500); + } + try { - const displayClientIds = []; - - // Base broadcast IDs for this loket - displayClientIds.push(`anjungan-loket-${loketId.value}`); - displayClientIds.push(`anjungan-masuk-${loketId.value}`); - // Also broadcast to clinic specific displays if needed - // displayClientIds.push(`anjungan-klinik-${...}`); - - // Broadcast to ALL loket admins and anjungans (including this one, for other computers opening the same loket) - for (let i = 1; i <= 10; i++) { - displayClientIds.push(`admin-loket-${i}`); - displayClientIds.push(`anjungan-loket-${i}`); - displayClientIds.push(`anjungan-masuk-${i}`); - } - - console.log('📡 [AdminLoket] Broadcasting update trigger to:', displayClientIds); - const payload = { loketId: loketId.value, triggerRefresh: true }; // If this is a CALL event, include the patient data - if (callData) { + if (isCallEvent) { payload.callEvent = { - ...callData, + ...extraData, loketId: loketId.value, loketName: loketName.value, calledAt: Date.now() }; } - // Send all broadcasts in parallel for speed - await Promise.all(displayClientIds.map(clientId => - sendViaPost({ - to_client: clientId, - data: payload - }) - )); + // Minimal broadcast: only send to the most relevant clients + const criticalTargets = [ + `anjungan-loket-${loketId.value}`, + `anjungan-masuk-${loketId.value}`, + `admin-loket-${loketId.value}`, + ]; + + // For MOVE events, also notify the destination loket + if (isMoveEvent && extraData.loketId && String(extraData.loketId) !== String(loketId.value)) { + const destLoketId = extraData.loketId; + criticalTargets.push(`admin-loket-${destLoketId}`); + criticalTargets.push(`anjungan-loket-${destLoketId}`); + criticalTargets.push(`anjungan-masuk-${destLoketId}`); + console.log(`📡 [AdminLoket] Patient moved: also notifying Loket ${destLoketId}`); + } + + console.log('📡 [AdminLoket] Broadcasting to:', criticalTargets); + + // Send sequentially with a delay to avoid 429 Rate Limit + for (const clientId of criticalTargets) { + try { + await sendViaPost({ to_client: clientId, data: payload }); + } catch (err) { + console.warn(`Failed to broadcast to ${clientId}:`, err?.message || err); + } + await new Promise(resolve => setTimeout(resolve, 80)); + } } catch (error) { console.error('❌ [AdminLoket] Error broadcasting update:', error); } diff --git a/pages/Dashboard.vue b/pages/Dashboard.vue index 4cefc67..6441836 100644 --- a/pages/Dashboard.vue +++ b/pages/Dashboard.vue @@ -240,11 +240,18 @@
+
+
+ mdi-chart-timeline-variant +

Belum Ada Data

+

Tren kunjungan bulanan akan tampil di sini

+
+