// stores/queueStore.js import { defineStore } from 'pinia'; import { ref, computed, watch } from 'vue'; import { useClinicStore } from './clinicStore'; import { usePenunjangStore } from './penunjangStore'; import { useLoketStore } from './loketStore'; import { useWebSocket } from '@/composables/useWebSocket'; export const useQueueStore = defineStore('queue', () => { const clinicStore = useClinicStore(); const penunjangStore = usePenunjangStore(); const loketStore = useLoketStore(); // ============================================ // API INTEGRATION FOR LOKET PATIENTS // ============================================ // State untuk API patient data per loket const allPatients = ref([]); const apiPatientsPerLoket = ref({}); const isLoadingPatients = ref(false); const apiPatientsError = ref(null); const quotaUsed = ref(5); const currentProcessingPatient = ref({}); const lastUpdated = ref(Date.now()); const lastFetchTime = ref({}); const lastGlobalFetchTime = ref(0); // Cooldown for bulk refreshes // Scoped Refresh Logic: track which lokets are currently being viewed const activeLoketInterest = ref({}); // { [loketId]: count } const activeClinicInterest = ref({}); // { [kodeKlinik]: count } const globalInterestCount = ref(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien) const registerInterest = (loketId) => { if (!loketId) return; const id = String(loketId); activeLoketInterest.value[id] = (activeLoketInterest.value[id] || 0) + 1; console.log(`๐Ÿ”Œ [queueStore] Registered interest in Loket ${id}. Active:`, activeLoketInterest.value); }; const unregisterInterest = (loketId) => { if (!loketId) return; const id = String(loketId); if (activeLoketInterest.value[id]) { activeLoketInterest.value[id] = Math.max(0, activeLoketInterest.value[id] - 1); if (activeLoketInterest.value[id] === 0) { delete activeLoketInterest.value[id]; } } console.log(`๐Ÿ”Œ [queueStore] Unregistered interest in Loket ${id}. Active lokets:`, activeLoketInterest.value); }; const registerClinicInterest = (kodeKlinik) => { if (!kodeKlinik) return; const code = String(kodeKlinik); activeClinicInterest.value[code] = (activeClinicInterest.value[code] || 0) + 1; console.log(`๐Ÿ”Œ [queueStore] Registered interest in Clinic ${code}. Active clinics:`, activeClinicInterest.value); }; const unregisterClinicInterest = (kodeKlinik) => { if (!kodeKlinik) return; const code = String(kodeKlinik); if (activeClinicInterest.value[code]) { activeClinicInterest.value[code] = Math.max(0, activeClinicInterest.value[code] - 1); if (activeClinicInterest.value[code] === 0) { delete activeClinicInterest.value[code]; } } console.log(`๐Ÿ”Œ [queueStore] Unregistered interest in Clinic ${code}. Active clinics:`, activeClinicInterest.value); }; const registerGlobalInterest = () => { globalInterestCount.value++; console.log(`๐ŸŒ [queueStore] Global interest registered. Total: ${globalInterestCount.value}`); }; const unregisterGlobalInterest = () => { globalInterestCount.value = Math.max(0, globalInterestCount.value - 1); console.log(`๐ŸŒ [queueStore] Global interest unregistered. Total: ${globalInterestCount.value}`); }; const fetchPatientsForClinic = async (kodeKlinik, force = false) => { if (!kodeKlinik) return { success: false, message: 'Kode Klinik diperlukan' }; isLoadingPatients.value = true; apiPatientsError.value = null; // THROTTLE: Check if we recently fetched (within last 2 seconds), unless forced const now = Date.now(); const lastFetch = lastFetchTime.value[`clinic-${kodeKlinik}`] || 0; const timeSinceLastFetch = now - lastFetch; if (!force && timeSinceLastFetch < 2000) { console.log(`โญ๏ธ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`); isLoadingPatients.value = false; return { success: true, message: 'Using cache' }; } lastFetchTime.value[`clinic-${kodeKlinik}`] = now; try { // Find clinic ID from clinicStore const clinic = clinicStore.clinics.find(c => c.kode === kodeKlinik); if (!clinic || !clinic.id) { throw new Error(`Klinik ID tidak ditemukan untuk kode: ${kodeKlinik}`); } const url = `/visit-api/visit?klinik_id=${clinic.id}&limit=500`; console.log(`๐Ÿ”„ [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const rawResponse = await response.json(); const data = rawResponse?.data || []; const mappedClinicPatients = []; data.forEach((visit, index) => { const healthcareServices = visit.healthcare_services || []; // Filter for KLINIK healthcare_type_name with active=true const services = healthcareServices.filter(s => s.healthcare_type_name === 'KLINIK' && s.active === true ); if (services.length > 0) { services.forEach(service => { const roomId = service.fk_ms_sub_healthcare_service_id; const visitStatuses = visit.visit_statuses || []; const latestStatus = visitStatuses[visitStatuses.length - 1]; let patientStatus = 'di-loket'; // Map status from database ID if available if (latestStatus?.fk_ref_visit_status_id) { const statusId = String(latestStatus.fk_ref_visit_status_id); if (PATIENT_STATUS_MAP[statusId]) { patientStatus = PATIENT_STATUS_MAP[statusId]; } } // Fallback to description search if (patientStatus === 'di-loket' && (latestStatus?.visit_status_description || latestStatus?.desc)) { const desc = (latestStatus.visit_status_description || latestStatus.desc).toLowerCase(); if (desc.includes('pemeriksaan') || desc.includes('sedang diproses')) { patientStatus = 'pemeriksaan'; } } const p = { no: visit.id || (20000 + index), barcode: visit.visit_code || service.ticket || '', noAntrian: service.ticket || visit.visit_code || '', jamPanggil: service.check_in_datetime || visit.registration_datetime || '', klinik: service.healthcare_service_name || clinic.name, kodeKlinik: kodeKlinik, klinikId: clinic.id, healthcareServiceId: service.id, // Store the healthcare service ID ruang: service.sub_healthcare_service_name || '', nomorRuang: roomId ? String(roomId) : String(clinic.id), pembayaran: service.payment_type_name || visit.payment_type_name || '', status: patientStatus, processStage: 'klinik-ruang', createdAt: visit.registration_datetime || new Date().toISOString(), visitType: visit.visit_type_name || 'ONSITE', noRM: visit.norm || '', fastTrack: (service.ticket && String(service.ticket).startsWith('F-')) ? "YA" : "TIDAK", registrationType: 'api', visitId: visit.visit_id || visit.id, visitCode: visit.visit_code, referencePatient: (() => { // First: try to find explicit LOKET/PENDAFTARAN service by type name or code const loketService = healthcareServices.find(s => { const type = (s.healthcare_type_name || '').toUpperCase(); const code = (s.healthcare_service_code || s.healtcare_service_code || '').toUpperCase(); return type.includes('LOKET') || type.includes('PENDAFTARAN') || type.includes('REGISTRATION') || code === 'RN'; }); if (loketService?.ticket) return loketService.ticket; // Fallback: find any service that is NOT KLINIK type (likely the loket/registration ticket) const nonKlinikService = healthcareServices.find(s => { const type = (s.healthcare_type_name || '').toUpperCase(); return !type.includes('KLINIK') && s.ticket; }); return nonKlinikService?.ticket || ''; })() }; if (isTodayPatient(p)) mappedClinicPatients.push(p); }); } }); // MERGE LOGIC const newPatientMap = new Map(); mappedClinicPatients.forEach(p => { const key = p.visitId ? `vid-${p.visitId}` : (p.barcode ? `bc-${p.barcode}` : `no-${p.no}`); newPatientMap.set(key, p); }); // CRITICAL FIX: Collect currently processing patient numbers to protect them const processingPatientNos = new Set( Object.values(currentProcessingPatient.value || {}) .filter(p => p) .map(p => p.no) ); // Update existing allPatients while preserving local UI state allPatients.value = allPatients.value.map(p => { if (p.processStage !== 'klinik-ruang' || p.kodeKlinik !== kodeKlinik) return p; // CRITICAL FIX: Protect currently processing patients from being removed if (processingPatientNos.has(p.no)) { // console.log(`๐Ÿ›ก๏ธ [queueStore] Protecting currently processing patient ${p.noAntrian} from clinic refresh`); const key = p.visitId ? `vid-${p.visitId}` : (p.barcode ? `bc-${p.barcode}` : `no-${p.no}`); // Remove from newPatientMap to prevent duplicate if (newPatientMap.has(key)) { newPatientMap.delete(key); } return p; // Keep the currently processing patient as-is } const key = p.visitId ? `vid-${p.visitId}` : (p.barcode ? `bc-${p.barcode}` : `no-${p.no}`); const apiP = newPatientMap.get(key); if (apiP) { newPatientMap.delete(key); // Mark as handled // Preserve fastTrack and F- prefix if existing patient has it const isFastTrack = (p.fastTrack === "YA" || (p.noAntrian && p.noAntrian.startsWith('F-'))); let finalNoAntrian = apiP.noAntrian; if (isFastTrack && !finalNoAntrian.startsWith('F-')) { finalNoAntrian = `F-${finalNoAntrian}`; } const mergedP = { ...apiP, fastTrack: isFastTrack ? "YA" : apiP.fastTrack, noAntrian: finalNoAntrian }; // If we have local updates, preserve them const hasLocalUpdates = ( p.status !== 'di-loket' || p.calledPemeriksaanAwal || p.calledTindakan || p.lastCalledAt ); if (hasLocalUpdates) { return { ...mergedP, status: p.status, calledPemeriksaanAwal: p.calledPemeriksaanAwal, calledTindakan: p.calledTindakan, tipeLayanan: p.tipeLayanan, lastCalledAt: p.lastCalledAt, lastCalledTipeLayanan: p.lastCalledTipeLayanan }; } else { return mergedP; } } // If it was an API patient that disappeared from the list, it's either stale or from another room if (p.registrationType === 'api') return null; return p; }).filter(Boolean); // Add remaining new patients allPatients.value.push(...newPatientMap.values()); // console.log(`โœ… [queueStore] Successfully fetched ${mappedClinicPatients.length} patients for clinic ${kodeKlinik}`); return { success: true, message: `${mappedClinicPatients.length} pasien dimuat` }; } catch (error) { console.error(`โŒ [queueStore] Error fetching clinic patients (${kodeKlinik}):`, error); apiPatientsError.value = error.message; return { success: false, message: error.message }; } finally { isLoadingPatients.value = false; } }; // ============================================ // WEBSOCKET INTEGRATION (CENTRALIZED) // ============================================ // ============================================ // WEBSOCKET INTEGRATION (CENTRALIZED) // ============================================ const isWsConnected = ref(false); const wsClientId = ref(`client-${Math.random().toString(36).substring(7)}`); const lastGlobalCall = ref(null); const lastKlinikCall = ref(null); const onWsMessage = (data) => { // Robust data extraction: some relays wrap data in another 'data' property let messageData = data?.data || data; if (messageData?.data && !messageData.callKlinikEvent && !messageData.callEvent) { messageData = messageData.data; // Double wrap check } const targetLoketId = messageData?.loketId || messageData?.idloket; const targetKlinikId = messageData?.klinikId || messageData?.idklinik; console.log('๐Ÿ“จ [queueStore] Global WS Message received Type:', typeof messageData); console.log('๐Ÿ“จ [queueStore] Global WS Message content:', JSON.stringify(messageData)); // Handle Call Events (cross-device sync for "Sedang Dipanggil" popup if (messageData?.triggerRefresh) { if (messageData.klinikId) { console.log(`๐Ÿ”„ [queueStore] Received refresh trigger for clinic ${messageData.klinikId}`); // Handle current processing update if provided if (messageData.currentProcessingUpdate) { console.log(`๐ŸŽฏ [queueStore] Applying current processing update:`, messageData.currentProcessingUpdate); // Merge specifically for the keys that exist in the update Object.keys(messageData.currentProcessingUpdate).forEach(key => { currentProcessingPatient.value[key] = messageData.currentProcessingUpdate[key]; // Also patch the status in allPatients if possible const processingPatient = messageData.currentProcessingUpdate[key]; if (processingPatient && processingPatient.no) { const idx = allPatients.value.findIndex(p => p.no === processingPatient.no); if (idx !== -1) { allPatients.value[idx] = { ...allPatients.value[idx], status: 'di-loket' }; } } }); } fetchPatientsForClinic(messageData.klinikId, true); } } if (messageData?.callEvent) { console.log('๐Ÿ“ž [queueStore] Call event received:', messageData.callEvent); lastGlobalCall.value = messageData.callEvent; } // Handle Klinik Call Events (cross-device sync for AntrianKlinikRuang display) if (messageData?.callKlinikEvent) { const ev = messageData.callKlinikEvent; console.log('๐Ÿฅ [queueStore] Klinik call event received:', ev); // PERSISTENCE FIX: Save to lastKlinikCall for displays to watch lastKlinikCall.value = ev; // Find the patient in allPatients and patch directly for immediate UI update // Match by barcode (string) or target antrian number (part before |) const idx = allPatients.value.findIndex(p => p.processStage === 'klinik-ruang' && p.kodeKlinik === ev.kodeKlinik && ( (p.barcode && String(p.barcode) === String(ev.barcode)) || (p.noAntrian && p.noAntrian.split(' |')[0] === ev.noantrian) ) ); if (idx !== -1) { // Create a patched object to ensure reactivity const updatedPatient = { ...allPatients.value[idx], tipeLayanan: ev.tipeLayanan, lastCalledAt: ev.lastCalledAt || new Date().toISOString(), lastCalledTipeLayanan: ev.tipeLayanan, status: 'di-loket', calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : allPatients.value[idx].calledPemeriksaanAwal, calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : allPatients.value[idx].calledTindakan }; allPatients.value[idx] = updatedPatient; console.log(`โœ… [queueStore] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`); } else { console.warn(`โš ๏ธ [queueStore] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`); console.log('๐Ÿงช [queueStore] Available klinik-ruang patients in store:', allPatients.value .filter(p => p.processStage === 'klinik-ruang') .map(p => `[${p.kodeKlinik}] ${p.noAntrian?.split(' |')[0]} / ${p.barcode}`) ); } } // TRIGGER STRATEGIC REFRESHES let refreshedSomething = false; if (targetLoketId) { // 1. If message specifies a loket, refresh that one specifically (Force bypass throttle) console.log(`๐ŸŽฏ [queueStore] WS targeting Loket ${targetLoketId}: Refreshing (FORCED)...`); fetchPatientsForLoket(targetLoketId, true); refreshedSomething = true; } if (targetKlinikId) { // 2. If message specifies a klinik, refresh those clinics (Force bypass throttle) const interestingClinics = Object.keys(activeClinicInterest.value); if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') { const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId; console.log(`๐ŸŽฏ [queueStore] WS targeting Clinic ${targetKlinikId}: Refreshing ${clinicToFetch} (FORCED)...`); fetchPatientsForClinic(clinicToFetch, true); refreshedSomething = true; } } // 3. Global interest refresh (Force bypass throttle) if (globalInterestCount.value > 0) { console.log(`๐Ÿ“ก [queueStore] WS trigger: Global Interest active, triggering bulk refresh (FORCED)...`); fetchAllPatients(); // Note: fetchAllPatients might need force internal too, but typically calls fetchPatientsForClinic refreshedSomething = true; } // 4. Fallback: If nothing specific was refreshed but we have generic interest, refresh all active things (FORCED) if (!refreshedSomething) { const interestingLokets = Object.keys(activeLoketInterest.value); const interestingClinics = Object.keys(activeClinicInterest.value); if (interestingLokets.length > 0) { console.log(`๐ŸŒ [queueStore] WS trigger fallback: Refreshing ${interestingLokets.length} active lokets (FORCED):`, interestingLokets); interestingLokets.forEach(loketId => { fetchPatientsForLoket(loketId, true); }); refreshedSomething = true; } if (interestingClinics.length > 0) { console.log(`๐ŸŒ [queueStore] WS trigger fallback: Refreshing ${interestingClinics.length} active clinics (FORCED):`, interestingClinics); interestingClinics.forEach(kodeKlinik => { fetchPatientsForClinic(kodeKlinik, true); }); refreshedSomething = true; } } if (!refreshedSomething) { console.log(`๐Ÿ”• [queueStore] WS trigger received but no active interest matched. Skipping.`); } }; const config = useRuntimeConfig(); const wsBaseUrl = config.public?.wsBaseUrl || "ws://10.10.123.135:8084/api/v1/ws"; const { connect, disconnect, sendViaPost, isConnected } = useWebSocket({ url: wsBaseUrl, clientId: wsClientId, fallbackPostUrl: '/stats-api/ws', onOpen: () => { console.log('โœ… [queueStore] WebSocket connected'); isWsConnected.value = true; }, onClose: () => { console.log('โŒ [queueStore] WebSocket disconnected'); isWsConnected.value = false; }, onError: (err) => { console.error('โš ๏ธ [queueStore] WebSocket error:', err); isWsConnected.value = false; }, onMessage: onWsMessage }); /** * Initialize Global WebSocket */ const initWebSocket = (customClientId = null) => { if (isConnected.value && customClientId === wsClientId.value) { console.log('๐Ÿ”Œ [queueStore] WebSocket already connected with same ID.'); return; } if (customClientId) { wsClientId.value = customClientId; // Re-connect with new ID if changed disconnect(); // useWebSocket will use the new wsClientId.value if it's reactive } console.log(`๐Ÿ”Œ [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`); connect(); }; /** * Disconnect Global WebSocket */ const disconnectWebSocket = () => { disconnect(); isWsConnected.value = false; }; /** * Sync patient status to apiPatientsPerLoket for reactivity */ const syncApiPatientStatus = (patient, newStatus) => { if (!patient) return; // Allow syncing if strictly 'api' OR if we can find a matching ID in the API store // This handles cases where we have an 'onsite' patient locally that corresponds to an API patient const loketId = patient.loketId; if (loketId && apiPatientsPerLoket.value[loketId]) { // Try to find patient in the API list const index = apiPatientsPerLoket.value[loketId].findIndex(p => { if (patient.registrationType === 'api' && p.no === patient.no) return true; // Fallback matching for 'onsite' patients if (patient.idtiket && p.idtiket && String(patient.idtiket) === String(p.idtiket)) return true; if (patient.barcode && p.barcode && String(patient.barcode) === String(p.barcode)) return true; return false; }); if (index !== -1) { const updated = [...apiPatientsPerLoket.value[loketId]]; updated[index] = { ...updated[index], status: newStatus }; apiPatientsPerLoket.value[loketId] = updated; } } }; /** * Reference mapping for patient status from API idvisit * idvisit 1, 2 = menunggu (CT ANJUNGAN, RT ANJUNGAN) * idvisit 3, 4 = anjungan (PG ANJUNGAN, RT CHECK-IN) * idvisit 5, 6 = di-loket (PS CHECK-IN, TP LOKET) */ const PATIENT_STATUS_MAP = { // Stage: ANJUNGAN (1-5, 25-27) 1: 'menunggu', // CT ANJUNGAN 2: 'menunggu', // RT ANJUNGAN 3: 'anjungan', // PG ANJUNGAN 4: 'anjungan', // RT CHECK-IN 5: 'di-loket', // PS CHECK-IN 25: 'menunggu', // BT ANJUNGAN 26: 'pending', // PE ANJUNGAN 27: 'terlambat', // TE ANJUNGAN // Stage: LOKET (6-9, 28-29) 6: 'di-loket', // RT PENDAFTARAN 7: 'anjungan', // PG PENDAFTARAN 8: 'di-loket', // PO PENDAFTARAN 9: 'di-loket', // PS PENDAFTARAN 28: 'pending', // PE PENDAFTARAN 29: 'terlambat', // TR PENDAFTARAN // Stage: VERIFIKASI (10-13, 30-31) 10: 'di-loket', 11: 'anjungan', 12: 'di-loket', 13: 'di-loket', 30: 'pending', 31: 'terlambat', // Stage: PELAYANAN (14-19, 32-33) 14: 'pemeriksaan', // CT PEMERIKSAAN 15: 'pemeriksaan', // RT PEMERIKSAAN 16: 'pemeriksaan', // PG PEMERIKSAAN AWAL 17: 'pemeriksaan', // PG PEMERIKSAAN 18: 'pemeriksaan', // PO PEMERIKSAAN 19: 'pemeriksaan', // PS PEMERIKSAAN 32: 'pending', // PE PEMERIKSAAN 33: 'terlambat', // TR PEMERIKSAAN // String versions for robustness "1": 'menunggu', "2": 'menunggu', "3": 'anjungan', "4": 'anjungan', "5": 'di-loket', "6": 'di-loket', "14": 'pemeriksaan', "15": 'pemeriksaan', "28": 'pending', "29": 'terlambat' }; /** * Map status from idvisit API */ const mapStatusFromIdVisit = (idvisit) => { return PATIENT_STATUS_MAP[idvisit] || 'menunggu'; }; /** * Map status dari deskripsi API ke status internal * menunggu (id 1, 2): "Cetak Tiket Antrian" atau "Ruang Tunggu Anjungan" * anjungan (id 3, 4): "Panggilan loket ke Anjungan" atau "Tunggu Pasien Check-In" * di-loket (id 5, 6): "Pasien Sudah Check-In" atau "Tunggu Panggilan Loket" */ const mapStatusFromDeskripsi = (deskripsi) => { if (!deskripsi) return 'menunggu'; const desc = deskripsi.toString().trim().toUpperCase(); // di-loket (id 5, 6): PS CHECK-IN (Pasien Sudah Check-In), RT PENDAFTARAN (Tunggu Panggilan Loket) if (desc.includes('PASIEN SUDAH CHECK-IN') || desc.includes('TUNGGU PANGGILAN LOKET') || desc.includes('PS CHECK-IN') || desc.includes('RT PENDAFTARAN')) { return 'di-loket'; } // anjungan (id 3, 4): PG ANJUNGAN (Panggilan loket ke Anjungan), RT CHECK-IN (Tunggu Pasien Check-In) if (desc.includes('PANGGILAN LOKET KE ANJUNGAN') || desc.includes('TUNGGU PASIEN CHECK-IN') || desc.includes('PG ANJUNGAN') || desc.includes('RT CHECK-IN')) { return 'anjungan'; } // menunggu (id 1, 2): CT ANJUNGAN (Cetak Tiket Antrian), RT ANJUNGAN (Ruang Tunggu Anjungan) if (desc.includes('CETAK TIKET') || desc.includes('RUANG TUNGGU ANJUNGAN') || desc.includes('CT ANJUNGAN') || desc.includes('RT ANJUNGAN')) { return 'menunggu'; } // Default: menunggu return 'menunggu'; }; /** * Map patient data dari API format ke store format */ const mapApiPatientToStoreFormat = (apiPatient, index) => { // Use idtiket as unique patient number to avoid conflicts with seed data // Seed data uses sequential numbers 1-20, so we use idtiket + 10000 offset const uniqueNo = apiPatient.idtiket ? parseInt(apiPatient.idtiket) + 10000 : 10000 + index; // Handle nested positions to find the best status let status = 'menunggu'; let posisiStr = apiPatient.posisi || ''; let idvisit = apiPatient.idvisit; if (apiPatient.posisi && Array.isArray(apiPatient.posisi) && apiPatient.posisi.length > 0) { // Find the "best" status among all positions // Priority: di-loket > anjungan > menunggu > pending > terlambat const statusPriority = { 'di-loket': 5, 'anjungan': 4, 'menunggu': 3, 'pending': 2, 'terlambat': 1 }; let bestPriority = 0; apiPatient.posisi.forEach(pos => { const pStatus = pos.idvisit ? mapStatusFromIdVisit(pos.idvisit) : mapStatusFromDeskripsi(pos.deskripsi); const pPrio = statusPriority[pStatus] || 0; if (pPrio > bestPriority) { bestPriority = pPrio; status = pStatus; posisiStr = pos.posisi || pos.deskripsi || ''; idvisit = pos.idvisit || idvisit; } }); } else { // Standard top-level status status = apiPatient.idvisit ? mapStatusFromIdVisit(apiPatient.idvisit) : mapStatusFromDeskripsi(apiPatient.deskripsi); } const mapped = { no: uniqueNo, jamPanggil: apiPatient.waktu || '', barcode: apiPatient.barcode || '', noAntrian: `${apiPatient.ticket || ''} | ${posisiStr}`, shift: apiPatient.shift ? `Shift ${apiPatient.shift}` : '', klinik: apiPatient.klinik || '', kodeKlinik: apiPatient.klinik || '', // Will be mapped later fastTrack: (apiPatient.ticket && String(apiPatient.ticket).startsWith('F-')) ? "YA" : "TIDAK", pembayaran: apiPatient.pembayaran || '', status: status, processStage: 'loket', createdAt: apiPatient.tanggal || new Date().toISOString(), registrationType: 'api', visitType: 'Onsite', visitDate: apiPatient.tanggal ? apiPatient.tanggal.split('T')[0] : new Date().toISOString().substring(0, 10), namaDokter: null, noRM: null, penanggungJawab: null, alasanFastTrack: null, idvisit: idvisit, idtiket: apiPatient.idtiket, ticket: apiPatient.ticket, posisi: apiPatient.posisi, deskripsi: apiPatient.deskripsi, visitCode: apiPatient.visit_code || apiPatient.ticket, referencePatient: (apiPatient.healthcare_services && Array.isArray(apiPatient.healthcare_services)) ? apiPatient.healthcare_services.find(s => { const type = (s.healthcare_type_name || '').toUpperCase(); const code = (s.healthcare_service_code || s.healtcare_service_code || '').toUpperCase(); return type.includes('LOKET') || type.includes('PENDAFTARAN') || type.includes('REGISTRATION') || code === 'RN'; })?.ticket || '' : '' }; // Map klinik name to code using clinicStore const clinic = clinicStore.clinics.find(c => c.name && apiPatient.klinik && (c.name.toUpperCase().includes(apiPatient.klinik.toUpperCase()) || apiPatient.klinik.toUpperCase().includes(c.name.toUpperCase())) ); if (clinic) { mapped.kodeKlinik = clinic.kode; } else { // Fallback: Use raw clinic ID if it looks like a code (e.g. numeric ID from API) mapped.kodeKlinik = apiPatient.idklinik || apiPatient.klinik || ''; } return mapped; }; /** * Fetch patient data untuk loket tertentu dari API */ const fetchPatientsForLoket = async (loketId, force = false) => { if (!loketId) { console.error('loketId required for fetchPatientsForLoket'); return { success: false, message: 'ID Loket diperlukan' }; } isLoadingPatients.value = true; apiPatientsError.value = null; // THROTTLE: Check if we recently fetched (within last 2 seconds), unless forced const now = Date.now(); const lastFetch = lastFetchTime.value[loketId] || 0; const timeSinceLastFetch = now - lastFetch; if (!force && timeSinceLastFetch < 2000 && apiPatientsPerLoket.value[loketId]) { console.log(`โญ๏ธ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`); isLoadingPatients.value = false; return { success: true, message: 'Using cached data', data: apiPatientsPerLoket.value[loketId] }; } // Update last fetch time lastFetchTime.value[loketId] = now; // Check for daily reset before fetching checkAndResetDaily(); try { console.log(`๐Ÿ”„ [queueStore] Fetching patients for loket ${loketId}...`); const response = await fetch(`http://10.10.123.140:8089/api/v1/loket/${loketId}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const rawData = await response.json(); // Check response structure if (rawData.metadata && rawData.metadata.code !== 200) { throw new Error(rawData.message || 'API returned error status'); } const patientsRaw = rawData.data || []; // Map API data to store format const mappedPatients = patientsRaw.map((apiPatient, index) => mapApiPatientToStoreFormat(apiPatient, index) ).filter(p => isTodayPatient(p)); // Deduplicate patients by idtiket // API returns multiple entries if patient is at multiple positions const deduplicatedMap = new Map(); mappedPatients.forEach(p => { const id = p.idtiket || p.barcode || p.no; const existing = deduplicatedMap.get(id); if (!existing) { deduplicatedMap.set(id, p); } else { // Priority: di-loket > anjungan > menunggu > pending > terlambat const statusPriority = { 'di-loket': 5, 'anjungan': 4, 'menunggu': 3, 'pending': 2, 'terlambat': 1 }; const pPrio = statusPriority[p.status] || 0; const ePrio = statusPriority[existing.status] || 0; if (pPrio > ePrio) { deduplicatedMap.set(id, p); } } }); const finalPatients = Array.from(deduplicatedMap.values()); // Store in API-specific state apiPatientsPerLoket.value[loketId] = finalPatients; // IMPORTANT: Also merge into allPatients so callMultiplePatients can find them // Prepare list of new API patients with loketId assigned const patientsWithLoketId = finalPatients.map(p => ({ ...p, loketId: parseInt(loketId), registrationType: 'api' })); // ROBUST DEDUPLICATION & STATUS MERGE // 1. Identify matches between existing allPatients and new API patients const newPatientMap = new Map(); patientsWithLoketId.forEach(p => { const key = p.idtiket ? `id-${p.idtiket}` : `bc-${p.barcode}`; newPatientMap.set(key, p); }); // 2. Iterate existing patients to find matches and preserve status const preservedStatuses = new Map(); allPatients.value.forEach(p => { const key = p.idtiket ? `id-${p.idtiket}` : `bc-${p.barcode}`; if (newPatientMap.has(key)) { // If existing patient has more advanced status (anjungan/di-loket), preserve it! // This handles cases where we updated status locally ('onsite' or 'api') but API is lagging const statusPriority = { 'di-loket': 5, 'anjungan': 4, 'menunggu': 3, 'pending': 2, 'terlambat': 1 }; const existingPrio = statusPriority[p.status] || 0; const newPatient = newPatientMap.get(key); const newPrio = statusPriority[newPatient.status] || 0; if (existingPrio > newPrio) { preservedStatuses.set(key, p.status); // Also preserve lastCalledAt/calledByAdmin if available if (p.lastCalledAt) newPatient.lastCalledAt = p.lastCalledAt; if (p.calledByAdmin) newPatient.calledByAdmin = p.calledByAdmin; } } }); // 3. Update new patients with preserved status and fasttrack patientsWithLoketId.forEach(p => { const key = p.idtiket ? `id-${p.idtiket}` : `bc-${p.barcode}`; if (preservedStatuses.has(key)) { p.status = preservedStatuses.get(key); } }); // 4. Remove existing patients that collide with new ones (to be replaced) // This ensures API data ALWAYS takes precedence over local/seed data // Remove ANY patient (local, seed, or api) with matching barcode/idtiket allPatients.value = allPatients.value.filter(p => { const key = p.idtiket ? `id-${p.idtiket}` : `bc-${p.barcode}`; // CRITICAL FIX: If patient has already moved to a later stage (e.g. klinik-ruang), // do NOT remove it or overwrite it with a 'loket' stage API patient. if (p.processStage && p.processStage !== 'loket') { // Check if this advanced patient matches the incoming batch if (newPatientMap.has(key)) { // Signal to skip adding the 'loket' version from API newPatientMap.delete(key); } return true; // Keep the advanced patient } // CRITICAL FIX: Protect currently processing patients from being removed // Check if this patient is currently being processed in any admin interface const isCurrentlyProcessing = Object.values(currentProcessingPatient.value || {}).some( processingPatient => processingPatient && processingPatient.no === p.no ); if (isCurrentlyProcessing) { console.log(`๐Ÿ›ก๏ธ [queueStore] Protecting currently processing patient ${p.noAntrian} from WebSocket refresh`); // Remove from newPatientMap to prevent duplicate if (newPatientMap.has(key)) { newPatientMap.delete(key); } return true; // Keep the currently processing patient } // If it's still in loket stage but marked as processed, and it's in the API batch, // we might be experiencing API lag. Keep it as processed. if (p.status === 'processed' && newPatientMap.has(key)) { // Skip replacing it with 'menunggu'/'anjungan' status newPatientMap.delete(key); return true; } // Remove if it's a stale 'api' patient for this loket if (p.registrationType === 'api' && String(p.loketId) === String(loketId)) { // But only if it's NOT in the new batch (stale) if (!newPatientMap.has(key)) return false; } return true; }); // 5. Add merged patients (with deduplication check) // Don't add if patient already exists in allPatients (prevents duplication with LocalStorage fallback) patientsWithLoketId.forEach(newPatient => { const existingIndex = allPatients.value.findIndex(p => (p.barcode && newPatient.barcode && p.barcode === newPatient.barcode) || (p.idtiket && newPatient.idtiket && p.idtiket === newPatient.idtiket) ); if (existingIndex === -1) { // Patient doesn't exist, add it allPatients.value.push(newPatient); } else { // Patient exists - only update if new status has higher priority const existing = allPatients.value[existingIndex]; const statusPriority = { 'di-loket': 5, 'anjungan': 4, 'menunggu': 3, 'pending': 2, 'terlambat': 1 }; const existingPrio = statusPriority[existing.status] || 0; const newPrio = statusPriority[newPatient.status] || 0; // Only update if new status has higher or equal priority // This prevents API data from overwriting LocalStorage terlambat/pending status if (newPrio >= existingPrio) { console.log(`๐Ÿ”„ [queueStore] Updating patient ${newPatient.barcode} from ${existing.status} to ${newPatient.status}`); // Preserve fastTrack and F- prefix if existing patient has it const isFastTrack = (existing.fastTrack === "YA" || (existing.noAntrian && existing.noAntrian.startsWith('F-'))); let finalNoAntrian = newPatient.noAntrian; if (isFastTrack && !finalNoAntrian.startsWith('F-')) { finalNoAntrian = `F-${finalNoAntrian}`; } allPatients.value[existingIndex] = { ...newPatient, // Preserve some local state calledByAdmin: existing.calledByAdmin, lastCalledAt: existing.lastCalledAt, fastTrack: isFastTrack ? "YA" : newPatient.fastTrack, noAntrian: finalNoAntrian }; } else { console.log(`๐Ÿ›ก๏ธ [queueStore] Protecting patient ${existing.barcode} with status ${existing.status} from API overwrite (${newPatient.status})`); } } }); // 6. RESTORE terlambat/pending status from LocalStorage (hybrid fallback) // This ensures status persists even if API doesn't save it allPatients.value.forEach((patient, index) => { if (patient.barcode) { const storageKey = `patient-status-${patient.barcode}`; const savedData = localStorage.getItem(storageKey); if (savedData) { try { const { status, timestamp } = JSON.parse(savedData); // Only apply if status is terlambat or pending and not too old (24 hours) const isRecent = Date.now() - timestamp < 24 * 60 * 60 * 1000; if ((status === 'terlambat' || status === 'pending') && isRecent) { console.log(`๐Ÿ”„ [LocalStorage] Restoring ${status} status for patient ${patient.barcode}`); allPatients.value[index] = { ...patient, status: status }; } else if (!isRecent) { // Clean up old data localStorage.removeItem(storageKey); } } catch (e) { console.error('Error parsing LocalStorage data:', e); localStorage.removeItem(storageKey); } } } }); // console.log(`โœ… [queueStore] Successfully fetched ${finalPatients.length} unique patients for loket ${loketId} (Original: ${mappedPatients.length})`); // console.log(`๐Ÿ“Š [queueStore] Total patients in allPatients: ${allPatients.value.length}`); return { success: true, message: `${mappedPatients.length} pasien berhasil dimuat`, data: mappedPatients }; } catch (error) { console.error(`โŒ [queueStore] Error fetching patients for loket ${loketId}:`, error); apiPatientsError.value = error.message; // Return empty array on error apiPatientsPerLoket.value[loketId] = []; return { success: false, message: `Gagal memuat data pasien: ${error.message}`, data: [] }; } finally { isLoadingPatients.value = false; } }; /** * Global fetcher for all patients across all available lokets */ /** * Global fetcher for all patients across all available lokets * Uses staggered fetching to prevent 429 Too Many Requests errors. */ const fetchAllPatients = async (force = false) => { // 1. Cooldown Check: Prevent global refresh spam (max once every 5 seconds) const now = Date.now(); if (!force && now - lastGlobalFetchTime.value < 5000) { console.log(`โญ๏ธ [queueStore] fetchAllPatients: Global refresh on cooldown (${Math.round((now - lastGlobalFetchTime.value)/1000)}s ago)`); return; } lastGlobalFetchTime.value = now; console.log('๐Ÿ”„ [queueStore] Fetching all patients for all lokets (Staggered)...'); const allLokets = loketStore.lokets || []; if (allLokets.length === 0) { console.warn('โš ๏ธ [queueStore] No lokets available for fetchAllPatients'); return; } // 2. Staggered Fetch: Instead of Promise.all, we fetch one by one with a small delay // This spreads the load and stays under the 429 threshold for (const loket of allLokets) { const id = loket.id || loket.no; if (id) { // We use await to wait for the fetch to finish, then wait a bit more // Pass the 'force' flag down to bypass individual throttles await fetchPatientsForLoket(id, force); // Wait 150ms between requests (not too long, but enough to breathe) await new Promise(resolve => setTimeout(resolve, 150)); } } console.log(`โœ… [queueStore] Staggered bulk fetch completed. Total: ${allPatients.value.length} patients in memory.`); }; /** * Get patients for a specific loket (from API or seed data based on loket type) */ const getPatientsForLoket = (loketId) => { return computed(() => { const loket = loketStore.getLoketById(parseInt(loketId)); // If loket is EKSEKUTIF, use seed data const isEksekutif = loket?.tipeloket === 'EKSEKUTIF' || loket?.tipeLoket === 'EKSEKUTIF' || (loket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF'); if (isEksekutif) { // Return EKSEKUTIF patients from seed data return allPatients.value.filter(p => { const isPembayaranEksekutif = (p.pembayaran || '').toUpperCase().includes('EKSEKUTIF') || (p.pembayaran || '').toUpperCase().includes('VIP'); return isPembayaranEksekutif && p.processStage === 'loket'; }); } // For REGULER loket, return API data return apiPatientsPerLoket.value[loketId] || []; }); }; // Helper function untuk mendapatkan loket default (Loket A) const getDefaultLoket = () => { const allLokets = loketStore.loketData?.value || loketStore.loketData || []; const loket1 = allLokets.find(l => l.id === 1 || l.no === 1); return loket1 ? loket1.namaLoket : 'Loket A'; }; // Helper function untuk increment barcode counter setelah barcode digunakan const incrementBarcodeCounter = () => { if (typeof window === 'undefined' || typeof localStorage === 'undefined') return; const now = new Date(); const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const datePrefix = `${year}${month}${day}`; const STORAGE_KEY = `barcode_counter_${datePrefix}`; const LAST_DATE_KEY = 'barcode_last_date'; const storedCounter = localStorage.getItem(STORAGE_KEY); if (storedCounter) { const currentCounter = parseInt(storedCounter, 10) || 1; localStorage.setItem(STORAGE_KEY, String(currentCounter + 1)); localStorage.setItem(LAST_DATE_KEY, datePrefix); } }; // Helper function untuk generate barcode dengan format: YYMMDD + 5 digit sequential // Format: YY (tahun 2 digit terakhir) + MM (bulan 2 digit) + DD (tanggal 2 digit) + XXXXX (5 digit sequential) // Contoh: 26011400001, 26011400002, dst // Counter akan reset setiap ganti tanggal (mulai dari 00001 lagi) // IMPORTANT: Memastikan barcode selalu UNIQUE dan sequential per tanggal // NOTE: allPatientsRef adalah optional parameter untuk menghindari TDZ error saat seed initialization const generateBarcode = (existingBarcodes = [], allPatientsRef = null) => { const now = new Date(); const year = String(now.getFullYear()).slice(-2); // 2 digit tahun terakhir const month = String(now.getMonth() + 1).padStart(2, '0'); // 2 digit bulan const day = String(now.getDate()).padStart(2, '0'); // 2 digit tanggal const datePrefix = `${year}${month}${day}`; // YYMMDD // Check if localStorage is available (browser environment) if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { // Key untuk localStorage berdasarkan tanggal const STORAGE_KEY = `barcode_counter_${datePrefix}`; const LAST_DATE_KEY = 'barcode_last_date'; // Cek apakah tanggal sudah berubah (reset counter) const lastDate = localStorage.getItem(LAST_DATE_KEY); const currentDate = datePrefix; let counter = 1; // Default mulai dari 1 if (lastDate === currentDate) { // Tanggal sama, lanjutkan counter dari localStorage const storedCounter = localStorage.getItem(STORAGE_KEY); if (storedCounter) { counter = parseInt(storedCounter, 10) || 1; } } else { // Tanggal berbeda, reset counter ke 1 counter = 1; // Hapus counter lama untuk tanggal sebelumnya (cleanup) if (lastDate) { localStorage.removeItem(`barcode_counter_${lastDate}`); } } // Generate barcode dengan counter saat ini let barcode = `${datePrefix}${String(counter).padStart(5, '0')}`; // Cek apakah barcode sudah ada di existingBarcodes atau allPatientsRef const checkExists = (b) => { if (existingBarcodes.includes(b)) return true; // Hanya cek allPatientsRef jika diberikan (tidak null/undefined) if (allPatientsRef && allPatientsRef.value && allPatientsRef.value.some(p => p.barcode === b)) return true; return false; }; let attempts = 0; const maxAttempts = 1000; // Maksimal 1000 pasien per hari // Cek uniqueness dan increment jika duplikat while (checkExists(barcode) && attempts < maxAttempts) { counter++; barcode = `${datePrefix}${String(counter).padStart(5, '0')}`; attempts++; } // IMPORTANT: JANGAN tulis counter ke localStorage di sini! // Counter hanya di-increment dan di-save setelah barcode benar-benar digunakan // Ini mencegah counter naik meskipun barcode tidak digunakan (misalnya saat refresh) // Counter akan di-increment oleh incrementBarcodeCounter() setelah pasien dibuat // Hanya update LAST_DATE_KEY untuk tracking tanggal localStorage.setItem(LAST_DATE_KEY, currentDate); return barcode; } else { // Fallback untuk SSR atau environment tanpa localStorage // Gunakan counter berdasarkan existing barcodes atau allPatientsRef const existingCount = existingBarcodes.filter(b => b && b.startsWith(datePrefix)).length; const allCount = allPatientsRef && allPatientsRef.value ? allPatientsRef.value.filter(p => p.barcode && p.barcode.startsWith(datePrefix)).length : 0; const counter = Math.max(existingCount, allCount) + 1; const counterCode = String(counter).padStart(5, '0'); return `${datePrefix}${counterCode}`; } }; // Seed data for easy reset during dev // IMPORTANT: Setiap pasien HARUS memiliki barcode UNIK untuk menghindari konflik // Format barcode menggunakan generateBarcode() untuk konsistensi dengan format baru // Generate barcode dengan memastikan uniqueness menggunakan existingBarcodes array // NOTE: Seed data menggunakan barcode statis untuk menghindari increment counter saat refresh // Counter hanya di-increment ketika pasien baru benar-benar dibuat dari Anjungan // Gunakan barcode statis untuk seed data (tidak memanggil generateBarcode) // Format: YYMMDD + 5 digit sequential dimulai dari 00001 // Ini mencegah counter naik setiap kali halaman di-refresh const getSeedBarcode = (index) => { if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { const now = new Date(); const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const datePrefix = `${year}${month}${day}`; // Gunakan barcode statis berdasarkan index (1-16) // Format: YYMMDD + 5 digit (contoh: 26011500001, 26011500002, dst) return `${datePrefix}${String(index).padStart(5, '0')}`; } // Fallback untuk SSR const now = new Date(); const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const datePrefix = `${year}${month}${day}`; return `${datePrefix}${String(index).padStart(5, '0')}`; }; const seedBarcode1 = getSeedBarcode(1); const seedBarcode2 = getSeedBarcode(2); const seedBarcode3 = getSeedBarcode(3); const seedBarcode4 = getSeedBarcode(4); const seedBarcode5 = getSeedBarcode(5); // Barcode untuk pasien Eksekutif only (REGULER akan dari API) const seedBarcodeE1 = getSeedBarcode(1); const seedBarcodeE2 = getSeedBarcode(2); const seedBarcodeE3 = getSeedBarcode(3); const seedBarcodeE4 = getSeedBarcode(4); // IMPORTANT: Set counter ke nilai yang sesuai dengan jumlah seed data // Ini mencegah counter naik tidak terkendali saat seed data di-generate // Counter akan di-set ke jumlah seed data (16) + 1 untuk next barcode // Hanya set jika counter belum ada atau lebih kecil dari jumlah seed data if (typeof window !== 'undefined' && typeof localStorage !== 'undefined') { const now = new Date(); const year = String(now.getFullYear()).slice(-2); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const datePrefix = `${year}${month}${day}`; const STORAGE_KEY = `barcode_counter_${datePrefix}`; const LAST_DATE_KEY = 'barcode_last_date'; const storedCounter = localStorage.getItem(STORAGE_KEY); const seedDataCount = 4; // Jumlah seed data (EKSEKUTIF only) // Hanya set counter jika belum ada atau lebih kecil dari jumlah seed data // Jangan overwrite counter yang sudah lebih besar (berarti sudah ada pasien baru) if (!storedCounter || parseInt(storedCounter, 10) < seedDataCount) { localStorage.setItem(STORAGE_KEY, String(seedDataCount + 1)); localStorage.setItem(LAST_DATE_KEY, datePrefix); } } // SEED DATA: ONLY EKSEKUTIF PATIENTS // All REGULER patients will come from API const seedPatients = [ // { // no: 1, // jamPanggil: "11:20", // barcode: seedBarcodeE1, // noAntrian: `EA001 | Online - ${seedBarcodeE1}`, // shift: "Shift 1", // klinik: "KANDUNGAN", // kodeKlinik: "KD", // fastTrack: "TIDAK", // pembayaran: "Eksekutif", // status: "anjungan", // processStage: "loket", // createdAt: new Date().toISOString(), // registrationType: 'online', // visitType: 'SEKARANG', // visitDate: new Date().toISOString().substring(0, 10), // namaDokter: "Dr. Ahmad Wijaya, Sp.OG", // noRM: "RM-E001", // penanggungJawab: null, // alasanFastTrack: null, // }, // { // no: 2, // jamPanggil: "13:45", // barcode: seedBarcodeE2, // noAntrian: `EA002 | Online - ${seedBarcodeE2}`, // shift: "Shift 1", // klinik: "IPD", // kodeKlinik: "IP", // fastTrack: "TIDAK", // pembayaran: "Eksekutif", // status: "anjungan", // processStage: "loket", // createdAt: new Date().toISOString(), // registrationType: 'online', // visitType: 'SEKARANG', // visitDate: new Date().toISOString().substring(0, 10), // namaDokter: "Dr. Budi Santoso, Sp.PD", // noRM: "RM-E002", // penanggungJawab: null, // alasanFastTrack: null, // }, // { // no: 3, // jamPanggil: "15:10", // barcode: seedBarcodeE3, // noAntrian: `F-EA003 | Online - ${seedBarcodeE3}`, // shift: "Shift 2", // klinik: "SARAF", // kodeKlinik: "SR", // fastTrack: "YA", // pembayaran: "Eksekutif", // status: "anjungan", // processStage: "loket", // createdAt: new Date().toISOString(), // registrationType: 'online', // visitType: 'SEKARANG', // visitDate: new Date().toISOString().substring(0, 10), // namaDokter: "Dr. Citra Dewi, Sp.S", // noRM: "RM-E003", // penanggungJawab: "Dr. Citra Dewi", // alasanFastTrack: "Pasien Eksekutif prioritas", // }, // { // no: 4, // jamPanggil: "16:30", // barcode: seedBarcodeE4, // noAntrian: `EA004 | Online - ${seedBarcodeE4}`, // shift: "Shift 2", // klinik: "THT", // kodeKlinik: "TH", // fastTrack: "TIDAK", // pembayaran: "VIP", // status: "anjungan", // processStage: "loket", // createdAt: new Date().toISOString(), // registrationType: 'online', // visitType: 'SEKARANG', // visitDate: new Date().toISOString().substring(0, 10), // namaDokter: "Dr. Eka Putri, Sp.THT", // noRM: "RM-E004", // penanggungJawab: null, // alasanFastTrack: null, // }, ]; const cloneSeed = () => seedPatients.map(p => ({ ...p })); // Initialize counters from seed data to ensure numbering continues correctly // Counter SHARED untuk semua payment group dan semua jenis pasien // Calculate max queue number from seeds or existing patients const syncCountersWithState = () => { if (typeof window === 'undefined') return; const today = new Date().toISOString().split('T')[0]; const STORAGE_KEY_BARCODE = `barcode_counter_${today.replace(/-/g, '').substring(2)}`; // Determine the source of patients (prefer current state, fallback to seeds) const sourcePatients = allPatients.value.length > 0 ? allPatients.value : seedPatients; // 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}`; // 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()); } }; // Initial state - START EMPTY to avoid clashing with hydration // Placeholder lines for moved state references (already handled at top) // Daftar klinik untuk dropdown diambil 1 pintu dari clinicStore (Computed for stability) const kliniks = computed(() => clinicStore.clinics || []); // Penunjang data - reference dari penunjangStore (Computed for stability) const penunjangs = computed(() => penunjangStore.penunjangList || []); const RESET_HOUR = 2; // 2 AM reset threshold /** * Get logical reset threshold date object * If current time is before 2 AM, the threshold is yesterday at 2 AM. * If current time is after 2 AM, the threshold is today at 2 AM. */ const getResetThreshold = () => { const now = new Date(); const threshold = new Date(now); threshold.setHours(RESET_HOUR, 0, 0, 0); if (now.getHours() < RESET_HOUR) { threshold.setDate(threshold.getDate() - 1); } return threshold; }; /** * Check if data needs to be reset (passed 2 AM of a new day) */ const checkAndResetDaily = () => { const threshold = getResetThreshold(); const thresholdTS = threshold.getTime(); // Use localStorage to persist last reset time across sessions/tabs const lastResetStr = localStorage.getItem('queue_last_reset_time'); const lastResetTS = lastResetStr ? parseInt(lastResetStr, 10) : 0; if (lastResetTS < thresholdTS) { console.log(`๐Ÿ•’ [queueStore] Daily reset triggered. Threshold: ${threshold.toLocaleString()}`); // Clear data allPatients.value = []; apiPatientsPerLoket.value = {}; currentProcessingPatient.value = {}; // Update reset timestamp localStorage.setItem('queue_last_reset_time', thresholdTS.toString()); // Re-sync counters for the new day syncCountersWithState(); return true; } return false; }; /** * Filter strictly to only show today's patients (after 2 AM) */ const isTodayPatient = (patient) => { if (!patient) return false; // Status processing overrides filter (always show if currently processing) const isProcessing = Object.values(currentProcessingPatient.value).some(p => p && p.no === patient.no); if (isProcessing) return true; const threshold = getResetThreshold(); // Get YYYY-MM-DD for the logical "today" (based on 2 AM threshold) const year = threshold.getFullYear(); const month = String(threshold.getMonth() + 1).padStart(2, '0'); const day = String(threshold.getDate()).padStart(2, '0'); const logicalTodayStr = `${year}-${month}-${day}`; // 1. If visitDate (YYYY-MM-DD) matches logical today's date, it's a "today" patient // This correctly includes API patients with midnight timestamps (00:00:00) if (patient.visitDate) { if (patient.visitDate === logicalTodayStr) return true; if (patient.visitDate < logicalTodayStr) return false; } // 2. Fallback: check createdAt timestamp (critical for seed/manual tickets) // Tickets created before today's 2 AM are stale const createdAt = patient.createdAt ? new Date(patient.createdAt) : null; if (createdAt && createdAt < threshold) { return false; } return true; }; /** * Ensures initial data exists. * Only seeds if the store is empty (not hydrated from storage) */ const ensureInitialData = () => { // 1. Check for daily reset first checkAndResetDaily(); 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(); }; // Synchronization Guard: track if we are currently hydrating from another tab let isSyncing = false; // 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') { if (!event.newValue) return; try { const newState = JSON.parse(event.newValue); // BREAK THE LOOP: Only re-hydrate if the incoming data is strictly newer than ours // Defensive check: Handle cases where lastUpdated might be undefined or missing const incomingVersion = Number(newState?.lastUpdated || 0); const currentVersion = Number(lastUpdated.value || 0); if (incomingVersion === 0 || incomingVersion <= currentVersion) { // Already up to date, older, or corrupted data. Skip hydration. return; } // console.log(`๐Ÿ”„ Syncing state to version: ${incomingVersion}`); if (newState) { isSyncing = true; // Block local watch from updating timestamp try { if (newState.allPatients) allPatients.value = newState.allPatients; if (newState.quotaUsed !== undefined) quotaUsed.value = newState.quotaUsed; if (newState.currentProcessingPatient) currentProcessingPatient.value = newState.currentProcessingPatient; if (newState.apiPatientsPerLoket) apiPatientsPerLoket.value = newState.apiPatientsPerLoket; // Set local timestamp to match the source lastUpdated.value = incomingVersion; syncCountersWithState(); } finally { // Ensure we release the lock setTimeout(() => { isSyncing = false; }, 50); } } } catch (e) { console.error('Error hydrating from storage event:', e); isSyncing = false; } } }); } // Automatic timestamp update for local changes // This ensures lastUpdated changes whenever state changes LOCALLY // but skips updates coming from isSyncing = true watch([allPatients, quotaUsed, currentProcessingPatient, apiPatientsPerLoket], () => { if (!isSyncing) { lastUpdated.value = Date.now(); // console.log('โœจ Local state updated, version:', lastUpdated.value); } }, { deep: true }); // Computed - Filter berdasarkan process stage dan status const getPatientsByStage = (stage) => { return computed(() => { // Filter by stage AND date (today only) // optimization: filter all once, then sub-filter const patients = allPatients.value.filter(p => p && p.processStage === stage && isTodayPatient(p)); // Debug log (limited to avoid spam) if (patients.length > 0) { // console.log(`getPatientsByStage(${stage}):`, patients.length, 'patients (Today)'); } return { all: patients, anjungan: patients.filter(p => p.status === 'anjungan'), menunggu: patients.filter(p => p.status === 'menunggu'), diLoket: patients.filter(p => p.status === 'di-loket'), terlambat: patients.filter(p => p.status === 'terlambat'), pending: patients.filter(p => p.status === 'pending'), }; }); }; // Total pasien per stage - strictly today const getTotalPasienByStage = (stage) => { return computed(() => allPatients.value.filter(p => p.processStage === stage && isTodayPatient(p)).length ); }; const totalPasien = computed(() => allPatients.value.length); const resetPatients = () => { allPatients.value = cloneSeed(); quotaUsed.value = 5; currentProcessingPatient.value = { loket: null, klinik: null, penunjang: null }; syncCountersWithState(); // Re-initialize counters after reset }; /** * Helper to sort patients for calling/process selection * Priority: Fast Track ("YA") first, then by sequence number (no) */ const sortPatientsForCalling = (patients) => { if (!patients || !Array.isArray(patients)) return []; return [...patients].sort((a, b) => { const aFT = (a.fastTrack ?? "").toString().trim().toUpperCase() === "YA"; const bFT = (b.fastTrack ?? "").toString().trim().toUpperCase() === "YA"; if (aFT && !bFT) return -1; if (!aFT && bFT) return 1; const numA = parseInt(a.no) || 0; const numB = parseInt(b.no) || 0; return numA - numB; }); }; /** * Check if patient's payment type is compatible with loket's accepted payments * Maps various payment names to standardized categories * Handles complex names like "UMUM / JKMM / SPM / DLL" */ const isPaymentCompatible = (patientPayment, loketPayments) => { if (!loketPayments || loketPayments.length === 0) return true; // No restriction if (!patientPayment) return false; const normalizedPatient = String(patientPayment).toUpperCase().trim(); // Map patient payment to category const patientCategory = (normalizedPatient.includes('JKN') || normalizedPatient.includes('BPJS')) ? 'JKN' : normalizedPatient.includes('EKSEKUTIF') || normalizedPatient.includes('VIP') ? 'EKSEKUTIF' : 'UMUM'; // Default to UMUM for non-JKN, non-EKSEKUTIF // Check if loket accepts this category return loketPayments.some(lp => { const normalized = String(lp).toUpperCase().trim(); const result = (normalized === patientCategory) || (patientCategory === 'UMUM' && (normalized.includes('UMUM') || normalized.includes('MANDIRI'))) || (patientCategory === 'JKN' && (normalized.includes('JKN') || normalized.includes('BPJS'))) || (patientCategory === 'EKSEKUTIF' && (normalized.includes('EKSEKUTIF') || normalized.includes('VIP'))) || (normalized === 'BPJS' && patientCategory === 'JKN') || (normalized === 'JKN' && patientCategory === 'JKN'); console.log(` - Checking loket accepts "${lp}" (normalized: "${normalized}") vs patient category "${patientCategory}": ${result}`); return result; }); }; // 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', 'penunjang': 'penunjang' }; const targetStage = stageMap[adminType]; 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') { // Fix: Prioritaskan loketId match. Jika loketId cocok, abaikan check pelayanan (karena bisa jadi data API beda mapping) const isLoketMatch = p.loketId && String(p.loketId) === String(targetId); if (isLoketMatch) return true; const thisLoket = loketStore.getLoketById(parseInt(targetId)); // Enforce clinic mapping (pelayanan) if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { if (thisLoket.pelayanan.includes(p.kodeKlinik)) { // NEW: Check payment compatibility if (!isPaymentCompatible(p.pembayaran, thisLoket.pembayaran)) { return false; } return true; } } 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" // SEQUENTIAL: Sort by Fast Track and Queue No before picking the first matching patient const waitingPatients = eligiblePatients.filter(p => p.status === 'menunggu'); const sortedWaiting = sortPatientsForCalling(waitingPatients); const nextPatient = sortedWaiting[0]; if (!nextPatient) { return { success: false, message: `Tidak ada antrean baru yang sesuai untuk ${adminType} ${targetId || ''}` }; } // Hitung kuota yang tersedia (khusus loket ini) const currentLoket = loketStore.getLoketById(parseInt(targetId)); const targetQuota = currentLoket?.kuota || 150; const diLoketCount = allPatients.value.filter(p => p.status === 'di-loket' && p.processStage === targetStage && (targetId && adminType === 'loket' ? String(p.loketId) === String(targetId) : true) ).length; const availableQuota = targetQuota - diLoketCount; if (availableQuota <= 0) { return { success: false, message: "Kuota sudah penuh" }; } // Update status menjadi 'anjungan' (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: "anjungan", // Assign to this specific loket if it was unassigned loketId: (adminType === 'loket' && targetId) ? parseInt(targetId) : allPatients.value[index].loketId, lastCalledAt: callTimestamp }; // SYNC to apiPatientsPerLoket if it's an API patient syncApiPatientStatus(allPatients.value[index], "anjungan"); // POST to external API when patient is called try { fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ barcode: nextPatient.barcode || "", statuspasien: "3", statuspasien2: "4", idklinikstatus: "1", idklinikstatus2: "1" }) }).then(response => { if (response.ok) { console.log(`โœ… Successfully posted status update for patient ${nextPatient.barcode}`); } else { console.error(`โš ๏ธ Failed to post status update for patient ${nextPatient.barcode}:`, response.status); } }).catch(error => { console.error(`โŒ Error posting status update for patient ${nextPatient.barcode}:`, error); }); } catch (error) { console.error(`โŒ Error initiating status update for patient ${nextPatient.barcode}:`, error); } } return { success: true, message: `Berhasil mengambil antrean ${nextPatient.noAntrian.split(" |")[0]} ke daftar tunggu`, }; }; const callMultiplePatients = (count, adminType = 'loket', specificId = null) => { const stageMap = { 'loket': 'loket', 'klinik': 'klinik', 'penunjang': 'penunjang' }; const targetStage = stageMap[adminType]; const targetId = specificId; const eligiblePatients = allPatients.value.filter(p => { if (p.processStage !== targetStage) return false; if (targetId) { if (adminType === 'loket') { // Fix: Prioritaskan loketId match. Jika loketId cocok, abaikan check pelayanan (karena bisa jadi data API beda mapping) const isLoketMatch = p.loketId && String(p.loketId) === String(targetId); if (isLoketMatch) return true; const thisLoket = loketStore.getLoketById(parseInt(targetId)); if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { if (thisLoket.pelayanan.includes(p.kodeKlinik)) { // NEW: Check payment compatibility if (!isPaymentCompatible(p.pembayaran, thisLoket.pembayaran)) { return false; } return true; } } 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' // SEQUENTIAL: Sort by Fast Track and Queue No const menungguList = sortPatientsForCalling(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 currentLoket = loketStore.getLoketById(parseInt(targetId)); const targetQuota = currentLoket?.kuota || 150; const diLoketCount = allPatients.value.filter(p => p.status === 'di-loket' && p.processStage === targetStage && (targetId && adminType === 'loket' ? String(p.loketId) === String(targetId) : true) ).length; const availableQuota = targetQuota - diLoketCount; const maxCallable = Math.min(count, menungguList.length, availableQuota); if (maxCallable <= 0) { if (availableQuota <= 0) return { success: false, message: "Kuota sudah penuh" }; return { success: false, message: "Tidak ada antrean yang bisa diambil" }; } const patientsToCall = menungguList.slice(0, maxCallable); const callTimestamp = new Date().toISOString(); patientsToCall.forEach(async (patient) => { const index = allPatients.value.findIndex(p => p.no === patient.no); if (index !== -1) { const newStatus = "anjungan"; const newLoketId = (adminType === 'loket' && targetId) ? targetId : allPatients.value[index].loketId; allPatients.value[index] = { ...allPatients.value[index], status: newStatus, loketId: newLoketId, lastCalledAt: callTimestamp }; // SYNC to apiPatientsPerLoket if it's an API patient syncApiPatientStatus(allPatients.value[index], newStatus); // POST to external API when patient is called try { const response = await fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ barcode: patient.barcode || "", statuspasien: "3", statuspasien2: "4", idklinikstatus: "1", idklinikstatus2: "1" }) }); if (response.ok) { console.log(`โœ… Successfully posted status update for patient ${patient.barcode}`); } else { console.error(`โš ๏ธ Failed to post status update for patient ${patient.barcode}:`, response.status); } } catch (error) { console.error(`โŒ Error posting status update for patient ${patient.barcode}:`, error); } } }); return { success: true, message: `Berhasil mengambil ${patientsToCall.length} antrean ke daftar tunggu`, }; }; const processPatient = async (patient, action, adminType = 'loket', specificId = null) => { const storageKey = specificId ? `${adminType}-${specificId}` : adminType; const patientCode = patient.noAntrian.split(" |")[0]; let message = ""; // Find patient index for reactive update const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); if (patientIndex === -1) { return { success: false, message: "Pasien tidak ditemukan" }; } switch (action) { case "check-in": // Jika check-in di loket, pindahkan ke klinik dengan status di-loket if (adminType === 'loket') { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "di-loket", processStage: "klinik", calledByAdmin: false // Reset flag saat pindah stage }; syncApiPatientStatus(allPatients.value[patientIndex], "di-loket"); message = `Pasien ${patientCode} berhasil check in dan masuk ke Tabel Loket Klinik`; // POST to external API when patient finishes at loket try { fetch('http://10.10.123.140:8089/api/v1/tiket/selesai', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ idloket: String(patient.loketId || specificId || ""), barcode: patient.barcode || "", statuspasien: "9", idklinikstatus: "2" }) }).then(response => { if (response.ok) { console.log(`โœ… [queueStore] Successfully posted selesai status for patient ${patient.barcode}`); } else { console.error(`โš ๏ธ [queueStore] Failed to post selesai status for patient ${patient.barcode}:`, response.status); } }).catch(error => { console.error(`โŒ [queueStore] Error posting selesai status for patient ${patient.barcode}:`, error); }); } catch (error) { console.error(`โŒ [queueStore] Error initiating selesai status update for patient ${patient.barcode}:`, error); } } // Jika check-in di klinik, selesai else if (adminType === 'klinik') { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "processed" }; syncApiPatientStatus(allPatients.value[patientIndex], "processed"); message = `Pasien ${patientCode} berhasil check in di Klinik`; } // Jika check-in di penunjang, selesai else if (adminType === 'penunjang') { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "processed" }; syncApiPatientStatus(allPatients.value[patientIndex], "processed"); message = `Pasien ${patientCode} berhasil check in di Penunjang`; } // Clear current processing di admin yang melakukan check-in if (currentProcessingPatient.value[storageKey]?.no === patient.no) { currentProcessingPatient.value[storageKey] = null; } break; case "terlambat": { // DON'T update local state immediately - wait for API success then refresh from DB const loketId = patient.loketId; if (currentProcessingPatient.value[storageKey]?.no === patient.no) { currentProcessingPatient.value[storageKey] = null; } message = `Menandai pasien ${patientCode} sebagai terlambat...`; const payload = { barcode: patient.barcode || "", statuspasien: "29", statuspasien2: "29", idklinikstatus: "2", idklinikstatus2: "2" }; console.log('๐Ÿ“ค [TERLAMBAT] Sending API request:', payload); // POST to external API and WAIT for response try { const response = await fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload) }); console.log('๐Ÿ“ฅ [TERLAMBAT] API response status:', response.status); // Read response body const responseData = await response.json().catch(() => ({})); console.log('๐Ÿ“ฅ [TERLAMBAT] API response data:', responseData); if (response.ok) { console.log(`โœ… [TERLAMBAT] API accepted request`); // Add delay to ensure database is updated console.log('โณ [TERLAMBAT] Waiting 500ms for database update...'); await new Promise(resolve => setTimeout(resolve, 500)); // REFRESH data from database to get updated status console.log(`๐Ÿ”„ [TERLAMBAT] Refreshing data from database...`); await fetchPatientsForLoket(loketId); // Verify status after refresh const updatedPatient = allPatients.value.find(p => p.barcode === patient.barcode); console.log('๐Ÿ” [TERLAMBAT] Patient after refresh:', updatedPatient); if (updatedPatient?.status === 'terlambat') { // API successfully saved status console.log('โœ… [TERLAMBAT] Status persisted in database!'); message = `Pasien ${patientCode} ditandai terlambat`; } else { // API didn't save status - use LocalStorage fallback console.warn('โš ๏ธ [TERLAMBAT] API did not persist status. Using LocalStorage fallback.'); // Save to LocalStorage const storageKey = `patient-status-${patient.barcode}`; localStorage.setItem(storageKey, JSON.stringify({ status: 'terlambat', timestamp: Date.now(), barcode: patient.barcode })); // Manually update local state since API didn't save const patientIndex = allPatients.value.findIndex(p => p.barcode === patient.barcode); if (patientIndex !== -1) { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: 'terlambat', calledByAdmin: false }; syncApiPatientStatus(allPatients.value[patientIndex], 'terlambat'); } message = `Pasien ${patientCode} ditandai terlambat (local)`; } } else { console.error(`โŒ [TERLAMBAT] API rejected request:`, responseData); message = `Gagal: ${responseData.message || 'API error'}`; } } catch (error) { console.error(`โŒ [TERLAMBAT] Error:`, error); message = `Error: ${error.message}`; } break; } case "pending": { // DON'T update local state immediately - wait for API success then refresh from DB const loketId = patient.loketId; if (currentProcessingPatient.value[storageKey]?.no === patient.no) { currentProcessingPatient.value[storageKey] = null; } message = `Menandai pasien ${patientCode} sebagai pending...`; const payload = { barcode: patient.barcode || "", statuspasien: "28", statuspasien2: "28", idklinikstatus: "2", idklinikstatus2: "2" }; console.log('๐Ÿ“ค [PENDING] Sending API request:', payload); // POST to external API and WAIT for response try { const response = await fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload) }); console.log('๐Ÿ“ฅ [PENDING] API response status:', response.status); // Read response body const responseData = await response.json().catch(() => ({})); console.log('๐Ÿ“ฅ [PENDING] API response data:', responseData); if (response.ok) { console.log(`โœ… [PENDING] API accepted request`); // Add delay to ensure database is updated console.log('โณ [PENDING] Waiting 500ms for database update...'); await new Promise(resolve => setTimeout(resolve, 500)); // REFRESH data from database to get updated status console.log(`๐Ÿ”„ [PENDING] Refreshing data from database...`); await fetchPatientsForLoket(loketId); // Verify status after refresh const updatedPatient = allPatients.value.find(p => p.barcode === patient.barcode); console.log('๐Ÿ” [PENDING] Patient after refresh:', updatedPatient); if (updatedPatient?.status === 'pending') { // API successfully saved status console.log('โœ… [PENDING] Status persisted in database!'); message = `Pasien ${patientCode} di-pending`; } else { // API didn't save status - use LocalStorage fallback console.warn('โš ๏ธ [PENDING] API did not persist status. Using LocalStorage fallback.'); // Save to LocalStorage const storageKey = `patient-status-${patient.barcode}`; localStorage.setItem(storageKey, JSON.stringify({ status: 'pending', timestamp: Date.now(), barcode: patient.barcode })); // Manually update local state since API didn't save const patientIndex = allPatients.value.findIndex(p => p.barcode === patient.barcode); if (patientIndex !== -1) { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: 'pending', calledByAdmin: false }; syncApiPatientStatus(allPatients.value[patientIndex], 'pending'); } message = `Pasien ${patientCode} di-pending (local)`; } } else { console.error(`โŒ [PENDING] API rejected request:`, responseData); message = `Gagal: ${responseData.message || 'API error'}`; } } catch (error) { console.error(`โŒ [PENDING] Error:`, error); message = `Error: ${error.message}`; } break; } case "aktifkan": { const currentStatus = allPatients.value[patientIndex].status; if (currentStatus === "terlambat" || currentStatus === "pending") { // PERBAIKAN: Update dengan cara yang Vue reactive allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "di-loket" }; syncApiPatientStatus(allPatients.value[patientIndex], "di-loket"); message = `Pasien ${patientCode} diaktifkan kembali dan masuk ke tabel Di Loket`; // POST to external API to revert status to sedang diproses (status 8) try { fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ barcode: patient.barcode || "", statuspasien: "8", // Status code for "sedang diproses" when reactivating statuspasien2: "8", idklinikstatus: "2", idklinikstatus2: "2" }) }).then(response => { if (response.ok) { console.log(`โœ… [queueStore] Successfully activated patient ${patient.barcode} to sedang diproses`); } else { console.error(`โš ๏ธ [queueStore] Failed to activate patient ${patient.barcode}:`, response.status); } }).catch(error => { console.error(`โŒ [queueStore] Error activating patient ${patient.barcode}:`, error); }); } catch (error) { console.error(`โŒ [queueStore] Error initiating activation for patient ${patient.barcode}:`, error); } } else { message = `Pasien ${patientCode} tidak dapat diaktifkan (status: ${currentStatus})`; } break; } case "proses": { // Ambil data terbaru dari array const patient = allPatients.value[patientIndex]; // Jika pasien memiliki status "pending" atau "terlambat", ubah menjadi "di-loket" // agar pasien masuk ke kategori diLoketPatients dan ditampilkan sebagai "diproses" const currentStatus = patient.status; const shouldUpdateStatus = currentStatus === "pending" || currentStatus === "terlambat"; // Jika adminType adalah 'loket', pastikan ada loket assignment if (adminType === 'loket') { // Pastikan antrian yang diproses memiliki loket assignment const currentLoket = patient.loket || getDefaultLoket(); const currentLoketId = patient.loketId || 1; // Update patient dengan loket assignment dan status (jika perlu) const updatedPatient = { ...patient, loket: patient.loket || currentLoket, loketId: patient.loketId || currentLoketId, // Ubah status menjadi "di-loket" jika sebelumnya "pending" atau "terlambat" ...(shouldUpdateStatus && { status: "di-loket" }) }; allPatients.value[patientIndex] = updatedPatient; // Set currentProcessingPatient with isolated key currentProcessingPatient.value[storageKey] = updatedPatient; // POST to external API when patient is being processed (sedang diproses) try { fetch('http://10.10.123.140:8089/api/v1/tiket/update', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ barcode: patient.barcode || "", statuspasien: "8", // Status code for "sedang diproses" statuspasien2: "8", idklinikstatus: "2", idklinikstatus2: "2" }) }).then(response => { if (response.ok) { console.log(`โœ… [queueStore] Successfully updated patient ${patient.barcode} to sedang diproses`); } else { console.error(`โš ๏ธ [queueStore] Failed to update patient ${patient.barcode} to sedang diproses:`, response.status); } }).catch(error => { console.error(`โŒ [queueStore] Error updating patient ${patient.barcode} to sedang diproses:`, error); }); } catch (error) { console.error(`โŒ [queueStore] Error initiating status update for patient ${patient.barcode}:`, error); } } else { // Untuk adminType selain loket, update status jika perlu if (shouldUpdateStatus) { const updatedPatient = { ...patient, status: "di-loket" }; allPatients.value[patientIndex] = updatedPatient; currentProcessingPatient.value[storageKey] = updatedPatient; } else { currentProcessingPatient.value[storageKey] = patient; } } message = `Memproses pasien ${patientCode}`; break; } } return { success: true, message }; }; const callProcessingPatient = (adminType = 'loket', specificId = null) => { // Panggil pasien yang sedang diproses untuk ditampilkan di layar anjungan const storageKey = specificId ? `${adminType}-${specificId}` : adminType; const processingPatient = currentProcessingPatient.value[storageKey]; if (!processingPatient) { return { success: false, message: "Tidak ada pasien yang sedang diproses" }; } // Update flag calledByAdmin const patientIndex = allPatients.value.findIndex(p => p.no === processingPatient.no); if (patientIndex !== -1) { allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], calledByAdmin: true, lastCalledAt: new Date().toISOString() }; // Update currentProcessingPatient with the new data currentProcessingPatient.value[storageKey] = allPatients.value[patientIndex]; return { success: true, message: `Memanggil pasien ${processingPatient.noAntrian.split(" |")[0]}` }; } return { success: false, message: "Gagal memanggil pasien" }; }; const createAntreanKlinik = (klinik, patient = null, adminType = 'loket') => { const newNo = allPatients.value.length + 1; const timestamp = new Date(); const barcode = patient ? patient.barcode : generateBarcode([], allPatients); const newPatient = { no: newNo, jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( timestamp.getMinutes() ).padStart(2, "0")}`, barcode: barcode, noAntrian: `KL${String(newNo).padStart(4, "0")} | Klinik - ${barcode}`, shift: "Shift 1", klinik: klinik.name, 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 if (!patient) { incrementBarcodeCounter(); } return { success: true, message: `Antrean klinik ${klinik.name} berhasil dibuat dan masuk ke Tabel Loket Klinik`, patient: newPatient, }; }; const createAntreanPenunjang = (penunjang, patient = null, adminType = 'loket') => { const newNo = allPatients.value.length + 1; const timestamp = new Date(); const barcode = patient ? patient.barcode : generateBarcode([], allPatients); const newPatient = { no: newNo, jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( timestamp.getMinutes() ).padStart(2, "0")}`, barcode: barcode, noAntrian: `PN${String(newNo).padStart(4, "0")} | Penunjang - ${barcode}`, shift: "Shift 1", klinik: penunjang.name, fastTrack: "TIDAK", pembayaran: patient ? patient.pembayaran : "UMUM", noRM: patient ? patient.noRM : `RM-SCAN-${barcode.slice(-6)}`, status: "di-loket", processStage: "penunjang", createdAt: timestamp.toISOString(), referencePatient: patient ? patient.noAntrian : null, }; allPatients.value.push(newPatient); // Increment counter setelah barcode digunakan if (!patient) { incrementBarcodeCounter(); } return { success: true, message: `Antrean penunjang ${penunjang.name} berhasil dibuat dan masuk ke Tabel Loket Penunjang`, patient: newPatient, }; }; const createAntreanKlinikRuang = (klinikRuang, ruang, patient = null, adminType = 'klinik', apiTicketNumber = null) => { const newNo = allPatients.value.length + 1; const timestamp = new Date(); const barcode = patient ? patient.barcode : generateBarcode([], allPatients); // Generate nomor antrian baru dengan format: [huruf pertama poli + urutan abjad ruang + nomor antrian ruang] // Contoh: "Anak" ruang 1 = "AA001" (A dari Anak, A dari ruang 1, 001 nomor antrian) let newNoAntrian; // Use API ticket number if available, otherwise generate locally if (apiTicketNumber) { newNoAntrian = apiTicketNumber; // Use ticket from API (e.g., "PK001") console.log('๐ŸŽซ Using API ticket number:', newNoAntrian); } else { // 1. Ambil huruf pertama dari nama klinik/poli const firstLetter = klinikRuang.namaKlinik.charAt(0).toUpperCase(); // 2. Konversi nomor ruang ke abjad (1 = A, 2 = B, 3 = C, dst) const ruangNumber = parseInt(ruang.nomorRuang) || 1; const ruangLetter = String.fromCharCode(64 + ruangNumber); // 64 = '@', 65 = 'A', 66 = 'B', dst // 3. Hitung nomor antrian ruang (dimulai dari 1, maksimal 3 digit) const roomQueues = allPatients.value.filter(p => p.kodeKlinik === klinikRuang.kodeKlinik && p.nomorRuang === ruang.nomorRuang && p.processStage === 'klinik-ruang' ); const queueNumber = roomQueues.length + 1; const queueNumberStr = String(queueNumber).padStart(3, "0"); // 4. Format nomor antrian: AA001, AB002, dst newNoAntrian = `${firstLetter}${ruangLetter}${queueNumberStr}`; } const newPatient = { no: newNo, jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( timestamp.getMinutes() ).padStart(2, "0")}`, barcode: barcode, noAntrian: `${newNoAntrian} | ${klinikRuang.namaKlinik} - ${ruang.namaRuang}`, noAntrianRuang: `${klinikRuang.namaKlinik} - ${ruang.namaRuang} | ${newNoAntrian}`, shift: patient ? (patient.shift || "Shift 1") : "Shift 1", klinik: klinikRuang.namaKlinik, ruang: ruang.namaRuang, kodeKlinik: klinikRuang.kodeKlinik, nomorRuang: ruang.nomorRuang, nomorScreen: ruang.nomorScreen, fastTrack: patient ? (patient.fastTrack || "TIDAK") : "TIDAK", pembayaran: patient ? patient.pembayaran : "UMUM", noRM: patient ? (patient.noRM || patient.norm) : null, status: "pemeriksaan", // Patients from loket to klinik ruang start as "pemeriksaan" processStage: "klinik-ruang", // Set ke klinik-ruang langsung createdAt: timestamp.toISOString(), referencePatient: patient ? patient.noAntrian : null, sourcePatientNo: patient ? patient.no : null, visitId: patient ? (patient.visitId || patient.id) : null, visitCode: patient ? (patient.visitCode || patient.visit_code) : null, registrationType: patient ? (patient.registrationType || 'onsite') : 'onsite', // Tracking panggilan calledPemeriksaanAwal: false, calledTindakan: false, lastCalledAt: null, lastCalledTipeLayanan: null, calledByAdmin: false, // Flag untuk tracking apakah sudah dipanggil oleh admin }; allPatients.value.push(newPatient); // Increment counter setelah barcode digunakan if (!patient) { incrementBarcodeCounter(); } return { success: true, message: `Antrean ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang} berhasil dibuat: ${newNoAntrian}`, patient: newPatient, }; }; // Pindah pasien ke klinik ruang lain dengan nomor antrian tetap const pindahKlinikRuang = (patient, targetKlinikRuang, targetRuang) => { if (patientIndex === -1) { return { success: false, message: "Pasien tidak ditemukan" }; } const currentPatient = allPatients.value[patientIndex]; // Jika pasien sudah punya antrean klinik ruang, update antreannya if (currentPatient.processStage === 'klinik-ruang' && currentPatient.tipeLayanan) { const baseNoAntrian = currentPatient.noAntrian?.split(" |")[0] || currentPatient.barcode || ''; // Update dengan klinik dan ruang baru, tapi nomor antrian tetap sama allPatients.value[patientIndex] = { ...currentPatient, klinik: targetKlinikRuang.namaKlinik, ruang: targetRuang.namaRuang, kodeKlinik: targetKlinikRuang.kodeKlinik, nomorRuang: targetRuang.nomorRuang, nomorScreen: targetRuang.nomorScreen, // Nomor antrian tetap sama noAntrian: `${baseNoAntrian} | ${targetKlinikRuang.namaKlinik} - ${targetRuang.namaRuang} - ${currentPatient.tipeLayanan}`, noAntrianRuang: `${targetKlinikRuang.namaKlinik} - ${targetRuang.namaRuang} | ${baseNoAntrian}` }; // Clear current processing dari ruang lama jika ada const oldKey = `klinik-ruang-${currentPatient.kodeKlinik}-${currentPatient.nomorRuang}`; if (currentProcessingPatient.value[oldKey]?.no === currentPatient.no) { currentProcessingPatient.value[oldKey] = null; } } else { // Pasien belum digenerate tiket, hanya update ruang allPatients.value[patientIndex] = { ...currentPatient, klinik: targetKlinikRuang.namaKlinik, ruang: targetRuang.namaRuang, kodeKlinik: targetKlinikRuang.kodeKlinik, nomorRuang: targetRuang.nomorRuang, nomorScreen: targetRuang.nomorScreen }; } return { success: true, message: `Pasien berhasil dipindah ke ${targetKlinikRuang.namaKlinik} Ruang ${targetRuang.nomorRuang}`, patient: allPatients.value[patientIndex] }; }; // Konsultasi: pindah pasien ke klinik ruang lain dengan nomor antrian baru (prefix K- atau KF-) const konsultasiKlinikRuang = (patient, targetKlinikRuang, targetRuang, tipeLayanan = 'Pemeriksaan Awal') => { const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); if (patientIndex === -1) { return { success: false, message: "Pasien tidak ditemukan" }; } const sourcePatient = allPatients.value[patientIndex]; // Cek apakah sudah ada antrean konsultasi di ruang tujuan const existingKonsultasi = allPatients.value.find(p => p.referencePatient === sourcePatient.noAntrian && p.kodeKlinik === targetKlinikRuang.kodeKlinik && p.nomorRuang === targetRuang.nomorRuang && p.processStage === 'klinik-ruang' && (p.noAntrian?.startsWith('K-') || p.noAntrian?.startsWith('KF-')) ); if (existingKonsultasi) { return { success: false, message: `Pasien sudah memiliki antrean konsultasi di ${targetKlinikRuang.namaKlinik} Ruang ${targetRuang.nomorRuang}` }; } // Generate queue number untuk konsultasi const roomQueues = allPatients.value.filter(p => p.kodeKlinik === targetKlinikRuang.kodeKlinik && p.nomorRuang === targetRuang.nomorRuang && p.tipeLayanan === tipeLayanan && p.processStage === 'klinik-ruang' && (p.noAntrian?.startsWith('K-') || p.noAntrian?.startsWith('KF-')) ); const queueNumber = roomQueues.length + 1; const prefix = tipeLayanan === 'Pemeriksaan Awal' ? 'PA' : 'TD'; // Tentukan prefix konsultasi: KF- untuk Fast Track, K- untuk normal const isFastTrack = sourcePatient.fastTrack === "YA"; const konsultasiPrefix = isFastTrack ? 'KF-' : 'K-'; const queueNumberStr = String(queueNumber).padStart(3, "0"); const newNoAntrian = `${konsultasiPrefix}${prefix}${queueNumberStr}`; const newNo = allPatients.value.length + 1; const timestamp = new Date(); const newPatient = { no: newNo, jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( timestamp.getMinutes() ).padStart(2, "0")}`, barcode: sourcePatient.barcode, noAntrian: `${newNoAntrian} | ${targetKlinikRuang.namaKlinik} - ${targetRuang.namaRuang} - ${tipeLayanan}`, noAntrianRuang: `${targetKlinikRuang.namaKlinik} - ${targetRuang.namaRuang} | ${newNoAntrian}`, shift: sourcePatient.shift || "Shift 1", klinik: targetKlinikRuang.namaKlinik, ruang: targetRuang.namaRuang, kodeKlinik: targetKlinikRuang.kodeKlinik, nomorRuang: targetRuang.nomorRuang, nomorScreen: targetRuang.nomorScreen, tipeLayanan: tipeLayanan, fastTrack: sourcePatient.fastTrack || "TIDAK", pembayaran: sourcePatient.pembayaran || "UMUM", status: "anjungan", processStage: "klinik-ruang", createdAt: sourcePatient.createdAt || timestamp.toISOString(), // Gunakan createdAt dari pasien awal referencePatient: sourcePatient.noAntrian, sourcePatientNo: sourcePatient.no, isKonsultasi: true, // Flag untuk konsultasi }; allPatients.value.push(newPatient); return { success: true, message: `Antrean konsultasi berhasil dibuat: ${newNoAntrian} untuk ${targetKlinikRuang.namaKlinik} Ruang ${targetRuang.nomorRuang}`, patient: newPatient, }; }; // Scan barcode dan generate antrean klinik ruang baru const scanAndCreateAntreanKlinikRuang = (barcodeInput, klinikRuang, ruang, tipeLayanan = 'Pemeriksaan Awal') => { // Clean input - remove whitespace and handle prefix letters const cleanInput = String(barcodeInput).trim().toUpperCase(); // Remove leading letters if any (e.g., "J200730100005" -> "200730100005") const numericInput = cleanInput.replace(/^[A-Z]+/, ''); // Find patient by barcode or noAntrian const sourcePatient = allPatients.value.find(p => { // Exact barcode match if (p.barcode === cleanInput || p.barcode === numericInput) return true; // Check if noAntrian includes the input const noAntrianUpper = (p.noAntrian || '').toUpperCase(); if (noAntrianUpper.includes(cleanInput) || noAntrianUpper.includes(numericInput)) return true; // Try extracting number from noAntrian const noAntrianNumber = noAntrianUpper.match(/([A-Z]+)(\d+)/); if (noAntrianNumber) { const extractedNumber = noAntrianNumber[2]; if (extractedNumber.includes(numericInput) || numericInput.includes(extractedNumber)) return true; } return false; }); if (!sourcePatient) { return { success: false, message: "Pasien tidak ditemukan. Pastikan barcode/nomor antrian benar." }; } // Check if patient already has antrean klinik ruang for this room const existingAntrean = allPatients.value.find(p => p.referencePatient === sourcePatient.noAntrian && p.kodeKlinik === klinikRuang.kodeKlinik && p.nomorRuang === ruang.nomorRuang && p.processStage === 'klinik-ruang' ); if (existingAntrean) { return { success: false, message: `Pasien sudah memiliki antrean di ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang}` }; } // Generate queue number for this specific room and tipeLayanan const roomQueues = allPatients.value.filter(p => p.kodeKlinik === klinikRuang.kodeKlinik && p.nomorRuang === ruang.nomorRuang && p.tipeLayanan === tipeLayanan && p.processStage === 'klinik-ruang' && !p.noAntrian?.startsWith('K-') && // Exclude konsultasi !p.noAntrian?.startsWith('KF-') // Exclude konsultasi Fast Track ); const queueNumber = roomQueues.length + 1; const prefix = tipeLayanan === 'Pemeriksaan Awal' ? 'PA' : 'TD'; // Tentukan prefix untuk Fast Track: F- untuk Fast Track const isFastTrack = sourcePatient.fastTrack === "YA"; const fastTrackPrefix = isFastTrack ? 'F-' : ''; const queueNumberStr = String(queueNumber).padStart(3, "0"); const newNoAntrian = `${fastTrackPrefix}${prefix}${queueNumberStr}`; const newNo = allPatients.value.length + 1; const timestamp = new Date(); const newPatient = { no: newNo, jamPanggil: `${String(timestamp.getHours()).padStart(2, "0")}:${String( timestamp.getMinutes() ).padStart(2, "0")}`, barcode: sourcePatient.barcode, noAntrian: `${newNoAntrian} | ${klinikRuang.namaKlinik} - ${ruang.namaRuang} - ${tipeLayanan}`, noAntrianRuang: `${klinikRuang.namaKlinik} - ${ruang.namaRuang} | ${newNoAntrian}`, shift: sourcePatient.shift || "Shift 1", klinik: klinikRuang.namaKlinik, ruang: ruang.namaRuang, kodeKlinik: klinikRuang.kodeKlinik, nomorRuang: ruang.nomorRuang, nomorScreen: ruang.nomorScreen, tipeLayanan: tipeLayanan, fastTrack: sourcePatient.fastTrack || "TIDAK", pembayaran: sourcePatient.pembayaran || "UMUM", status: "anjungan", processStage: "klinik-ruang", createdAt: sourcePatient.createdAt || timestamp.toISOString(), // Gunakan createdAt dari pasien awal referencePatient: sourcePatient.noAntrian, sourcePatientNo: sourcePatient.no, }; allPatients.value.push(newPatient); return { success: true, message: `Antrean ${tipeLayanan} berhasil dibuat: ${newPatient.noAntrian.split(" |")[0]} untuk ${klinikRuang.namaKlinik} Ruang ${ruang.nomorRuang}`, patient: newPatient, }; }; // Get patients by klinik and ruang const getPatientsByKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan = null) => { return computed(() => { let patients = allPatients.value.filter(p => p.kodeKlinik === kodeKlinik && p.nomorRuang === nomorRuang && p.processStage === 'klinik-ruang' ); if (tipeLayanan) { patients = patients.filter(p => p.tipeLayanan === tipeLayanan); } return { all: patients, anjungan: patients.filter(p => p.status === 'anjungan'), diLoket: patients.filter(p => p.status === 'di-loket'), terlambat: patients.filter(p => p.status === 'terlambat'), pending: patients.filter(p => p.status === 'pending'), }; }); }; // Call next patient for a specific room and tipeLayanan const callNextKlinikRuang = (kodeKlinik, nomorRuang, tipeLayanan, allowMultiple = false) => { const patients = allPatients.value.filter(p => p.kodeKlinik === kodeKlinik && p.nomorRuang === nomorRuang && p.tipeLayanan === tipeLayanan && p.processStage === 'klinik-ruang' && (p.status === 'anjungan' || (allowMultiple && p.status === 'di-loket')) ).sort((a, b) => { // Sort by status priority first: anjungan=1, di-loket=2 const statusPriority = { 'anjungan': 1, 'di-loket': 2 }; const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99); if (priorityDiff !== 0) return priorityDiff; 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` }; } const nextPatient = patients[0]; 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 ${nextPatient.noAntrian.split(" |")[0]}`, patient: allPatients.value[index] }; }; // Process patient in klinik ruang (set as current processing) const processPatientKlinikRuang = async (patient, action, kodeKlinik, nomorRuang) => { const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); if (patientIndex === -1) return { success: false, message: "Pasien tidak ditemukan" }; const specificId = `${kodeKlinik}-${nomorRuang}`; const storageKey = `klinik-ruang-${specificId}`; const pCode = patient.noAntrian.split(" |")[0]; let message = ""; switch (action) { case "proses": allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "di-loket" }; currentProcessingPatient.value[storageKey] = allPatients.value[patientIndex]; message = `Memproses ${pCode}`; break; case "selesai": // Send API call to finish endpoint try { const apiUrl = 'http://10.10.123.135:8084/api/v1/visit/status/finish'; const requestBody = { patient_visit_healthcare_service_id: patient.healthcareServiceId, visit_code: patient.barcode || patient.visitCode, visit_status_id: [19] }; console.log('๐Ÿ“ค [queueStore] Sending finish request to API:', requestBody); const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); if (!response.ok) { console.error('โŒ [queueStore] Failed to finish patient via API:', response.status, response.statusText); // Continue with local status update even if API fails } else { console.log('โœ… [queueStore] Successfully finished patient via API'); } } catch (error) { console.error('โŒ [queueStore] Error calling finish API:', error); // Continue with local status update even if API fails } // Update local status 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[storageKey] = null; message = `Pasien ${pCode} ditandai terlambat`; break; case "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', specificId = null) => { const patientIndex = allPatients.value.findIndex(p => p.no === patient.no); if (patientIndex === -1) return { success: false, message: "Pasien tidak ditemukan" }; const oldPatient = allPatients.value[patientIndex]; const oldLoketId = oldPatient.loketId; // Find appropriate loket for the new clinic from loketStore let targetLoketId = oldLoketId; let targetLoketName = oldPatient.loket; if (newKlinik.kode) { const allLokets = loketStore.lokets || []; const foundLoket = allLokets.find(l => l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(newKlinik.kode) ); if (foundLoket) { targetLoketId = foundLoket.id; targetLoketName = foundLoket.namaLoket; console.log(`๐Ÿ“ก [queueStore] changeKlinik: Re-assigning to Loket ${targetLoketId} (${targetLoketName})`); } } // Determine if we need to reset status from 'diproses' to 'di-loket' if moved to different loket const isMovedToDifferentLoket = String(oldLoketId) !== String(targetLoketId); let newStatus = oldPatient.status; // Check if patient is currently being processed at the old loket const oldKey = specificId ? `${adminType}-${specificId}` : adminType; const isCurrentlyProcessingAtOld = currentProcessingPatient.value[oldKey]?.no === patient.no; if (isMovedToDifferentLoket && isCurrentlyProcessingAtOld) { // If moved, they should be back to waiting list ('di-loket') at the new target newStatus = 'di-loket'; // Clear from old processing slot currentProcessingPatient.value[oldKey] = null; console.log(`๐Ÿ“ก [queueStore] changeKlinik: Cleared processing at ${oldKey} and set status to 'di-loket'`); } // Update the patient in allPatients const updatedPatient = { ...oldPatient, klinik: newKlinik.name, kodeKlinik: newKlinik.kode, loketId: targetLoketId, loket: targetLoketName, status: newStatus }; allPatients.value[patientIndex] = updatedPatient; // If they AREN'T moved but were processing, update their clinic info in the current slot if (!isMovedToDifferentLoket && isCurrentlyProcessingAtOld) { currentProcessingPatient.value[oldKey] = updatedPatient; } let successMessage = `Klinik berhasil diubah ke ${newKlinik.name}`; if (isMovedToDifferentLoket) { successMessage += `. Antrean dipindah ke ${targetLoketName}.`; } return { success: true, message: successMessage, patient: updatedPatient, moved: isMovedToDifferentLoket }; }; const getCurrentProcessing = (adminType, id = null) => { const key = id ? `${adminType}-${id}` : adminType; return computed(() => currentProcessingPatient.value[key] || null); }; const setCurrentProcessing = (patient, adminType, id = null) => { const key = id ? `${adminType}-${id}` : adminType; currentProcessingPatient.value[key] = patient; }; const generateQueueNumber = (clinic, paymentType, isEksekutif = false) => { const serviceType = isEksekutif ? 'E' : 'R'; const today = new Date().toISOString().split('T')[0]; // FIXED: Use the same key as initializeCountersFromSeed const counterKey = `queue_counter_loket_shared_${today}`; let counter = 0; if (typeof window !== 'undefined') { const stored = localStorage.getItem(counterKey); counter = stored ? parseInt(stored, 10) : 0; } counter = counter + 1; const loketIndex = Math.floor((counter - 1) / 999); if (loketIndex > 13) { counter = 1; if (typeof window !== 'undefined') localStorage.setItem(counterKey, '1'); return `${serviceType}A001`; } const loketLetter = String.fromCharCode(65 + loketIndex); const numberInLoket = ((counter - 1) % 999) + 1; const numberPart = String(numberInLoket).padStart(3, '0'); if (typeof window !== 'undefined') localStorage.setItem(counterKey, counter.toString()); 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, 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 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")}`; // 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 const queueNumber = generateQueueNumber(clinic, paymentType, isEksekutif); const finalQueueNumber = isFastTrack ? `F-${queueNumber}` : queueNumber; const noAntrian = `${finalQueueNumber} | Onsite - ${barcode}`; const status = 'menunggu'; const newPatient = { no: newNo, jamPanggil: jamPanggil, barcode: barcode, 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)}`, status: status, processStage: "loket", createdAt: timestamp.toISOString(), registrationType: 'onsite', visitType: visitType, visitDate: visitDate || timestamp.toISOString().substring(0, 10), 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); incrementBarcodeCounter(); return { success: true, message: `Pendaftaran ${clinic.name || clinic} berhasil diproses.`, patient: newPatient, }; }; /** * Register REGULER patient via API * POST to http://10.10.123.140:8089/api/v1/tiket/generate */ const registerRegulerPatientViaApi = async (clinic, paymentType, visitType = 'SEKARANG', isFastTrack = false, fastTrackData = null) => { try { console.log('๐Ÿ”„ [queueStore] Generating ticket via API for REGULER patient...'); console.log('๐Ÿ“‹ [queueStore] Payment Type:', paymentType, '| Clinic:', clinic.name, '| Clinic Code:', clinic.kode); // 1. Find appropriate idloket based on BOTH clinic code AND payment type // IMPROVED: Check specifically for API-source lokets (id < 1000) const apiLoketsExist = loketStore.lokets?.some(l => l.source === 'api' || l.id < 1000); if (!apiLoketsExist) { console.log('๐Ÿ”„ [queueStore] API loket data empty, fetching from API...'); await loketStore.fetchLoketFromAPI(); } const allLokets = loketStore.lokets || []; // Determine payment type for matching (normalize BPJS to JKN for API compatibility) const paymentTypeForMatching = paymentType === "BPJS" ? "JKN" : paymentType; console.log('๐Ÿ” [queueStore] Searching lokets...'); console.log(' Total lokets:', allLokets.length); console.log(' API lokets (id < 1000):', allLokets.filter(l => l.source === 'api' && l.id < 1000).length); console.log(' Looking for clinic:', clinic.kode, 'payment:', paymentTypeForMatching); // Find loket that handles BOTH the clinic AND the payment type const targetLoket = allLokets.find(l => { // Check source: only use API lokets (id < 1000), not local eksekutif if (l.source !== 'api' || l.id >= 1000) return false; // Check if loket handles the clinic const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) && l.pelayanan.includes(clinic.kode); if (handlesClinic) { // Debug: This loket handles the clinic, now check payment console.log(` โœ“ Loket ${l.id} (${l.namaLoket}) handles clinic ${clinic.kode}`); console.log(` Pembayaran array:`, l.pembayaran); // NEW: Use isPaymentCompatible helper for robust payment matching const acceptsPayment = isPaymentCompatible(paymentTypeForMatching, l.pembayaran); console.log(` Accepts payment ${paymentTypeForMatching}:`, acceptsPayment); if (acceptsPayment) { console.log(` โœ… MATCH FOUND: Loket ${l.id}`); return true; } } return false; }); // BLOCK REGISTRATION: No fallback to loket 2 if (!targetLoket) { console.error(`โŒ [queueStore] No loket found for clinic ${clinic.kode} (${clinic.name}) with payment ${paymentTypeForMatching}`); console.warn(`โš ๏ธ [queueStore] Available lokets:`, allLokets.filter(l => l.source === 'api').map(l => ({ id: l.id, nama: l.namaLoket, pelayanan: l.pelayanan, pembayaran: l.pembayaran }))); return { success: false, message: `Maaf, klinik "${clinic.name}" dengan pembayaran "${paymentTypeForMatching}" belum tersedia. Silakan hubungi petugas pendaftaran.` }; } console.log('๐ŸŽฏ [queueStore] Target Loket:', `${targetLoket.namaLoket} (ID: ${targetLoket.id})`, '| Accepts Payment:', targetLoket.pembayaran); // 2. Determine idpembayaran based on paymentType const idloket = String(targetLoket.id); // BPJS (JKN) = "2", UMUM and others = "1" const idpembayaran = paymentType === "BPJS" ? "2" : "1"; // 3. Prepare API Body const body = { idloket: idloket, dokter: "", idklinik: String(clinic.id), namaklinik: clinic.name, idpembayaran: idpembayaran, statuspasien: "1", statuspasien2: "2", idklinikstatus: "1" }; console.log('๐Ÿ“ค [queueStore] API Body:', body); // 3. Call API const response = await fetch('http://10.10.123.140:8089/api/v1/tiket/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { throw new Error(`API error! status: ${response.status}`); } const result = await response.json(); console.log('๐Ÿ“ฅ [queueStore] API Response:', result); if (result.metadata && result.metadata.code !== 200) { return { success: false, message: result.message || 'Gagal generate barcode via API' }; } const apiData = result.data; if (!apiData || !apiData.barcode) { return { success: false, message: 'Data barcode tidak valid dari API' }; } // 4. Map API response to Store Format const timestamp = new Date(); const newNo = allPatients.value.length > 0 ? Math.max(...allPatients.value.map(p => p.no)) + 1 : 1; // Gunakan waktu dari API jika tersedia const jamPanggil = apiData.waktutiket || `${String(timestamp.getHours()).padStart(2, "0")}:${String(timestamp.getMinutes()).padStart(2, "0")}`; const barcode = apiData.barcode; // Penomoran tiket menggunakan "ticket" dari API const queueNumber = apiData.ticket || apiData.barcode; const finalQueueNumber = isFastTrack ? `F-${queueNumber}` : queueNumber; const noAntrian = `${finalQueueNumber} | Onsite - ${barcode}`; const newPatient = { no: newNo, jamPanggil: jamPanggil, barcode: barcode, noAntrian: noAntrian, shift: apiData.shift || 'Shift 1', klinik: clinic.name, kodeKlinik: clinic.kode, fastTrack: isFastTrack ? "YA" : "TIDAK", pembayaran: paymentType, noRM: `RM-${barcode.slice(-6)}`, status: 'menunggu', processStage: "loket", createdAt: apiData.tangaltiket ? `${apiData.tangaltiket}T${apiData.waktutiket || '00:00:00'}` : timestamp.toISOString(), registrationType: 'onsite', visitType: visitType, visitDate: apiData.tangaltiket || timestamp.toISOString().substring(0, 10), namaDokter: null, loketId: parseInt(idloket), loket: targetLoket ? targetLoket.namaLoket : null, calledByAdmin: false, penanggungJawab: (isFastTrack && fastTrackData) ? fastTrackData.penanggungJawab : null, alasanFastTrack: (isFastTrack && fastTrackData) ? fastTrackData.alasanFastTrack : null, // Additional API fields idvisit: 1, // Default menunggu idtiket: apiData.id }; allPatients.value.push(newPatient); // NOTE: Kita tidak perlu incrementBarcodeCounter() di sini karena barcode berasal dari API console.log(`โœ… [queueStore] Successfully registered REGULER patient via API: ${noAntrian}`); return { success: true, message: `Pendaftaran ${clinic.name} berhasil via API.`, patient: newPatient }; } catch (error) { console.error('โŒ [queueStore] Error generating ticket via API:', error); return { success: false, message: `Gagal pendaftaran API: ${error.message}` }; } }; /** * Check-in patient via API * POST to http://10.10.123.140:8089/api/v1/tiket/checkin */ const checkInPatientViaApi = async (barcode) => { try { console.log(`๐Ÿ”„ [queueStore] Syncing check-in via API for barcode: ${barcode}...`); const body = { barcode: barcode, statuspasien: "5", statuspasien2: "6", idklinikstatus: "1", idklinikstatus2: "2" }; console.log('๐Ÿ“ค [queueStore] Check-in API Body:', body); const response = await fetch('http://10.10.123.140:8089/api/v1/tiket/checkin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!response.ok) { throw new Error(`API error! status: ${response.status}`); } const result = await response.json(); console.log('๐Ÿ“ฅ [queueStore] Check-in API Response:', result); return { success: true, data: result }; } catch (error) { console.error('โŒ [queueStore] Error syncing check-in via API:', error); return { success: false, message: `Gagal sync check-in API: ${error.message}` }; } }; // Check-in patient (update status from anjungan to di-loket) // IMPORTANT: Hanya menggunakan EXACT barcode match untuk menghindari false positive // Format barcode: YYMMDD + 5 digit (contoh: 26011500001) // Jangan gunakan fallback ke noAntrian atau no karena bisa menyebabkan false positive const checkInPatient = async (patientIdOrBarcode) => { console.log('๐Ÿ” checkInPatient called with:', patientIdOrBarcode); console.log('๐Ÿ“Š Total patients in store:', allPatients.value.length); // Clean input - remove whitespace and normalize const cleanInput = String(patientIdOrBarcode).trim(); // PRIORITAS: Hanya cari dengan EXACT barcode match (case-insensitive, whitespace-insensitive) // Format barcode: YYMMDD + 5 digit (contoh: 26011500001) // Ini adalah satu-satunya cara yang aman untuk match pasien const patientIndex = allPatients.value.findIndex(p => { // Normalize barcode untuk comparison const patientBarcode = String(p.barcode || '').trim(); // EXACT barcode match (case-insensitive, whitespace-insensitive) // Ini adalah satu-satunya cara yang aman untuk match pasien if (patientBarcode === cleanInput || patientBarcode.toLowerCase() === cleanInput.toLowerCase()) { console.log('โœ… Found by exact barcode match:', patientBarcode, '===', cleanInput); return true; } return false; }); if (patientIndex === -1) { console.log('โŒ Patient not found. Searched for barcode:', cleanInput); console.log('๐Ÿ“‹ Available barcodes (first 10):', allPatients.value.slice(0, 10).map(p => ({ no: p.no, barcode: p.barcode, noAntrian: p.noAntrian?.split(' |')[0] }))); return { success: false, message: `Pasien dengan barcode ${cleanInput} tidak ditemukan. Pastikan barcode benar (format: YYMMDD + 5 digit, contoh: 26011500001).` }; } // IMPORTANT: Get fresh patient data from array to avoid stale data // Use the patientIndex we found earlier, but get fresh data from array const patient = allPatients.value[patientIndex]; console.log('โœ… Patient found (fresh):', { no: patient.no, barcode: patient.barcode, noAntrian: patient.noAntrian, status: patient.status, processStage: patient.processStage }); // Only allow check-in if status is anjungan (sudah dipanggil) or pending // Pasien dengan status "menunggu" belum bisa check-in (belum dipanggil) if (patient.status === 'menunggu') { console.log('โš ๏ธ Patient status is menunggu (belum dipanggil):', patient.status); return { success: false, message: `Pasien belum dipanggil oleh admin loket. Mohon menunggu hingga nomor antrean Anda dipanggil.` }; } // Check if already checked in if (patient.status === 'di-loket') { console.log('โš ๏ธ Patient already checked in:', patient.status); return { success: false, message: `Pasien sudah melakukan check-in sebelumnya. Status saat ini: ${patient.status}` }; } if (patient.status !== 'anjungan' && patient.status !== 'pending') { console.log('โš ๏ธ Patient status is not anjungan/pending:', patient.status); return { success: false, message: `Pasien tidak dapat check-in. Status saat ini: ${patient.status}` }; } // Update status to di-loket using the patientIndex allPatients.value[patientIndex] = { ...allPatients.value[patientIndex], status: "di-loket", }; syncApiPatientStatus(allPatients.value[patientIndex], "di-loket"); console.log('โœ… Check-in successful locally, patient status updated to di-loket'); // Sync to API const apiSyncResult = await checkInPatientViaApi(allPatients.value[patientIndex].barcode); if (!apiSyncResult.success) { console.warn('โš ๏ธ Check-in API sync failed:', apiSyncResult.message); // We still return true because local state is updated, but we could also return failure if critical } return { success: true, message: `Check-in berhasil. Pasien ${patient.noAntrian.split(" |")[0]} siap diproses di loket.`, patient: allPatients.value[patientIndex], }; }; // FIX: Explicit implementation of processNextQueue to target ONLY 'di-loket' patients 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 ONLY from 'di-loket' status // These are patients who have already checked in or been moved to 'di-loket' const eligiblePatients = allPatients.value.filter(p => p.status === 'di-loket' && p.processStage === targetStage && (adminType !== 'loket' || String(p.loketId) === String(specificId)) ); // Exclude the current processing patient to find the TRULY next one const currentNum = currentProcessingPatient.value[key]?.no; const waitingDiLoket = eligiblePatients.filter(p => p.no !== currentNum); // 3. Sort by priority (Fast Track, then No) const sorted = sortPatientsForCalling(waitingDiLoket); const nextPatient = sorted[0]; if (!nextPatient) { return { success: false, message: "Tidak ada antrean 'Di Loket' yang menunggu untuk diproses." }; } // 4. Update Current Processing ISOLATED by key // We don't change status because it's already 'di-loket' // But we update the lastCalledAt and set it as current const index = allPatients.value.findIndex(p => p.no === nextPatient.no); if (index !== -1) { const updatedPatient = { ...allPatients.value[index], lastCalledAt: new Date().toISOString() }; allPatients.value[index] = updatedPatient; 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, lastUpdated, lastFetchTime, lastGlobalFetchTime, kliniks, penunjangs, // API Patient State apiPatientsPerLoket, isLoadingPatients, apiPatientsError, // Computed totalPasien, // Actions callNext, callMultiplePatients, processPatient, callProcessingPatient, createAntreanKlinik, createAntreanPenunjang, createAntreanKlinikRuang, scanAndCreateAntreanKlinikRuang, getPatientsByKlinikRuang, callNextKlinikRuang, processPatientKlinikRuang, changeKlinik, pindahKlinikRuang, konsultasiKlinikRuang, processNextQueue: processNextQueueCorrected, getPatientsByStage, getTotalPasienByStage, getCurrentProcessing, setCurrentProcessing, registerPatientFromAnjungan, checkInPatient, generateQueueNumber, generateBarcode, incrementBarcodeCounter, syncCountersWithState, // API Patient Actions fetchPatientsForLoket, fetchPatientsForClinic, fetchAllPatients, getPatientsForLoket, mapStatusFromDeskripsi, mapApiPatientToStoreFormat, registerRegulerPatientViaApi, checkInPatientViaApi, checkAndResetDaily, isTodayPatient, getResetThreshold, initWebSocket, disconnectWebSocket, isWsConnected, sendViaPost, lastGlobalCall, lastKlinikCall, registerInterest, unregisterInterest, activeLoketInterest, registerClinicInterest, unregisterClinicInterest, activeClinicInterest, registerGlobalInterest, unregisterGlobalInterest, }; }, { persist: { key: 'queue-store-state', storage: typeof window !== 'undefined' ? localStorage : undefined, paths: ['allPatients', 'quotaUsed', 'currentProcessingPatient', 'apiPatientsPerLoket', 'lastUpdated'], serializer: { deserialize: JSON.parse, serialize: JSON.stringify, }, restore: (value) => { // Ensure allPatients is always an array if (value && value.allPatients && !Array.isArray(value.allPatients)) { value.allPatients = []; } return value; }, }, });