From f6060451c009f09933a0ef3dd3a5ad0edb1a15ff Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Tue, 27 Jan 2026 15:21:42 +0700 Subject: [PATCH] push update admin loket --- composables/useQueue.js | 89 +++- pages/AdminKlinik.vue | 2 + pages/AdminLoket/[id].vue | 188 ++++++-- pages/AdminLoket/index.vue | 246 ++++++++-- pages/AdminPenunjang.vue | 13 + pages/Anjungan/Anjungan/[id].vue | 10 +- pages/Anjungan/AntreanMasuk/[id].vue | 12 +- pages/Anjungan/AntrianLoket/[id].vue | 591 +++++++++-------------- stores/loketStore.js | 130 ++--- stores/queueStore.js | 689 +++++++++++++++------------ 10 files changed, 1138 insertions(+), 832 deletions(-) diff --git a/composables/useQueue.js b/composables/useQueue.js index b8030ae..3bdf497 100644 --- a/composables/useQueue.js +++ b/composables/useQueue.js @@ -1,9 +1,18 @@ // composables/useQueue.js import { ref, computed } from "vue"; import { useQueueStore } from "../stores/queueStore"; +import { useLoketStore } from "../stores/loketStore"; -export const useQueue = (adminType = "loket") => { +export const useQueue = (adminType = "loket", specificId = null) => { const queueStore = useQueueStore(); + const loketStore = useLoketStore(); + + // Normalize specificId to a reactive value + const idValue = computed(() => { + if (typeof specificId === 'function') return specificId(); + if (specificId && typeof specificId === 'object' && 'value' in specificId) return specificId.value; + return specificId; + }); // Local state const snackbar = ref(false); @@ -26,9 +35,10 @@ export const useQueue = (adminType = "loket") => { return patients.value; }); - // Computed from store - filtered by stage + // Computed from store - isolated by specificId if provided const currentProcessingPatient = computed(() => { - return queueStore.currentProcessingPatient[adminType]; + const key = idValue.value ? `${adminType}-${idValue.value}` : adminType; + return queueStore.currentProcessingPatient[key]; }); // Derive from stagePatients - ADD DEBUG LOGS @@ -70,7 +80,61 @@ export const useQueue = (adminType = "loket") => { return total.value; }); - const quotaUsed = computed(() => queueStore.quotaUsed); + // Helper to check if a patient is relevant to the current admin view + const isPatientRelevant = (p) => { + if (p.processStage !== adminType) return false; + const targetId = idValue.value; + if (!targetId) return true; // No specific ID, show all for this stage + + if (adminType === 'loket') { + const thisLoket = loketStore.getLoketById(parseInt(targetId)); + if (!thisLoket) return String(p.loketId) === String(targetId); + + // Must be assigned to this loket OR unassigned but clinic is served by this loket + const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId); + const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik); + + // User requested "match loket id AND klinik id" logic for calling? + // Actually, "match loket and clinic" usually means: + // if it has a clinic, it must be in our services. + // let's be strict: clinic must ALWAYS be in services if loketId is specified + if (thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { + if (!thisLoket.pelayanan.includes(p.kodeKlinik)) return false; + } + + return isAssignedToThis || isServedByThis; + } + + if (adminType === 'klinik') { + // Filter by clinic code or clinic name + return String(p.kodeKlinik) === String(targetId) || String(p.klinik) === String(targetId); + } + + if (adminType === 'penunjang') { + // Penunjang usually uses clinic name as the identifier in seeds + return String(p.klinik) === String(targetId) || String(p.kodeKlinik) === String(targetId); + } + + return true; + }; + + // 1. Menunggu Count (Backlog ONLY - Strictly status 'menunggu') + const menungguCount = computed(() => { + let list = menungguPatients.value || []; + return list.filter(isPatientRelevant).length; + }); + + // 2. Calculated Quota Used (Dynamic - any ticket NOT 'menunggu' counts as used) + const calculatedQuotaUsed = computed(() => { + const list = queueStore.allPatients || []; + return list.filter(p => { + if (!isPatientRelevant(p)) return false; + // Status is NOT 'menunggu' (meaning it's being served or finished) + return p.status !== 'menunggu'; + }).length; + }); + + const quotaUsed = computed(() => calculatedQuotaUsed.value); // Expose dev helper to refresh seed data const resetPatients = () => queueStore.resetPatients(); @@ -104,18 +168,20 @@ export const useQueue = (adminType = "loket") => { snackbar.value = true; }; - const callNext = () => { - const result = queueStore.callNext(adminType); + const callNext = (specificIdOverride = null) => { + const targetId = specificIdOverride || idValue.value; + const result = queueStore.callNext(adminType, targetId); showSnackbar(result.message, result.success ? "success" : "warning"); }; - const callMultiplePatients = (count) => { - const result = queueStore.callMultiplePatients(count, adminType); + const callMultiplePatients = (count, specificIdOverride = null) => { + const targetId = specificIdOverride || idValue.value; + const result = queueStore.callMultiplePatients(count, adminType, targetId); showSnackbar(result.message, result.success ? "success" : "warning"); }; const processPatient = (patient, action) => { - const result = queueStore.processPatient(patient, action, adminType); + const result = queueStore.processPatient(patient, action, adminType, idValue.value); let color = "success"; if (action === "terlambat") color = "warning"; @@ -127,7 +193,8 @@ export const useQueue = (adminType = "loket") => { // Helper function untuk mendapatkan pasien yang sedang diproses dari store const getCurrentProcessingPatientFromStore = () => { try { - const processingPatient = queueStore.currentProcessingPatient?.[adminType]; + const key = idValue.value ? `${adminType}-${idValue.value}` : adminType; + const processingPatient = queueStore.currentProcessingPatient?.[key]; if (!processingPatient) return null; // Dapatkan data terbaru dari allPatients langsung (bukan dari getPatientsByStage yang sudah difilter) @@ -218,7 +285,7 @@ export const useQueue = (adminType = "loket") => { }; const processNextQueue = () => { - const result = queueStore.processNextQueue(adminType); + const result = queueStore.processNextQueue(adminType, idValue.value); showSnackbar(result.message, result.success ? "success" : "warning"); }; diff --git a/pages/AdminKlinik.vue b/pages/AdminKlinik.vue index 94649c5..f9ecfda 100644 --- a/pages/AdminKlinik.vue +++ b/pages/AdminKlinik.vue @@ -32,6 +32,7 @@ class="mt-3" :total-quota="150" :used-quota="quotaUsed" + :menunggu-count="menungguCount" :has-next="!!nextPatient" @call="handleCall" /> @@ -262,6 +263,7 @@ const { terlambatPatients, pendingPatients, nextPatient, + menungguCount, quotaUsed, filteredKliniks, filteredPenunjangs, diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index f66ace7..21d905e 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -42,10 +42,10 @@ @@ -57,7 +57,11 @@
- + +
+ mdi-clock-outline + {{ menungguCount }} Antrean Menunggu +
@@ -218,6 +222,7 @@ import { useQueue } from "@/composables/useQueue"; import { useQueueStore } from "@/stores/queueStore"; import { useMasterStore } from "@/stores/masterStore"; import { useLoketStore } from "@/stores/loketStore"; +import { useClinicStore } from "@/stores/clinicStore"; import PageHeader from "@/components/common/PageHeader.vue"; import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue"; import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue"; @@ -231,6 +236,7 @@ const router = useRouter(); const masterStore = useMasterStore(); const queueStore = useQueueStore(); const loketStore = useLoketStore(); +const clinicStore = useClinicStore(); const { printTicketFromPatient } = useThermalPrint(); // Broadcast Channel @@ -259,6 +265,7 @@ const { waitingPatients, menungguPatients, nextPatient, + menungguCount, quotaUsed, filteredKliniks, filteredPenunjangs, @@ -271,7 +278,60 @@ const { openPenunjangDialog, changeKlinik, processNextQueue, -} = useQueue("loket"); +} = useQueue("loket", loketId); + +// PERSISTENCE FIX: Ensure data exists on mount +onMounted(async () => { + await nextTick(); + queueStore.ensureInitialData(); + + // 1. Ensure CLINIC data is loaded first (critical for mapping codes like "25" -> "MT") + // Check if we need to fetch reguler clinics (e.g. if we only have local exec clinics) + const hasRegulerClinics = clinicStore.clinics.some(c => c.jenisLayanan === 'Reguler'); + if (!hasRegulerClinics) { + console.log('AdminLoket: Fetching reguler clinics...'); + await clinicStore.fetchRegulerClinics(); + } + + // 2. Ensure LOKET data is loaded next (needs clinic data for mapping) + // Fix: Don't just check length (which might include local exec data). Check for API data (id < 1000) + const hasRegularLokets = loketStore.lokets.some(l => l.id < 1000); + const targetId = parseInt(loketId.value); + const currentLoketExists = loketStore.getLoketById(targetId); + + if (!hasRegularLokets || !currentLoketExists) { + console.log('AdminLoket: Fetching lokets (missing regular data or specific loket)...'); + await loketStore.fetchLoketFromAPI(); + } + + // 3. Fetch specific quota/loket data from API to ensure fresh counts + // This matches MasterLoket.vue implementation + await fetchQuotaFromAPI(); +}); + +const apiQuota = ref(null); + +// Fetch latest quota data specifically for this loket +const fetchQuotaFromAPI = async () => { + try { + const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/loket'); + const data = await response.json(); + + if (data && data.metadata && data.metadata.code === 200) { + // Find our specific loket + const targetId = parseInt(loketId.value); + const loketData = data.response.find(l => l.loketid === targetId); + + if (loketData) { + // Update local quota state + apiQuota.value = loketData.kuota; + console.log(`✅ [AdminLoket] API Quota Refreshed for Loket ${targetId}:`, apiQuota.value); + } + } + } catch (error) { + console.error('Error fetching fresh quota:', error); + } +}; const currentDate = ref( new Date().toLocaleDateString("id-ID", { @@ -323,40 +383,81 @@ const fastTrackOptions = computed(() => { // Combine all patients with status - PRESERVE ALL PROPERTIES const allPatientsForStage = computed(() => { const currentPatientNo = currentProcessingPatient.value?.no; + const targetLoketId = loketId.value; + const currentLoket = loketStore.getLoketById(parseInt(targetLoketId)); + console.log('🔍 [AdminLoket] currentLoket:', currentLoket); + console.log('🔍 [AdminLoket] targetLoketId:', targetLoketId); + const allowedServices = currentLoket?.pelayanan || []; - const diLoket = (diLoketPatients.value || []).map((p) => ({ - ...p, - status: p.no === currentPatientNo ? "diproses" : "diloket", - })); + // Helper to check if patient belongs to this loket + const isPatientForThisLoket = (p) => { + // 0. STRICT FILTER: Payment Type vs Loket Type + const isLoketEksekutif = currentLoket?.tipeloket === 'EKSEKUTIF' || currentLoket?.jenisloket === 'EKSEKUTIF' || (currentLoket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF'); + const isPatientEksekutif = (p.pembayaran || '').toUpperCase().includes('EKSEKUTIF') || (p.pembayaran || '').toUpperCase().includes('VIP'); // Check payment type + + if (isLoketEksekutif) { + // Loket Eksekutif HANYA melayani pasien Eksekutif + if (!isPatientEksekutif) return false; + } else { + // Loket Reguler TIDAK melayani pasien Eksekutif + if (isPatientEksekutif) return false; + } + + // Priority 1: Explicit loketId match + if (p.loketId) { + return String(p.loketId) === String(targetLoketId); + } + + // Priority 2: Service (kodeKlinik) match + if (p.kodeKlinik) { + return allowedServices.includes(p.kodeKlinik); + } + + // Priority 3: Fallback for seed/legacy data (Match by clinic name) + if (p.klinik && currentLoket) { + const normalizedKlinik = p.klinik.trim().toUpperCase(); + // Check if any allowed service matches this clinic name in _spesialisDetail + const matchByDetail = (currentLoket._spesialisDetail || []).some(s => + s.namaklinik && s.namaklinik.trim().toUpperCase() === normalizedKlinik + ); + if (matchByDetail) return true; + } + + return false; + }; - const terlambat = (terlambatPatients.value || []).map((p) => ({ - ...p, - status: "terlambat", - })); + const diLoket = (diLoketPatients.value || []) + .filter(isPatientForThisLoket) + .map((p) => ({ + ...p, + status: p.no === currentPatientNo ? "diproses" : "diloket", + })); - const pending = (pendingPatients.value || []).map((p) => ({ - ...p, - status: "pending", - })); + const terlambat = (terlambatPatients.value || []) + .filter(isPatientForThisLoket) + .map((p) => ({ + ...p, + status: "terlambat", + })); - const waiting = (waitingPatients.value || []).map((p) => ({ - ...p, - status: "waiting", - })); + const pending = (pendingPatients.value || []) + .filter(isPatientForThisLoket) + .map((p) => ({ + ...p, + status: "pending", + })); + + const waiting = (waitingPatients.value || []) + .filter(isPatientForThisLoket) + .map((p) => ({ + ...p, + status: "waiting", + })); const combined = [...diLoket, ...waiting, ...terlambat, ...pending]; return combined; }); -const diLoketCount = computed(() => { - const currentPatientNo = currentProcessingPatient.value?.no; - return (diLoketPatients.value || []).filter(p => p.no !== currentPatientNo).length; -}); - -const menungguCount = computed(() => { - return (menungguPatients.value || []).length; -}); - const waitingCount = computed(() => { return (waitingPatients.value || []).length; }); @@ -378,14 +479,9 @@ 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'. + callNext(loketId.value); } else { - callMultiplePatients(count); + callMultiplePatients(count, loketId.value); } }; @@ -399,7 +495,7 @@ const handleProcessNext = () => { const handleCallPatient = () => { if (currentProcessingPatient.value) { - const result = queueStore.callProcessingPatient('loket'); + const result = queueStore.callProcessingPatient('loket', loketId.value); if (result.success) { snackbarText.value = result.message; snackbarColor.value = "success"; @@ -445,6 +541,11 @@ onMounted(() => { // Initialize Broadcast Channel broadcastChannel = new BroadcastChannel('antrian-loket-channel'); + + // Ensure initial data is loaded if store is empty (after hydration) + setTimeout(() => { + queueStore.ensureInitialData(); + }, 200); }); onUnmounted(() => { @@ -606,6 +707,19 @@ const buatAntreanKlinikRuang = async (klinikRuang, ruang) => { text-transform: uppercase; } +.waiting-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--color-warning-100); + color: var(--color-warning-700); + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + border: 1px solid var(--color-warning-300); +} + .data-header { display: flex; flex-direction: column; diff --git a/pages/AdminLoket/index.vue b/pages/AdminLoket/index.vue index 444b38c..c766fce 100644 --- a/pages/AdminLoket/index.vue +++ b/pages/AdminLoket/index.vue @@ -9,38 +9,75 @@ />
- - +
- - - mdi-desktop-mac -
{{ loket.namaLoket }}
- - {{ loket.loketAktif ? 'Aktif' : 'Non-Aktif' }} - -
- mdi-account-group - {{ loket.pembayaran }} +
+
+

{{ loket.namaLoket }}

+
+ + {{ loket.pembayaran }} + + ID: {{ loket.id }} + + {{ loket.loketAktif ? 'Aktif' : 'Non-Aktif' }} +
- - - - +
+
+ +
+
+ mdi-hospital-building + {{ loket.pelayanan?.length || 0 }} Pelayanan +
+
+ + {{ getServiceName(loket, serviceId) }} + + + +{{ loket.pelayanan.length - 3 }} + +
+
+
+
-
- mdi-desktop-mac-dashboard -
Tidak ada data loket yang tersedia
+
+ mdi-desktop-mac-dashboard +

Tidak Ada Data Loket Tersedia

+

Silakan tambah data loket terlebih dahulu di halaman master

+ + mdi-cog + Ke Halaman Master +
@@ -50,10 +87,12 @@ import { computed, onMounted } from 'vue'; import { useRouter } from 'vue-router'; import { useLoketStore } from '@/stores/loketStore'; +import { useMasterStore } from '@/stores/masterStore'; import PageHeader from '@/components/common/PageHeader.vue'; const router = useRouter(); const loketStore = useLoketStore(); +const masterStore = useMasterStore(); // Ensure data is loaded onMounted(() => { @@ -66,6 +105,21 @@ const loketList = computed(() => { return loketStore.loketData || []; }); +// Helper untuk mendapatkan nama layanan/klinik +const getServiceName = (item, serviceId) => { + const idStr = String(serviceId); + + if (item._spesialisDetail && Array.isArray(item._spesialisDetail)) { + const detail = item._spesialisDetail.find(s => String(s.idklinik) === idStr); + if (detail && detail.namaklinik) return detail.namaklinik; + } + + const byId = masterStore.getKlinikById ? masterStore.getKlinikById(serviceId) : null; + if (byId && byId.nama) return byId.nama; + + return masterStore.getKlinikNameByKode ? masterStore.getKlinikNameByKode(serviceId) : serviceId; +}; + const navigateToLoket = (id) => { router.push(`/adminloket/${id}`); }; @@ -74,9 +128,9 @@ const navigateToLoket = (id) => { diff --git a/pages/AdminPenunjang.vue b/pages/AdminPenunjang.vue index bac47aa..895eb5e 100644 --- a/pages/AdminPenunjang.vue +++ b/pages/AdminPenunjang.vue @@ -450,6 +450,19 @@ const getStatusLabel = (status) => { text-transform: uppercase; } +.waiting-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: #fef3c7; + color: #b45309; + padding: 6px 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + border: 1px solid #fcd34d; +} + .patient-details { background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%); border-radius: 8px; diff --git a/pages/Anjungan/Anjungan/[id].vue b/pages/Anjungan/Anjungan/[id].vue index 6cf3616..5bbf8fa 100644 --- a/pages/Anjungan/Anjungan/[id].vue +++ b/pages/Anjungan/Anjungan/[id].vue @@ -1094,7 +1094,9 @@ const registerPatient = async (visitType, paymentType, namaDokter, isFastTrack = 'Shift 1', namaDokter, isFastTrack, - fastTrackData // Pass fastTrackData (penanggungJawab, alasanFastTrack) + fastTrackData, // Pass fastTrackData (penanggungJawab, alasanFastTrack) + null, // Stop passing anjunganId as targetLoketId (fix routing bug) + null // Stop passing anjunganName as targetLoket name ); if (result && result.success && result.patient) { @@ -1168,7 +1170,11 @@ const submitBooking = async () => { 'JADWAL_LAIN', bookingForm.value.date, bookingForm.value.shift, - namaDokter + namaDokter, + false, // isFastTrack + null, // fastTrackData + null, // Stop passing anjunganId as targetLoketId (fix routing bug) + null // Stop passing anjunganName as targetLoket name ); if (result && result.success && result.patient) { diff --git a/pages/Anjungan/AntreanMasuk/[id].vue b/pages/Anjungan/AntreanMasuk/[id].vue index f48bdee..13e3691 100644 --- a/pages/Anjungan/AntreanMasuk/[id].vue +++ b/pages/Anjungan/AntreanMasuk/[id].vue @@ -186,7 +186,17 @@ const screenData = computed(() => { // Get all loket IDs configured for this screen const configuredLoketIds = computed(() => { - return screenData.value?.loket || [] + if (screenData.value) { + return screenData.value.loket || [] + } + + // FALLBACK: If screen not found, treat ID as a Loket ID + // This allows displaying a specific single loket without screen config + if (screenId.value) { + return [screenId.value] + } + + return [] }) diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue index bd67211..c4cbc0b 100644 --- a/pages/Anjungan/AntrianLoket/[id].vue +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -40,20 +40,6 @@
- -
-
-
{{ queue.noAntrian.split(' |')[0] }}
-
- mdi-timer - {{ currentMultipleCallsTimer }} -
-
-
@@ -117,30 +103,30 @@ - -
+ +
+
DI LOKET (SIAP DIPANGGIL)
- {{ queue.noAntrian.split(' |')[0] }} +
+ {{ queue.noAntrian.split(' |')[0] }} +
+ -
+
mdi-clock-outline

Tidak Ada Antrian

@@ -219,19 +205,39 @@ const loketData = computed(() => { return loketStore.getLoketById(id) || null }) -// Get current processing patient dari AdminLoket +// Get current processing patient dari AdminLoket (isolated by loketId) const currentProcessingPatient = computed(() => { - return queueStore.currentProcessingPatient?.loket || null + const targetId = loketId.value; + const key = targetId ? `loket-${targetId}` : 'loket'; + return queueStore.currentProcessingPatient?.[key] || null; }) // Get all patients with processStage "loket" and filter status "di-loket" (sudah check-in) // Hanya tampilkan nomor antrian yang sudah check-in dan berstatus "di-loket" +// Get all patients with processStage "loket" const loketPatients = computed(() => { - const allPatients = queueStore.getPatientsByStage('loket').value.all - // Hanya tampilkan antrian dengan status "di-loket" (sudah check-in di admin loket) - // Jangan tampilkan status "waiting", "menunggu", "terlambat", atau "pending" - return allPatients.filter(p => p.status === 'di-loket') -}) + // Get all patients from queueStore + const allPatients = queueStore.allPatients?.value || queueStore.allPatients || []; + + // Filter for this stage and relevant statuses + // Include 'di-loket' (serving/called) and 'waiting' (next in line) + return allPatients.filter(p => + p.processStage === 'loket' && + (p.status === 'di-loket' || p.status === 'waiting') + ); +}); + +// Watch currentProcessingPatient to clear broadcastedPatient when patient changes +watch(() => { + const targetId = loketId.value; + const key = targetId ? `loket-${targetId}` : 'loket'; + return queueStore.currentProcessingPatient?.[key]; +}, (newVal, oldVal) => { + if (!newVal || (oldVal && newVal.no !== oldVal.no)) { + console.log('🔄 [Anjungan] Processing patient changed, clearing broadcasted call'); + broadcastedPatient.value = null; + } +}, { deep: true }); // Helper untuk mendapatkan waktu check-in atau waktu dipanggil const getCheckInTime = (patient) => { @@ -326,94 +332,106 @@ const getKlinikNameFromPatient = (patient) => { return 'UMUM' } -// Display clinics with their queues - Group by klinik, filtered by loket pelayanan -const displayedClinics = computed(() => { - // Get pelayanan dari loket yang dipilih +// 1. Dapatkan semua pasien yang relevan untuk loket ini (berdasarkan loketId atau pelayanan) +const filteredPatientsForLoket = computed(() => { const targetLoketId = loketId.value let allowedPelayananCodes = [] if (targetLoketId && loketData.value) { - // Ambil pelayanan dari loket (array of kode klinik seperti ['AN', 'IP', 'SR']) allowedPelayananCodes = loketData.value.pelayanan || [] } - // Jika tidak ada loketId atau pelayanan kosong, tampilkan semua const shouldFilterByPelayanan = targetLoketId && allowedPelayananCodes.length > 0 - // Include currentProcessingPatient dalam list jika ada - let allPatientsForDistribution = [...loketPatients.value] + // Ambil semua pasien yang berada di tahap 'loket' + let allPatients = loketPatients.value - // Include currentProcessingPatient jika ada - if (currentProcessingPatient.value) { - const existsInList = allPatientsForDistribution.find(p => p.no === currentProcessingPatient.value.no) - if (!existsInList) { - allPatientsForDistribution.push(currentProcessingPatient.value) + // Include currentProcessingPatient jika ada dan belum ada di list + const targetId = loketId.value; + const key = targetId ? `loket-${targetId}` : 'loket'; + const currentProc = queueStore.currentProcessingPatient?.[key] + + if (currentProc) { + const exists = allPatients.find(p => p.no === currentProc.no) + if (!exists) { + allPatients = [...allPatients, currentProc] } } - - // Filter berdasarkan loket jika ada loketId di route - if (targetLoketId) { - allPatientsForDistribution = allPatientsForDistribution.filter(p => { - const patientLoketId = p.loketId || 1 - return patientLoketId === targetLoketId || - patientLoketId === targetLoketId.toString() || - (p.loket && loketData.value && p.loket.toLowerCase().includes(loketData.value.namaLoket?.toLowerCase() || '')) - }) - } - - // Group by klinik dan filter berdasarkan pelayanan loket - const clinicsMap = new Map() - - allPatientsForDistribution.forEach(patient => { - // Gunakan helper untuk mendapatkan nama klinik yang benar - // Ini akan parse dari noAntrian jika patient.klinik tidak sesuai - const klinikName = getKlinikNameFromPatient(patient) + + // Filter based on Loket ID and assigned Services (Pelayanan) + return allPatients.filter(patient => { + // A. Filter by Loket ID (Priority) + // Jika pasien sudah memiliki loketId eksplisit, harus match + if (patient.loketId) { + const isMatch = patient.loketId === targetLoketId || patient.loketId === targetLoketId.toString() + if (isMatch) return true + } - // Jika ada filter pelayanan, cek apakah klinik ini ada di pelayanan loket + // B. Filter by Loket Name (Priority) + if (patient.loket && loketData.value) { + if (patient.loket.toLowerCase().includes(loketData.value.namaLoket?.toLowerCase() || '')) return true + } + + // C. Filter by assigned Services (Pelayanan) + // Jika tidak ada loketId eksplisit (e.g. seed/new onsite), check if clinic is handled by this loket if (shouldFilterByPelayanan) { - // Cari kode klinik dari nama klinik menggunakan clinicStore + const klinikName = getKlinikNameFromPatient(patient) const clinic = clinicStore.getClinicByName ? clinicStore.getClinicByName(klinikName) : null + let isAllowed = false if (clinic) { - // Jika ditemukan di clinicStore, cek apakah kode-nya ada di pelayanan - const isAllowed = allowedPelayananCodes.includes(clinic.kode) - if (!isAllowed) return // Skip jika tidak ada di pelayanan + isAllowed = allowedPelayananCodes.includes(clinic.kode) } else { - // Jika tidak ditemukan di clinicStore, cek menggunakan masterStore - // Juga cek dari noAntrian jika ada (RA001 -> RA -> Radioterapi) - let matchedKode = allowedPelayananCodes.find(kode => { - const k = masterStore.getKlinikByKode ? masterStore.getKlinikByKode(kode) : null - return k && k.nama === klinikName - }) - - // Jika belum match, coba parse dari noAntrian - if (!matchedKode && patient.noAntrian) { + // Fallback checks for codes in noAntrian + if (patient.noAntrian) { const noAntrianPart = patient.noAntrian.split(' |')[0] const match = noAntrianPart.match(/^([A-Z]+)/) if (match) { const kodeFromNoAntrian = match[1] - matchedKode = allowedPelayananCodes.find(kode => kode === kodeFromNoAntrian) + if (allowedPelayananCodes.includes(kodeFromNoAntrian)) isAllowed = true } } - - if (!matchedKode) return // Skip jika tidak match } + + // Fallback: Match by clinic name if no code match found + if (!isAllowed && klinikName && loketData.value) { + const normalizedKlinik = klinikName.trim().toUpperCase(); + isAllowed = (loketData.value._spesialisDetail || []).some(s => + s.namaklinik && s.namaklinik.trim().toUpperCase() === normalizedKlinik + ); + } + + return isAllowed } - - // Pastikan pasien memiliki klinik yang valid sebelum di-group - if (!klinikName || klinikName.trim() === '') { - return // Skip jika klinik tidak valid - } + + // D. If no explicit filters match and we shouldn't filter by pelayanan, + // we only show if it's default Loket 1 (fallback for untagged items) + if (!targetLoketId || targetLoketId === 1) return true + + return false + }) +}) + +// 2. Tampilkan klinik dengan antrean masing-masing +const displayedClinics = computed(() => { + const filteredPatients = filteredPatientsForLoket.value + const clinicsMap = new Map() + + filteredPatients.forEach(patient => { + const klinikName = getKlinikNameFromPatient(patient) + if (!klinikName || klinikName.trim() === '') return if (!clinicsMap.has(klinikName)) { clinicsMap.set(klinikName, []) } - // Pastikan pasien yang di-push memiliki klinik yang sesuai clinicsMap.get(klinikName).push(patient) }) // PENTING: Selalu tampilkan card untuk semua pelayanan loket, // bahkan jika tidak ada antrian + const targetLoketId = loketId.value + const allowedPelayananCodes = (targetLoketId && loketData.value) ? (loketData.value.pelayanan || []) : [] + if (targetLoketId && loketData.value) { allowedPelayananCodes.forEach(kode => { // Prioritas 1: Cari nama klinik dari _spesialisDetail (data API) @@ -524,124 +542,58 @@ const displayedClinics = computed(() => { // Flatten multiple call groups untuk mendapatkan semua multiple calls const allMultipleCalls = multipleCallGroups.flat() - // Current queue adalah yang sedang dilayani untuk klinik ini - // Prioritas 1: Pasien yang sedang dipanggil di hero section (currentCalledQueue) jika sesuai dengan klinik ini - // Prioritas 2: Pasien yang status 'di-loket' (yang sedang dilayani) - // PASTIKAN hanya mengambil dari pasien yang klinik-nya sesuai dengan card ini + // Determine currentQueue: The patient currently being served in this clinic let currentQueue = null - - // Normalize nama klinik sekali di awal scope const normalizedKlinikName = klinikName.trim() - // Prioritas 1: Cek apakah pasien yang sedang dipanggil di hero section sesuai dengan klinik ini - // Jika pasien dipanggil di hero section, tampilkan di "SEDANG DILAYANI" di card klinik yang sesuai - // Filter berdasarkan nama klinik: jika RADIOTERAPI dipanggil, tampilkan di card RADIOTERAPI, dst + // Get all checked-in patients for this clinic + const servingQueues = sortedQueues.filter(q => + q.status === 'di-loket' && q.processStage === 'loket' && getKlinikNameFromPatient(q) === normalizedKlinikName + ) + + // Prioritas 1: If Hero section is calling someone for THIS clinic, show them as serving if (currentCalledQueue.value) { const heroPatient = currentCalledQueue.value const heroKlinik = getKlinikNameFromPatient(heroPatient) - // Pastikan nama klinik dari hero section sesuai dengan card ini if (heroKlinik === normalizedKlinikName) { - // Cari pasien di sortedQueues berdasarkan no, noAntrian, atau barcode - const heroInQueues = sortedQueues.find(q => + currentQueue = sortedQueues.find(q => q.no === heroPatient.no || - (q.noAntrian && heroPatient.noAntrian && q.noAntrian === heroPatient.noAntrian) || - (q.barcode && heroPatient.barcode && q.barcode === heroPatient.barcode) - ) - - if (heroInQueues) { - // Gunakan pasien dari sortedQueues (data lebih lengkap) - // Pastikan pasien ini sesuai dengan filter (processStage 'loket') - if (heroInQueues.processStage === 'loket') { - currentQueue = heroInQueues - } - } else { - // Jika tidak ada di sortedQueues, gunakan langsung dari hero section - // Pastikan pasien sesuai dengan filter (processStage 'loket' dan status 'di-loket') - if (heroPatient.processStage === 'loket' && - heroPatient.status === 'di-loket') { - // Langsung gunakan pasien dari hero section karena sudah dipanggil dan check-in - currentQueue = heroPatient - } - } + (q.noAntrian && heroPatient.noAntrian && q.noAntrian === heroPatient.noAntrian) + ) || heroPatient } } - // Prioritas 2: Cari yang sedang di-loket (sedang dilayani) dan pastikan klinik-nya sesuai - // HANYA ambil yang dipanggil dari admin loket (processStage masih 'loket') - // Gunakan helper untuk mendapatkan nama klinik yang benar dari pasien + // Prioritas 2: Persistence - If no active Hero call for this clinic, + // find the patient who was explicitly called by admin and is still in servingQueues (di-loket) if (!currentQueue) { - const diLoketQueues = sortedQueues.filter(q => { - // Pastikan: - // 1. Status 'di-loket' (sudah check-in di admin loket) - // 2. processStage masih 'loket' (masih di admin loket, belum pindah ke klinik) - // 3. Klinik-nya sesuai dengan card ini - const patientKlinik = getKlinikNameFromPatient(q) - return q.status === 'di-loket' && - q.processStage === 'loket' && - patientKlinik === normalizedKlinikName - }) + const calledServingQueues = servingQueues + .filter(q => q.calledByAdmin) // Use calledByAdmin for explicit loket call + .sort((a, b) => new Date(b.lastCalledAt) - new Date(a.lastCalledAt)) - if (diLoketQueues.length > 0) { - // Ambil yang paling lama (berdasarkan waktu check-in atau createdAt) - currentQueue = diLoketQueues.sort((a, b) => { - const checkInA = getCheckInTime(a) - const checkInB = getCheckInTime(b) - return checkInA - checkInB // Yang lebih lama lebih dulu - })[0] + if (calledServingQueues.length > 0) { + currentQueue = calledServingQueues[0] } } - - // Multiple calls untuk display - semua yang dipanggil bersamaan (termasuk yang sudah di-loket) - // Pastikan hanya dari klinik ini - const multipleCalls = allMultipleCalls.filter(q => { - // Pastikan dari klinik yang sama (gunakan helper untuk mendapatkan nama klinik yang benar) + + // Multiple calls for display + const multipleCalls = (allMultipleCalls || []).filter(q => { const queueKlinik = getKlinikNameFromPatient(q) if (queueKlinik !== normalizedKlinikName) return false - // Exclude currentQueue jika ada - return q.no !== currentQueue?.no || currentQueue?.status !== 'di-loket' - }) - - // Validasi akhir: pastikan currentQueue benar-benar dari klinik ini dan dari admin loket - let validatedCurrentQueue = currentQueue - if (validatedCurrentQueue) { - // Pastikan klinik-nya sesuai - const queueKlinik = getKlinikNameFromPatient(validatedCurrentQueue) - if (queueKlinik !== normalizedKlinikName) { - // Jika tidak match, set ke null untuk menghindari menampilkan tiket yang salah - validatedCurrentQueue = null - } - - // Pastikan processStage masih 'loket' (dipanggil dari admin loket) - if (validatedCurrentQueue && validatedCurrentQueue.processStage !== 'loket') { - validatedCurrentQueue = null - } - - // Pastikan status selalu 'di-loket' (sudah check-in di admin loket) - if (validatedCurrentQueue && validatedCurrentQueue.status !== 'di-loket') { - validatedCurrentQueue = null - } - } - - // Validasi allQueues: pastikan semua pasien benar-benar dari klinik ini - // Hanya ambil yang status 'di-loket' (sudah check-in) dan dipanggil dari admin loket - const validatedAllQueues = sortedQueues.filter(q => { - // Pastikan: - // 1. Status 'di-loket' (sudah check-in di admin loket) - // 2. processStage masih 'loket' (masih di admin loket, belum pindah ke klinik) - // 3. Klinik-nya sesuai dengan card ini (dibaca dari nomor antrian) - const queueKlinik = getKlinikNameFromPatient(q) - return q.status === 'di-loket' && - q.processStage === 'loket' && - queueKlinik === normalizedKlinikName + return q.no !== currentQueue?.no }) + // Separate di-loket grid - excluding currentQueue + const diLoketQueuesInGrid = servingQueues.filter(q => q.no !== currentQueue?.no); + return { name: klinikName, - currentQueue: validatedCurrentQueue, + currentQueue: currentQueue, multipleCalls: multipleCalls, - allQueues: validatedAllQueues, - totalQueues: validatedAllQueues.length + diLoketQueues: diLoketQueuesInGrid, + waitingQueues: [], // Removed as per request + allQueues: diLoketQueuesInGrid, + totalQueues: servingQueues.length } }) @@ -667,9 +619,18 @@ 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(() => { + const targetLoketId = String(loketId.value) + // Prioritas 0: Broadcasted patient (Real-time from BroadcastChannel) + // Check if the broadcast came from this loket if (broadcastedPatient.value) { - return 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 + } } // Prioritas 1: Pasien yang sedang diproses di admin loket (currentProcessingPatient) @@ -677,6 +638,13 @@ const currentCalledQueue = computed(() => { if (currentProcessingPatient.value) { const processingPatient = currentProcessingPatient.value + // Cek apakah data ini milik loket ini (berdasarkan metadata loketId) + // Jika tidak ada loketId (misal data lama), fallback ke true untuk Admin 1 + const patientLoketId = processingPatient.loketId ? String(processingPatient.loketId) : "1" + if (patientLoketId !== targetLoketId) { + return null + } + // Cek apakah sudah dipanggil oleh admin if (!processingPatient.calledByAdmin) { return null @@ -696,58 +664,6 @@ const currentCalledQueue = computed(() => { return null }) -// Current multiple calls - antrian yang dipanggil bersamaan -const currentMultipleCalls = computed(() => { - const allMultipleCalls = displayedClinics.value - .flatMap(klinik => (klinik.multipleCalls || []).map(queue => ({ - ...queue, - klinikName: klinik.name - }))) - .sort((a, b) => { - const timeA = a.lastCalledAt ? new Date(a.lastCalledAt) : new Date(a.createdAt || 0) - const timeB = b.lastCalledAt ? new Date(b.lastCalledAt) : new Date(b.createdAt || 0) - return timeB - timeA - }) - - if (allMultipleCalls.length > 0) { - // Group by call time (within 5 seconds) - const groups = [] - const processed = new Set() - - allMultipleCalls.forEach(queue => { - if (processed.has(queue.no)) return - - const callTime = queue.lastCalledAt ? new Date(queue.lastCalledAt) : new Date(queue.createdAt || 0) - const group = [queue] - processed.add(queue.no) - - allMultipleCalls.forEach(otherQueue => { - if (!processed.has(otherQueue.no)) { - const otherCallTime = otherQueue.lastCalledAt ? new Date(otherQueue.lastCalledAt) : new Date(otherQueue.createdAt || 0) - const timeDiff = Math.abs(callTime - otherCallTime) - if (timeDiff <= 5000) { - group.push(otherQueue) - processed.add(otherQueue.no) - } - } - }) - - if (group.length > 1) { - groups.push(group) - } - }) - - if (groups.length > 0) { - // Return most recent group - return groups[0].map(q => ({ - ...q, - klinik: q.klinikName || q.klinik || 'Klinik' - })) - } - } - - return null -}) // Timer untuk card utama (TTS countdown) const currentMultipleCallsTimer = computed(() => { @@ -792,45 +708,11 @@ const getTimerText = (queue) => { // Next 5 tickets to be called - tiket yang sudah check-in (status 'di-loket') dan belum diproses const nextTicketsToCall = computed(() => { - // Ambil semua tiket yang sudah check-in (status 'di-loket') dan processStage 'loket' - const allDiLoketQueues = loketPatients.value.filter(p => + // Gunakan pasien yang sudah ter-filter untuk loket ini + const filteredQueues = filteredPatientsForLoket.value.filter(p => p.status === 'di-loket' && p.processStage === 'loket' ) - // Filter berdasarkan pelayanan loket jika ada - const targetLoketId = loketId.value - let filteredQueues = allDiLoketQueues - - if (targetLoketId && loketData.value) { - const allowedPelayananCodes = loketData.value.pelayanan || [] - if (allowedPelayananCodes.length > 0) { - filteredQueues = allDiLoketQueues.filter(patient => { - // Dapatkan nama klinik dari nomor antrian - const klinikName = getKlinikNameFromPatient(patient) - const clinic = clinicStore.getClinicByName ? clinicStore.getClinicByName(klinikName) : null - - if (clinic) { - return allowedPelayananCodes.includes(clinic.kode) - } else { - // Coba match dengan kode dari nomor antrian - if (patient.noAntrian) { - const noAntrianPart = patient.noAntrian.split(' |')[0] - const match = noAntrianPart.match(/^([A-Z]+)/) - if (match) { - const kodeFromNoAntrian = match[1] - return allowedPelayananCodes.includes(kodeFromNoAntrian) - } - } - const matchedKode = allowedPelayananCodes.find(kode => { - const k = masterStore.getKlinikByKode ? masterStore.getKlinikByKode(kode) : null - return k && k.nama === klinikName - }) - return !!matchedKode - } - }) - } - } - // Urutkan berdasarkan waktu check-in (yang check-in duluan lebih dulu) const sortedByCheckIn = filteredQueues.sort((a, b) => { const checkInA = getCheckInTime(a) @@ -894,7 +776,7 @@ const isCalled = (queue) => { // Statistics const statistics = computed(() => { - const all = loketPatients.value + const all = filteredPatientsForLoket.value const active = all.filter(p => p.status === 'di-loket').length return { total: all.length, @@ -919,81 +801,67 @@ const updateTime = () => { }) } +// WebSocket configuration (Placeholder) +const config = useRuntimeConfig() +const wsBaseUrl = config.public?.wsBaseUrl || 'ws://10.10.150.100:8084/api/v1/ws' +const anjunganClientId = computed(() => `anjungan-loket-${loketId.value}`) +let wsInstance = null +const isConnected = ref(false) + +const initWebSocket = () => { + console.log('🔌 WebSocket Placeholder: Connecting to', wsBaseUrl); + // Implementation will follow AntrianKlinikRuang style when backend is ready + return null; +} + onMounted(() => { - // Fetch loket data dari API di background - loketStore.fetchLoketFromAPI(true).then(result => { - if (result.success) { - console.log('✅ [AntrianLoket] Loket API data loaded:', result.message); - } else { - console.warn('⚠️ [AntrianLoket] Failed to fetch loket from API:', result.message); - } - }).catch(err => { - console.error('❌ [AntrianLoket] Error fetching loket from API:', err); - }); + // Fetch loket data from API to ensure fresh mapping + loketStore.fetchLoketFromAPI(true).catch(console.error); - // Langsung start timer tanpa menunggu fetch - updateTime() - // Init BroadcastChannel + updateTime(); + timeInterval = setInterval(updateTime, 1000); + + // Initialize BroadcastChannel try { - broadcastChannel = new BroadcastChannel('antrian-loket-channel') + 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 + console.log('📡 Anjungan received broadcast:', event.data); + if (event.data?.type === 'CALL_PATIENT') { + const { patient, loketId: senderId } = event.data; + const targetId = String(loketId.value); - // 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 - } + console.log(`📞 Call from Loket ${senderId} to Anjungan ${targetId}`); + + // Match loket ID strictly to ensure call isolation + if (targetId && String(senderId) !== targetId) { + console.warn(`🚫 Ignoring call: Loket ID mismatch (${senderId} vs ${targetId})`); + return; } - // Update local state untuk prioritas tampilan hero - broadcastedPatient.value = patient + // Update local state for Hero display + broadcastedPatient.value = { + ...patient, // Spread patient data + _rawMessage: event.data // Attach metadata for filtering + }; - // PENTING: Update Store agar list antrian juga berubah - // 1. Update currentProcessingPatient - if (!queueStore.currentProcessingPatient) { - queueStore.currentProcessingPatient = {} + // Sync with store using isolated key + if (queueStore.currentProcessingPatient) { + const key = targetId ? `loket-${targetId}` : 'loket'; + queueStore.currentProcessingPatient[key] = patient; } - 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) + console.error('❌ BroadcastChannel error:', e); } - // Start TTS checker - timeInterval = setInterval(updateTime, 1000) -}) + // Future: initWebSocket() + + // Ensure initial data is loaded if store is empty + setTimeout(() => { + queueStore.ensureInitialData(); + }, 200); +}); onUnmounted(() => { if (timeInterval) clearInterval(timeInterval) @@ -1279,41 +1147,6 @@ onUnmounted(() => { margin-top: 6px; } -/* Multiple Calls on the Side */ -.hero-multiple-calls-side { - display: flex; - flex-direction: column; - gap: 8px; - flex: 0 0 140px; -} - -.hero-call-card-side { - background: linear-gradient(135deg, var(--color-warning-600) 0%, var(--color-warning-700) 100%); - border-radius: 12px; - padding: 12px 16px; - text-align: center; - box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3); -} - -.call-number-side { - font-size: 24px; - font-weight: 800; - color: var(--color-neutral-100); - letter-spacing: 2px; - line-height: 1; - margin-bottom: 4px; -} - -.call-timer-side { - display: flex; - align-items: center; - justify-content: center; - gap: 4px; - font-size: 11px; - font-weight: 700; - color: var(--color-neutral-100); - margin-top: 4px; -} @keyframes pulse-highlight { 0%, 100% { @@ -1503,6 +1336,8 @@ onUnmounted(() => { display: grid; gap: 10px; margin-top: 12px; + grid-template-rows: auto; + width: 100%; } .queue-grid-item { @@ -1526,6 +1361,14 @@ onUnmounted(() => { box-shadow: 0 2px 6px rgba(255, 152, 0, 0.3); } + /* Waiting queue - green or neutral highlight */ + &.is-waiting { + background: var(--color-success-50); + border: 2px solid var(--color-success-400); + color: var(--color-success-800); + font-weight: 600; + } + /* Multiple calls - highlighted in red */ &.is-multiple { background: var(--color-danger-100); diff --git a/stores/loketStore.js b/stores/loketStore.js index 0ef36b9..dd535d8 100644 --- a/stores/loketStore.js +++ b/stores/loketStore.js @@ -262,7 +262,7 @@ export const useLoketStore = defineStore('loket', () => { /** * Fetch loket data dari API backend - * MENGGUNAKAN endpoint /api/v1/klinik/reguler untuk memetakan Loket -> Clinics + * MENGGUNAKAN endpoint /api/v1/klinik/loket yang sudah terstruktur per Loket */ const fetchLoketFromAPI = async (force = false, retryCount = 0) => { if (activeFetchPromise && retryCount === 0) { @@ -280,9 +280,8 @@ export const useLoketStore = defineStore('loket', () => { apiError.value = null; try { - 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'); + console.log(`🔄 [loketStore] Fetching loket configuration (Try ${retryCount + 1})...`); + const response = await fetch('http://10.10.150.131:8089/api/v1/klinik/loket'); if (!response.ok) { if (response.status === 429 && retryCount < 3) { @@ -295,69 +294,93 @@ export const useLoketStore = defineStore('loket', () => { } const rawData = await response.json(); - const clinics = rawData.data || []; + const loketsRaw = rawData.data || []; - // AGGREGATE: Build Loket Map from Clinics - const loketMap = new Map(); - - 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 - }); - } - }); - } + // MAPPING: Convert API Structure to Store Format + const mappedLokets = loketsRaw.map(l => { + const id = parseInt(l.idloket); + + // Map pelayanan (clinic codes) dan detail spesialis + const spesialisDetail = (l.spesialis || []).map(s => { + // Coba cari kode klinik dari clinicStore untuk konsistensi + const clinic = clinicStore.clinics.find(c => + String(c.id) === String(s.idklinik) || + c.name === s.namaklinik + ); + + return { + idklinik: s.idklinik, + namaklinik: s.namaklinik, + code: clinic ? clinic.kode : (s.kode || s.idklinik) + }; + }); + + // Extract unique codes for 'pelayanan' field + const pelayananCodes = [...new Set(spesialisDetail.map(s => s.code))]; + + // Map payment types + const pembayaranLabel = (l.pembayaran || []) + .map(p => p.pembayaran) + .filter(Boolean) + .join(', ') || 'JKN'; + + return { + id: id, + namaLoket: l.namaloket, + kodeLoket: l.kodeloket, + kuota: parseInt(l.kuotaloket) || 100, + pelayanan: pelayananCodes, + _spesialisDetail: spesialisDetail, + pembayaran: pembayaranLabel, + source: 'api', + loketAktif: l.loketaktif ?? true, + jenisloket: l.jenisloket, + tipeloket: l.tipeloket + }; }); - const lokets = Array.from(loketMap.values()); - - // 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; - }); - - // Set No sequential - lokets.forEach((loket, idx) => { + // Sort by id for stability and set sequential No + mappedLokets.sort((a, b) => a.id - b.id); + mappedLokets.forEach((loket, idx) => { loket.no = idx + 1; }); - apiLoketData.value = lokets; + console.log(`✅ [loketStore] Successfully mapped ${mappedLokets.length} lokets from API`); + + apiLoketData.value = mappedLokets; lastSyncTimestamp.value = new Date().toISOString(); return { success: true, - message: `${lokets.length} loket berhasil dipetakan dari klinik`, - data: lokets + message: `${mappedLokets.length} loket berhasil dikonfigurasi`, + data: mappedLokets }; } catch (error) { - console.error('❌ Error fetching loket config:', error); + console.error('❌ [loketStore] Error fetching loket config:', error); apiError.value = error.message; + + // FALLBACK: If API fails, populate with dummy Reguler data for Dev/Offline mode + if (apiLoketData.value.length === 0) { + console.warn('⚠️ [loketStore] Using FALLBACK data due to API failure...'); + const dummyLokets = Array.from({length: 6}, (_, i) => ({ + id: i + 1, + namaLoket: `LOKET ${i + 1} REG (Mock)`, + kodeLoket: `L${i+1}`, + kuota: 100, + pelayanan: ['UM', 'BP', 'OB', 'AN', 'IP', 'SR', 'TH', 'MT', 'KK', 'PR'], // Mock all services + _spesialisDetail: [], // Empty detail + pembayaran: 'BPJS, Umum', + source: 'api', // Mimic API + loketAktif: true, + jenisloket: 'REGULER', + tipeloket: 'REGULER', + no: i + 1 + })); + apiLoketData.value = dummyLokets; + return { success: true, message: 'Menggunakan data fallback (API Offline)', warning: true }; + } + return { success: false, message: `Gagal memuat: ${error.message}` }; } finally { isLoadingAPI.value = false; @@ -372,6 +395,7 @@ export const useLoketStore = defineStore('loket', () => { return { // State loketData, + lokets: loketData, // Alias for backward compatibility/clarity availableServices, // API State diff --git a/stores/queueStore.js b/stores/queueStore.js index 2a8575c..e9a19b5 100644 --- a/stores/queueStore.js +++ b/stores/queueStore.js @@ -198,6 +198,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-RA001 | Online - ${seedBarcode1}`, // Counter 1: Fast Track BPJS shift: "Shift 1", klinik: "KANDUNGAN", + kodeKlinik: "KD", fastTrack: "YA", // Fast Track Patient pembayaran: "BPJS", status: "waiting", @@ -218,6 +219,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA002 | Online - ${seedBarcode2}`, // Counter 2: Non-fast track UMUM shift: "Shift 1", klinik: "IPD", + kodeKlinik: "IP", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -238,6 +240,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-RA003 | Online - ${seedBarcode3}`, // Counter 3: Fast Track BPJS shift: "Shift 1", klinik: "SARAF", + kodeKlinik: "SR", fastTrack: "YA", // Fast Track Patient pembayaran: "BPJS", status: "waiting", @@ -258,6 +261,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA004 | Online - ${seedBarcode4}`, // Counter 4: Non-fast track UMUM shift: "Shift 1", klinik: "THT", + kodeKlinik: "TH", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -278,6 +282,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA005 | Online - ${seedBarcode5}`, // Counter 5: Non-fast track UMUM shift: "Shift 2", klinik: "KANDUNGAN", + kodeKlinik: "KD", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -298,6 +303,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-RA006 | Online - ${seedBarcode6}`, // Counter 6: Fast Track BPJS shift: "Shift 1", klinik: "IPD", + kodeKlinik: "IP", fastTrack: "YA", // Fast Track Patient pembayaran: "BPJS", status: "waiting", @@ -318,6 +324,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA007 | Online - ${seedBarcode7}`, // Counter 7: Non-fast track UMUM shift: "Shift 1", klinik: "SARAF", + kodeKlinik: "SR", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -338,6 +345,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA008 | Online - ${seedBarcode8}`, // Counter 8: Non-fast track UMUM shift: "Shift 1", klinik: "THT", + kodeKlinik: "TH", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -358,6 +366,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-RA009 | Online - ${seedBarcode9}`, // Counter 9: Fast Track BPJS shift: "Shift 2", klinik: "KANDUNGAN", + kodeKlinik: "KD", fastTrack: "YA", // Fast Track Patient pembayaran: "BPJS", status: "waiting", @@ -378,6 +387,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA010 | Online - ${seedBarcode10}`, // Counter 10: Non-fast track UMUM shift: "Shift 1", klinik: "IPD", + kodeKlinik: "IP", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -398,6 +408,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `RA011 | Online - ${seedBarcode11}`, // Counter 11: Non-fast track UMUM shift: "Shift 1", klinik: "SARAF", + kodeKlinik: "SR", fastTrack: "TIDAK", pembayaran: "UMUM", status: "waiting", @@ -418,6 +429,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-RA012 | Online - ${seedBarcode12}`, // Counter 12: Fast Track BPJS shift: "Shift 2", klinik: "THT", + kodeKlinik: "TH", fastTrack: "YA", // Fast Track Patient pembayaran: "BPJS", status: "waiting", @@ -438,6 +450,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `EA013 | Online - ${seedBarcodeE1}`, // Counter 13: Non-fast track Eksekutif shift: "Shift 1", klinik: "KANDUNGAN", + kodeKlinik: "KD", fastTrack: "TIDAK", pembayaran: "Eksekutif", status: "waiting", @@ -458,6 +471,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `EA014 | Online - ${seedBarcodeE2}`, // Counter 14: Non-fast track Eksekutif shift: "Shift 1", klinik: "IPD", + kodeKlinik: "IP", fastTrack: "TIDAK", pembayaran: "Eksekutif", status: "waiting", @@ -478,6 +492,7 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: `F-EA015 | Online - ${seedBarcodeE3}`, // Counter 15: Fast Track Eksekutif shift: "Shift 2", klinik: "SARAF", + kodeKlinik: "SR", fastTrack: "YA", // Fast Track Patient pembayaran: "Eksekutif", status: "waiting", @@ -517,86 +532,94 @@ export const useQueueStore = defineStore('queue', () => { // Initialize counters from seed data to ensure numbering continues correctly // Counter SHARED untuk semua payment group dan semua jenis pasien - const initializeCountersFromSeed = () => { - if (typeof window === 'undefined') return; // Skip in SSR + // Calculate max queue number from seeds or existing patients + const syncCountersWithState = () => { + if (typeof window === 'undefined') return; - const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const today = new Date().toISOString().split('T')[0]; + const STORAGE_KEY_BARCODE = `barcode_counter_${today.replace(/-/g, '').substring(2)}`; - // Parse seed data to find max counter (SHARED untuk semua) - let maxCounter = 0; + // Determine the source of patients (prefer current state, fallback to seeds) + const sourcePatients = allPatients.value.length > 0 ? allPatients.value : seedPatients; - seedPatients.forEach(patient => { - if (!patient.noAntrian) return; - - // Extract queue number from noAntrian (format: "F-RA001 | ..." or "RA001 | ..." or "EA001 | ...") - // Remove "F-" prefix untuk mendapatkan nomor asli - const queueNumberMatch = patient.noAntrian.match(/^(?:F-)?([RE])([A-N])(\d+)/); - if (!queueNumberMatch) return; - - const loketLetter = queueNumberMatch[2]; // A-N - const number = parseInt(queueNumberMatch[3], 10); // 001-999 - - // Calculate counter from loket and number - // Loket A = 1-999, Loket B = 1000-1998, etc. - const loketIndex = loketLetter.charCodeAt(0) - 65; // A=0, B=1, etc. - const counter = (loketIndex * 999) + number; - - // Update max counter (shared untuk semua) - if (counter > maxCounter) { - maxCounter = counter; + // Find max barcode counter + let maxBarcodeCounter = 0; + sourcePatients.forEach(patient => { + const barcode = patient.barcode || ''; + if (barcode.length > 5) { + const counterStr = barcode.substring(barcode.length - 5); + const counter = parseInt(counterStr, 10); + if (!isNaN(counter) && counter > maxBarcodeCounter) { + maxBarcodeCounter = counter; + } } }); + + // Initialize barcode counter if not exists or smaller + const existingBarcode = localStorage.getItem(STORAGE_KEY_BARCODE); + if (!existingBarcode || parseInt(existingBarcode, 10) < maxBarcodeCounter) { + localStorage.setItem(STORAGE_KEY_BARCODE, maxBarcodeCounter.toString()); + } + + // Existing queue counter logic... + const counterKeyQueue = `queue_counter_loket_shared_${today}`; - // Initialize localStorage counter (SHARED untuk semua payment group) - if (maxCounter > 0) { - const counterKey = `queue_counter_loket_shared_${today}`; - const existing = localStorage.getItem(counterKey); - if (!existing || parseInt(existing, 10) < maxCounter) { - localStorage.setItem(counterKey, maxCounter.toString()); - } + // Calculate max queue number from seeds dynamically + const maxQueueCounter = sourcePatients.length > 0 + ? Math.max(...sourcePatients.map(p => p.no)) + : 0; + + const existingQueue = localStorage.getItem(counterKeyQueue); + // Always update if current max in memory is larger than stored + if (!existingQueue || parseInt(existingQueue) < maxQueueCounter) { + localStorage.setItem(counterKeyQueue, maxQueueCounter.toString()); } }; - // Initialize counters from seed data - initializeCountersFromSeed(); - - // Initialize state - will be automatically hydrated from localStorage by pinia-plugin-persistedstate - // If localStorage has data, it will override these defaults - const allPatients = ref(cloneSeed()); + // Initial state - START EMPTY to avoid clashing with hydration + const allPatients = ref([]); const quotaUsed = ref(5); - const currentProcessingPatient = ref({ - loket: null, - klinik: null, - penunjang: null, - }); + // State - currentProcessingPatient is now an object mapping 'stage-id' to patient + // For example: { 'loket-1': patient, 'loket-2': patient, 'klinik-3': patient } + const currentProcessingPatient = ref({}); // Daftar klinik untuk dropdown diambil 1 pintu dari clinicStore - const kliniks = computed(() => { - const baseList = typeof clinicStore.getClinicsForDropdown === 'function' - ? clinicStore.getClinicsForDropdown() - : []; + const kliniks = ref(clinicStore.clinics || []); - // Bentuk objek disesuaikan dengan yang dipakai di useQueue (id, name, kode) - return baseList.map((c) => ({ - id: c.id, - name: c.name, - kode: c.kode, - icon: c.icon, - available: c.available, - })); - }); + // Penunjang data - reference dari penunjangStore + const penunjangs = ref(penunjangStore.penunjangs || []); - // Penunjang data - reference dari penunjangStore (single source of truth) - // Menggunakan computed untuk reactive reference - const penunjangs = computed(() => { - // Get penunjang list from penunjangStore dan map ke format yang diharapkan - const penunjangList = penunjangStore.penunjangList || []; - return penunjangList.map(p => ({ - id: p.id, - name: p.nama || p.name, // Support both nama and name for backward compatibility - kode: p.kode - })); - }); + /** + * Ensures initial data exists. + * Only seeds if the store is empty (not hydrated from storage) + */ + const ensureInitialData = () => { + if (allPatients.value.length === 0) { + console.log('🌱 Seeding queueStore with initial data...'); + allPatients.value = cloneSeed(); + } + // ALWAYS sync counters with state (hydrated or seeded) to ensure next number is correct + syncCountersWithState(); + }; + + // CROSS-TAB SYNC: Listen for storage events to update store across tabs + if (typeof window !== 'undefined') { + window.addEventListener('storage', (event) => { + if (event.key === 'queue-store-state') { + console.log('🔄 queue-store-state changed in another tab, re-hydrating...'); + try { + const newState = JSON.parse(event.newValue); + if (newState) { + if (newState.allPatients) allPatients.value = newState.allPatients; + if (newState.quotaUsed !== undefined) quotaUsed.value = newState.quotaUsed; + if (newState.currentProcessingPatient) currentProcessingPatient.value = newState.currentProcessingPatient; + } + } catch (e) { + console.error('Error hydrating from storage event:', e); + } + } + }); + } // Computed - Filter berdasarkan process stage dan status const getPatientsByStage = (stage) => { @@ -634,10 +657,13 @@ export const useQueueStore = defineStore('queue', () => { allPatients.value = cloneSeed(); quotaUsed.value = 5; currentProcessingPatient.value = { loket: null, klinik: null, penunjang: null }; + syncCountersWithState(); // Re-initialize counters after reset }; - // Actions - const callNext = (adminType = 'loket') => { + // Action: Ambil Antrean Masuk dari Anjungan (Reservoir -> Waiting Room) + // "fungsi ini juga sesuaikan dengan idloket dan klinik id" + // "hanya memanggil tiket dari loket lain harus tiket menunggu yang sesuai id loket dan id kliniknya" + const callNext = (adminType = 'loket', specificId = null) => { const stageMap = { 'loket': 'loket', 'klinik': 'klinik', @@ -645,114 +671,142 @@ export const useQueueStore = defineStore('queue', () => { }; const targetStage = stageMap[adminType]; - // Prioritaskan pasien dengan status 'menunggu' (yang belum dipanggil) - const nextPatient = allPatients.value.find(p => - p.status === 'menunggu' && p.processStage === targetStage - ) || allPatients.value.find(p => - p.status === 'waiting' && p.processStage === targetStage - ); + const targetId = specificId; + + // Filter list by stage AND relevance to this specific loket/clinic/penunjang + const eligiblePatients = allPatients.value.filter(p => { + // 1. Stage Check + if (p.processStage !== targetStage) return false; + + // 2. Relevance Check based on adminType + if (targetId) { + if (adminType === 'loket') { + const thisLoket = loketStore.getLoketById(parseInt(targetId)); + // Enforce clinic mapping (pelayanan) + if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { + if (!thisLoket.pelayanan.includes(p.kodeKlinik)) return false; + } + // Enforce assignment if present + if (p.loketId && String(p.loketId) !== String(targetId)) return false; + } + else if (adminType === 'klinik') { + // Clinic ID match + if (String(p.kodeKlinik) !== String(targetId) && String(p.klinik) !== String(targetId)) return false; + } + else if (adminType === 'penunjang') { + // Penunjang match + if (String(p.klinik) !== String(targetId) && String(p.kodeKlinik) !== String(targetId)) return false; + } + } + + return true; + }); + + // PRIORITAS: Hanya ambil pasien dengan status 'menunggu' (Antrean Baru dari Anjungan) + // "bukan untuk memanggil pasien tapi tiket baru dari anjungan yang statusnya menunggu" + const nextPatient = eligiblePatients.find(p => p.status === 'menunggu'); if (!nextPatient) { - return { success: false, message: "Tidak ada pasien selanjutnya" }; + return { success: false, message: `Tidak ada antrean baru yang sesuai untuk ${adminType} ${targetId || ''}` }; } - // Hitung kuota yang tersedia - const menungguCount = allPatients.value.filter(p => - p.status === 'menunggu' && p.processStage === targetStage - ).length; + // Hitung kuota yang tersedia (khusus loket ini) const diLoketCount = allPatients.value.filter(p => - p.status === 'di-loket' && p.processStage === targetStage + p.status === 'di-loket' && + p.processStage === targetStage && + (targetId && adminType === 'loket' ? String(p.loketId) === String(targetId) : true) ).length; const availableQuota = 150 - diLoketCount; if (availableQuota <= 0) { return { success: false, message: "Kuota sudah penuh" }; } - - if (menungguCount === 0) { - return { success: false, message: "Tidak ada pasien yang menunggu untuk dipanggil" }; - } - // Langsung update status menjadi 'waiting' (sudah dipanggil, bisa check-in) + // Update status menjadi 'waiting' (Masuk ke antrean aktif) const callTimestamp = new Date().toISOString(); const index = allPatients.value.findIndex(p => p.no === nextPatient.no); if (index !== -1) { allPatients.value[index] = { ...allPatients.value[index], - status: "waiting", // Status "waiting" = sudah dipanggil, bisa check-in - lastCalledAt: callTimestamp // Track waktu panggilan untuk multiple calls + status: "waiting", + // Assign to this specific loket if it was unassigned + loketId: (adminType === 'loket' && targetId) ? targetId : allPatients.value[index].loketId, + lastCalledAt: callTimestamp }; } return { success: true, - message: `Memanggil pasien ${nextPatient.noAntrian.split(" |")[0]}`, + message: `Berhasil mengambil antrean ${nextPatient.noAntrian.split(" |")[0]} ke daftar tunggu`, }; }; - const callMultiplePatients = (count, adminType = 'loket') => { - const stageMap = { - 'loket': 'loket', - 'klinik': 'klinik', - 'penunjang': 'penunjang' - }; - + const callMultiplePatients = (count, adminType = 'loket', specificId = null) => { + const stageMap = { 'loket': 'loket', 'klinik': 'klinik', 'penunjang': 'penunjang' }; const targetStage = stageMap[adminType]; - // Prioritaskan pasien dengan status 'menunggu' (yang belum dipanggil) - const menungguList = allPatients.value.filter(p => - p.status === 'menunggu' && p.processStage === targetStage - ); - const waitingList = allPatients.value.filter(p => - p.status === 'waiting' && p.processStage === targetStage - ); - - // Gabungkan: menunggu dulu, baru waiting - const combinedList = [...menungguList, ...waitingList]; + const targetId = specificId; - if (combinedList.length === 0) { - return { success: false, message: "Tidak ada pasien yang menunggu" }; + const eligiblePatients = allPatients.value.filter(p => { + if (p.processStage !== targetStage) return false; + if (targetId) { + if (adminType === 'loket') { + const thisLoket = loketStore.getLoketById(parseInt(targetId)); + if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { + if (!thisLoket.pelayanan.includes(p.kodeKlinik)) return false; + } + if (p.loketId && String(p.loketId) !== String(targetId)) return false; + } else if (adminType === 'klinik' || adminType === 'penunjang') { + if (String(p.kodeKlinik) !== String(targetId) && String(p.klinik) !== String(targetId)) return false; + } + } + return true; + }); + + // Hanya ambil pasien status 'menunggu' + const menungguList = eligiblePatients.filter(p => p.status === 'menunggu'); + + if (menungguList.length === 0) { + return { success: false, message: `Tidak ada antrean baru yang sesuai untuk ${adminType} ${targetId || ''}` }; } // Hitung kuota yang tersedia const diLoketCount = allPatients.value.filter(p => - p.status === 'di-loket' && p.processStage === targetStage + p.status === 'di-loket' && + p.processStage === targetStage && + (targetId && adminType === 'loket' ? String(p.loketId) === String(targetId) : true) ).length; const availableQuota = 150 - diLoketCount; - // Kuota yang bisa dipanggil = min(count yang diminta, jumlah pasien menunggu, kuota tersedia) const maxCallable = Math.min(count, menungguList.length, availableQuota); if (maxCallable <= 0) { - if (menungguList.length === 0) { - return { success: false, message: "Tidak ada pasien yang menunggu untuk dipanggil" }; - } - if (availableQuota <= 0) { - return { success: false, message: "Kuota sudah penuh. Tidak bisa memanggil pasien lagi" }; - } + if (availableQuota <= 0) return { success: false, message: "Kuota sudah penuh" }; + return { success: false, message: "Tidak ada antrean yang bisa diambil" }; } - const patientsToCall = combinedList.slice(0, maxCallable); - - // Langsung update status menjadi 'waiting' + const patientsToCall = menungguList.slice(0, maxCallable); const callTimestamp = new Date().toISOString(); + patientsToCall.forEach((patient) => { const index = allPatients.value.findIndex(p => p.no === patient.no); if (index !== -1) { - allPatients.value[index] = { - ...allPatients.value[index], + allPatients.value[index] = { + ...allPatients.value[index], status: "waiting", - lastCalledAt: callTimestamp // Track waktu panggilan untuk multiple calls + loketId: (adminType === 'loket' && targetId) ? targetId : allPatients.value[index].loketId, + lastCalledAt: callTimestamp }; } }); return { success: true, - message: `Memanggil ${patientsToCall.length} pasien`, + message: `Berhasil mengambil ${patientsToCall.length} antrean ke daftar tunggu`, }; }; - const processPatient = (patient, action, adminType = 'loket') => { + const processPatient = (patient, action, adminType = 'loket', specificId = null) => { + const storageKey = specificId ? `${adminType}-${specificId}` : adminType; const patientCode = patient.noAntrian.split(" |")[0]; let message = ""; @@ -793,8 +847,8 @@ export const useQueueStore = defineStore('queue', () => { } // Clear current processing di admin yang melakukan check-in - if (currentProcessingPatient.value[adminType]?.no === patient.no) { - currentProcessingPatient.value[adminType] = null; + if (currentProcessingPatient.value[storageKey]?.no === patient.no) { + currentProcessingPatient.value[storageKey] = null; } break; @@ -804,8 +858,8 @@ export const useQueueStore = defineStore('queue', () => { status: "terlambat", calledByAdmin: false // Reset flag }; - if (currentProcessingPatient.value[adminType]?.no === patient.no) { - currentProcessingPatient.value[adminType] = null; + if (currentProcessingPatient.value[storageKey]?.no === patient.no) { + currentProcessingPatient.value[storageKey] = null; } message = `Pasien ${patientCode} ditandai terlambat`; break; @@ -816,8 +870,8 @@ export const useQueueStore = defineStore('queue', () => { status: "pending", calledByAdmin: false // Reset flag }; - if (currentProcessingPatient.value[adminType]?.no === patient.no) { - currentProcessingPatient.value[adminType] = null; + if (currentProcessingPatient.value[storageKey]?.no === patient.no) { + currentProcessingPatient.value[storageKey] = null; } message = `Pasien ${patientCode} di-pending`; break; @@ -862,8 +916,8 @@ export const useQueueStore = defineStore('queue', () => { }; allPatients.value[patientIndex] = updatedPatient; - // Set currentProcessingPatient dengan data terbaru - currentProcessingPatient.value[adminType] = updatedPatient; + // Set currentProcessingPatient with isolated key + currentProcessingPatient.value[storageKey] = updatedPatient; } else { // Untuk adminType selain loket, update status jika perlu if (shouldUpdateStatus) { @@ -872,9 +926,9 @@ export const useQueueStore = defineStore('queue', () => { status: "di-loket" }; allPatients.value[patientIndex] = updatedPatient; - currentProcessingPatient.value[adminType] = updatedPatient; + currentProcessingPatient.value[storageKey] = updatedPatient; } else { - currentProcessingPatient.value[adminType] = patient; + currentProcessingPatient.value[storageKey] = patient; } } message = `Memproses pasien ${patientCode}`; @@ -885,34 +939,12 @@ export const useQueueStore = defineStore('queue', () => { return { success: true, message }; }; - const processNextQueue = (adminType = 'loket') => { - const stageMap = { - 'loket': 'loket', - 'klinik': 'klinik', - 'penunjang': 'penunjang' - }; - - const targetStage = stageMap[adminType]; - const nextPatient = allPatients.value.find(p => - p.status === 'di-loket' && p.processStage === targetStage - ); - if (!nextPatient) { - return { success: false, message: "Tidak ada pasien di loket yang dapat diproses" }; - } - // Set sebagai current processing patient - currentProcessingPatient.value[adminType] = nextPatient; - - return { - success: true, - message: `Memproses pasien ${nextPatient.noAntrian.split(" |")[0]}`, - }; - }; - - const callProcessingPatient = (adminType = 'loket') => { + const callProcessingPatient = (adminType = 'loket', specificId = null) => { // Panggil pasien yang sedang diproses untuk ditampilkan di layar anjungan - const processingPatient = currentProcessingPatient.value[adminType]; + const storageKey = specificId ? `${adminType}-${specificId}` : adminType; + const processingPatient = currentProcessingPatient.value[storageKey]; if (!processingPatient) { return { success: false, message: "Tidak ada pasien yang sedang diproses" }; @@ -928,7 +960,7 @@ export const useQueueStore = defineStore('queue', () => { }; // Update currentProcessingPatient with the new data - currentProcessingPatient.value[adminType] = allPatients.value[patientIndex]; + currentProcessingPatient.value[storageKey] = allPatients.value[patientIndex]; return { success: true, @@ -956,12 +988,31 @@ export const useQueueStore = defineStore('queue', () => { fastTrack: "TIDAK", pembayaran: patient ? patient.pembayaran : "UMUM", noRM: patient ? (patient.noRM || `RM-SCAN-${barcode.slice(-6)}`) : `RM-SCAN-${barcode.slice(-6)}`, + kodeKlinik: klinik.kode || klinik.id, // Ensure kodeKlinik is saved status: "di-loket", processStage: "klinik", createdAt: timestamp.toISOString(), referencePatient: patient ? patient.noAntrian : null, + loketId: null, // Initial check }; + // Auto-assign Loket ID based on Clinic Mapping + // "menyesuaikan loketnya berdasar loket id tergantung dari create tiket itu di klinik" + if (newPatient.kodeKlinik) { + const allLokets = loketStore.lokets || []; + const targetLoket = allLokets.find(l => + l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik) + ); + + if (targetLoket) { + newPatient.loketId = targetLoket.id; + newPatient.loket = targetLoket.namaLoket; + console.log(`✅ Auto-assigned Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`); + } else { + console.log(`⚠️ No specific Loket found for Clinic ${newPatient.kodeKlinik}, defaulting to general pool.`); + } + } + allPatients.value.push(newPatient); // Increment counter setelah barcode digunakan @@ -1348,227 +1399,167 @@ export const useQueueStore = defineStore('queue', () => { p.processStage === 'klinik-ruang' && (p.status === 'waiting' || (allowMultiple && p.status === 'di-loket')) ).sort((a, b) => { - // Sort by status priority first - const statusPriority = { - 'waiting': 1, - 'di-loket': 2 - }; + // Sort by status priority first: waiting=1, di-loket=2 + const statusPriority = { 'waiting': 1, 'di-loket': 2 }; const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99); if (priorityDiff !== 0) return priorityDiff; - // Then sort by queue number (extract from noAntrian) - const numA = parseInt(a.noAntrian.match(/\d+/)?.[0] || '999'); - const numB = parseInt(b.noAntrian.match(/\d+/)?.[0] || '999'); + const numA = parseInt(a.noAntrian?.match(/\d+/)?.[0] || '999'); + const numB = parseInt(b.noAntrian?.match(/\d+/)?.[0] || '999'); return numA - numB; }); if (patients.length === 0) { - return { success: false, message: `Tidak ada antrian ${tipeLayanan} yang menunggu di ruang ini` }; + return { success: false, message: `Tidak ada antrian ${tipeLayanan} yang menunggu` }; } const nextPatient = patients[0]; - const patientIndex = allPatients.value.findIndex(p => p.no === nextPatient.no); - - if (patientIndex !== -1) { - // If allowMultiple, we can call even if already di-loket (just update timestamp) - // Otherwise, only update if status is waiting - if (allowMultiple || nextPatient.status === 'waiting') { - allPatients.value[patientIndex] = { - ...allPatients.value[patientIndex], - status: "di-loket", - lastCalledAt: new Date().toISOString() // Track last call time - }; - } + const index = allPatients.value.findIndex(p => p.no === nextPatient.no); + if (index !== -1) { + allPatients.value[index] = { + ...allPatients.value[index], + status: "di-loket", + lastCalledAt: new Date().toISOString() + }; } - - return { - success: true, - message: `Memanggil pasien ${nextPatient.noAntrian.split(" |")[0]} untuk ${tipeLayanan}`, - patient: allPatients.value[patientIndex], + return { + success: true, + message: `Memanggil ${nextPatient.noAntrian.split(" |")[0]}`, + patient: allPatients.value[index] }; }; // Process patient in klinik ruang (set as current processing) const processPatientKlinikRuang = (patient, action, kodeKlinik, nomorRuang) => { const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); - - if (patientIndex === -1) { - return { success: false, message: "Pasien tidak ditemukan" }; - } + if (patientIndex === -1) return { success: false, message: "Pasien tidak ditemukan" }; - const patientCode = patient.noAntrian.split(" |")[0]; - const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`; + const specificId = `${kodeKlinik}-${nomorRuang}`; + const storageKey = `klinik-ruang-${specificId}`; + const pCode = patient.noAntrian.split(" |")[0]; let message = ""; switch (action) { case "proses": - // Set as current processing for this room (1 pasien, tidak dipisah per tipe layanan) - currentProcessingPatient.value = { - ...currentProcessingPatient.value, - [key]: allPatients.value[patientIndex] - }; - message = `Memproses pasien ${patientCode}`; + currentProcessingPatient.value[storageKey] = allPatients.value[patientIndex]; + message = `Memproses ${pCode}`; break; - case "selesai": - allPatients.value[patientIndex] = { - ...allPatients.value[patientIndex], - status: "processed" - }; - // Clear current processing - currentProcessingPatient.value[key] = null; - message = `Pasien ${patientCode} selesai diproses`; + allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "processed" }; + currentProcessingPatient.value[storageKey] = null; + message = `Pasien ${pCode} selesai diproses`; break; - case "terlambat": - allPatients.value[patientIndex] = { - ...allPatients.value[patientIndex], - status: "terlambat" - }; - currentProcessingPatient.value[key] = null; - message = `Pasien ${patientCode} ditandai terlambat`; + allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "terlambat" }; + currentProcessingPatient.value[storageKey] = null; + message = `Pasien ${pCode} ditandai terlambat`; break; - case "pending": - allPatients.value[patientIndex] = { - ...allPatients.value[patientIndex], - status: "pending" - }; - currentProcessingPatient.value[key] = null; - message = `Pasien ${patientCode} di-pending`; + allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "pending" }; + currentProcessingPatient.value[storageKey] = null; + message = `Pasien ${pCode} di-pending`; break; } - return { success: true, message }; }; - const changeKlinik = (patient, newKlinik, adminType = 'loket') => { - const patientIndex = allPatients.value.findIndex((p) => p.no === patient.no); - - if (patientIndex !== -1) { - // Update dengan cara yang Vue reactive - allPatients.value[patientIndex] = { - ...allPatients.value[patientIndex], - klinik: newKlinik.name - }; - - // Update current processing if it's the same patient - if (currentProcessingPatient.value[adminType]?.no === patient.no) { - currentProcessingPatient.value[adminType] = { - ...currentProcessingPatient.value[adminType], - klinik: newKlinik.name - }; - } + const changeKlinik = (patient, newKlinik, adminType = 'loket', specificId = null) => { + const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); + if (patientIndex === -1) return { success: false, message: "Pasien tidak ditemukan" }; - return { - success: true, - message: `Klinik berhasil diubah ke ${newKlinik.name}`, + allPatients.value[patientIndex] = { + ...allPatients.value[patientIndex], + klinik: newKlinik.name, + kodeKlinik: newKlinik.kode + }; + + const key = specificId ? `${adminType}-${specificId}` : adminType; + if (currentProcessingPatient.value[key]?.no === patient.no) { + currentProcessingPatient.value[key] = { + ...currentProcessingPatient.value[key], + klinik: newKlinik.name, + kodeKlinik: newKlinik.kode }; } - - return { success: false, message: "Pasien tidak ditemukan" }; + return { success: true, message: `Klinik berhasil diubah ke ${newKlinik.name}` }; }; - const getCurrentProcessing = (adminType) => { - return computed(() => currentProcessingPatient.value[adminType]); + const getCurrentProcessing = (adminType, id = null) => { + const key = id ? `${adminType}-${id}` : adminType; + return computed(() => currentProcessingPatient.value[key] || null); }; - const setCurrentProcessing = (patient, adminType) => { - currentProcessingPatient.value[adminType] = patient; + const setCurrentProcessing = (patient, adminType, id = null) => { + const key = id ? `${adminType}-${id}` : adminType; + currentProcessingPatient.value[key] = patient; }; - // Helper function untuk generate nomor antrean baru - // Format: R/E + Loket (A-N) + 3 digit - // Format: R/E (jenis pelayanan Reguler/Eksekutif) + A-N (nomor loket) + 3 digit angka - // Contoh: RA001, EA001, RB001, RB002 - // - R = Reguler (untuk pasien Reguler/BPJS) - // - E = Eksekutif (untuk pasien Eksekutif/Grand Pavilion) - // IMPORTANT: Counter SHARED untuk semua payment group (JKN/UMUM) dan semua jenis (fast track/non-fast track) - // Prefix "F-" hanya untuk menandai status fast track, bukan untuk memisahkan counter - // Setiap loket menampung maksimal 999 nomor (001-999) const generateQueueNumber = (clinic, paymentType, isEksekutif = false) => { - // Tentukan prefix jenis pelayanan: R untuk Reguler, E untuk Eksekutif (Grand Pavilion) const serviceType = isEksekutif ? 'E' : 'R'; - - // Counter SHARED untuk semua payment group dan semua jenis pasien - // Tidak ada pemisahan counter berdasarkan payment group atau fast track - const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const today = new Date().toISOString().split('T')[0]; + // FIXED: Use the same key as initializeCountersFromSeed const counterKey = `queue_counter_loket_shared_${today}`; - // Get current counter dari localStorage let counter = 0; if (typeof window !== 'undefined') { const stored = localStorage.getItem(counterKey); counter = stored ? parseInt(stored, 10) : 0; } - // Increment counter counter = counter + 1; + const loketIndex = Math.floor((counter - 1) / 999); - // Tentukan loket berdasarkan counter - // Loket A untuk 1-999, B untuk 1000-1998, C untuk 2000-2997, dst sampai N (14 loket) - // Setiap loket menampung maksimal 999 nomor (001-999) - // Loket index: 0=A, 1=B, 2=C, ..., 13=N - const loketIndex = Math.floor((counter - 1) / 999); // 0 untuk A, 1 untuk B, dst - - // Jika loket melebihi N (14 loket, index 13), wrap kembali ke A - // Maksimal 14 loket (A-N), jadi maksimal counter per hari = 14 * 999 = 13986 - // Jika melebihi, reset counter ke 1 if (loketIndex > 13) { counter = 1; - if (typeof window !== 'undefined') { - localStorage.setItem(counterKey, '1'); - } - // Format: R/E + Loket A + 001 + if (typeof window !== 'undefined') localStorage.setItem(counterKey, '1'); return `${serviceType}A001`; } - // Loket letter (A-N) - const loketLetter = String.fromCharCode(65 + loketIndex); // 65 = 'A', 66 = 'B', dst - - // Nomor dalam loket (1-999, di-display sebagai 001-999) - // counter 1-999 → loket A, nomor 001-999 - // counter 1000-1998 → loket B, nomor 001-999 + const loketLetter = String.fromCharCode(65 + loketIndex); const numberInLoket = ((counter - 1) % 999) + 1; const numberPart = String(numberInLoket).padStart(3, '0'); - // Save counter back to localStorage - if (typeof window !== 'undefined') { - localStorage.setItem(counterKey, counter.toString()); - } + if (typeof window !== 'undefined') localStorage.setItem(counterKey, counter.toString()); - // Format: R/E + Loket + 3 digit - // Contoh: RA001, RA002, ..., RA999, RB001, RB002, ... return `${serviceType}${loketLetter}${numberPart}`; }; // Register patient from Anjungan (onsite registration) - const registerPatientFromAnjungan = (clinic, paymentType, visitType = 'SEKARANG', visitDate = null, shift = 'Shift 1', namaDokter = null, isFastTrack = false, fastTrackData = null) => { + const registerPatientFromAnjungan = (clinic, paymentType, visitType = 'SEKARANG', visitDate = null, shift = 'Shift 1', namaDokter = null, isFastTrack = false, fastTrackData = null, loketId = null, loket = null) => { + // 1. Validasi keberadaan (prevent duplicates) + // Gunakan date today untuk check-in sync + const timestamp = new Date(); + + // Generate barcode FIRST to check for existence + const barcode = generateBarcode([], allPatients); + + // Check EXACT barcode match prevent duplicate submission + const duplicate = allPatients.value.find(p => p.barcode === barcode); + if (duplicate) { + return { + success: false, + message: "Pasien dengan barcode ini sudah terdaftar untuk hari ini.", + patient: duplicate + }; + } + const newNo = allPatients.value.length > 0 ? Math.max(...allPatients.value.map(p => p.no)) + 1 : 1; - const timestamp = new Date(); const visitDateTime = visitDate ? new Date(visitDate) : timestamp; const jamPanggil = visitDate ? `${String(visitDateTime.getHours()).padStart(2, "0")}:${String(visitDateTime.getMinutes()).padStart(2, "0")}` : `${String(timestamp.getHours()).padStart(2, "0")}:${String(timestamp.getMinutes()).padStart(2, "0")}`; - // Generate barcode dengan format: YYMMDD + 5 digit sequential - const barcode = generateBarcode([], allPatients); - // Tentukan apakah pasien Eksekutif/Grand Pavilion (jika ada namaDokter, berarti Eksekutif) const isEksekutif = namaDokter !== null && namaDokter !== undefined && namaDokter !== ''; // Generate nomor antrean dengan format baru: R/E + Loket (A-N) + 3 digit - // Format: RA001 (Reguler), EA001 (Eksekutif/Grand Pavilion), RB001, RB002, dll - // Untuk Fast Track, tambahkan prefix "F-" di depan: F-RA001, F-EA001, dll const queueNumber = generateQueueNumber(clinic, paymentType, isEksekutif); const finalQueueNumber = isFastTrack ? `F-${queueNumber}` : queueNumber; const noAntrian = `${finalQueueNumber} | Onsite - ${barcode}`; - // Status awal untuk pasien dari anjungan adalah "menunggu" (belum dipanggil) - // Hanya setelah dipanggil oleh admin loket, status berubah menjadi "waiting" (bisa check-in) const status = 'menunggu'; const newPatient = { @@ -1578,32 +1569,45 @@ export const useQueueStore = defineStore('queue', () => { noAntrian: noAntrian, shift: shift, klinik: clinic.name || clinic, + kodeKlinik: clinic.kode || null, // Essential for filtering fastTrack: isFastTrack ? "YA" : "TIDAK", pembayaran: paymentType, - noRM: `RM-${barcode.slice(-6)}`, // Generate No. RM from barcode suffix + noRM: `RM-${barcode.slice(-6)}`, status: status, processStage: "loket", createdAt: timestamp.toISOString(), registrationType: 'onsite', visitType: visitType, visitDate: visitDate || timestamp.toISOString().substring(0, 10), - namaDokter: namaDokter || null, // Nama dokter hanya untuk pasien Eksekutif - calledByAdmin: false, // Flag untuk tracking apakah sudah dipanggil oleh admin - // Fast Track data + namaDokter: namaDokter || null, + loketId: loketId, // Optional loketId + loket: loket, // Optional loket name + calledByAdmin: false, penanggungJawab: (isFastTrack && fastTrackData) ? fastTrackData.penanggungJawab : null, alasanFastTrack: (isFastTrack && fastTrackData) ? fastTrackData.alasanFastTrack : null, }; + // Auto-assign Loket ID if not provided, based on Clinic Mapping + // fulfill requirement: "adjust based on loket id depending on creation" + if (!newPatient.loketId && newPatient.kodeKlinik) { + const allLokets = loketStore.lokets || []; + const targetLoket = allLokets.find(l => + l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newPatient.kodeKlinik) + ); + + if (targetLoket) { + newPatient.loketId = targetLoket.id; + if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket; + console.log(`✅ Auto-assigned Onsite Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`); + } + } + allPatients.value.push(newPatient); - - // IMPORTANT: Increment counter SETELAH barcode benar-benar digunakan untuk membuat pasien - // Ini mencegah counter naik meskipun barcode tidak digunakan - // registerPatientFromAnjungan selalu membuat pasien baru, jadi selalu increment counter incrementBarcodeCounter(); return { success: true, - message: `Pendaftaran ${clinic.name || clinic} untuk kunjungan ${visitType === 'SEKARANG' ? 'HARI INI' : visitDate} dengan pembayaran ${paymentType} berhasil diproses.`, + message: `Pendaftaran ${clinic.name || clinic} berhasil diproses.`, patient: newPatient, }; }; @@ -1700,12 +1704,80 @@ export const useQueueStore = defineStore('queue', () => { }; }; - // State persistence is handled automatically by pinia-plugin-persistedstate - // No need for manual watch or save logic + // FIX: Explicit implementation of processNextQueue to prevent cross-loket leakage + const processNextQueueCorrected = (adminType = 'loket', specificId = null) => { + // 1. Determine target stage and key + const stageMap = { 'loket': 'loket', 'klinik': 'klinik', 'penunjang': 'penunjang' }; + const targetStage = stageMap[adminType]; + const key = specificId ? `${adminType}-${specificId}` : adminType; + + console.log(`🚀 [processNextQueue] Processing for ${key} (Stage: ${targetStage})`); + + // 2. Find next patient (Status: 'menunggu' -> 'waiting') + // Prioritaskan yang assigned ke loket ini (loketId) jika ada match + let nextPatient = null; + + if (adminType === 'loket' && specificId) { + // Cari yang spesifik untuk loket ini dulu + nextPatient = allPatients.value.find(p => + p.status === 'menunggu' && + p.processStage === targetStage && + String(p.loketId) === String(specificId) + ); + } + + // Jika tidak ada yang spesifik, cari yang umum (menunggu & loketId null/match) + if (!nextPatient) { + nextPatient = allPatients.value.find(p => + p.status === 'menunggu' && + p.processStage === targetStage && + (adminType !== 'loket' || !p.loketId || String(p.loketId) === String(specificId)) + ); + } + + // Fallback: Check 'waiting' status if needed (though usually 'menunggu' is for calling) + if (!nextPatient) { + nextPatient = allPatients.value.find(p => + p.status === 'waiting' && + p.processStage === targetStage && + (adminType !== 'loket' || String(p.loketId) === String(specificId)) + ); + } + + if (!nextPatient) { + return { success: false, message: "Tidak ada antrean yang menunggu untuk diproses." }; + } + + // 3. Update Patient Status (di-loket) + const index = allPatients.value.findIndex(p => p.no === nextPatient.no); + if (index !== -1) { + const updatedPatient = { + ...allPatients.value[index], + status: 'di-loket', + loketId: specificId || allPatients.value[index].loketId, // Ensure loketId is set + lastCalledAt: new Date().toISOString() + }; + + allPatients.value[index] = updatedPatient; + + // 4. Set Current Processing ISOLATED by key + currentProcessingPatient.value[key] = updatedPatient; + + return { + success: true, + message: `Memproses ${nextPatient.noAntrian.split(" |")[0]}`, + patient: updatedPatient + }; + } + + return { success: false, message: "Gagal memproses antrean." }; + }; return { // State allPatients, + resetPatients, + ensureInitialData, quotaUsed, currentProcessingPatient, kliniks, @@ -1729,7 +1801,7 @@ export const useQueueStore = defineStore('queue', () => { changeKlinik, pindahKlinikRuang, konsultasiKlinikRuang, - processNextQueue, + processNextQueue: processNextQueueCorrected, getPatientsByStage, getTotalPasienByStage, getCurrentProcessing, @@ -1739,6 +1811,7 @@ export const useQueueStore = defineStore('queue', () => { generateQueueNumber, generateBarcode, incrementBarcodeCounter, + syncCountersWithState, }; }, { persist: {