feat: Introduce a new Pinia store for comprehensive patient queue management, including API integration, and add supporting admin and kiosk display pages.

This commit is contained in:
Fanrouver
2026-02-19 11:18:36 +07:00
parent 0c4019c67d
commit 4342cdcc67
7 changed files with 341 additions and 122 deletions
@@ -84,6 +84,11 @@
</v-chip>
</div>
<div class="patient-klinik-pembayaran">{{ patient.klinik }} | {{ patient.pembayaran }}</div>
<div v-if="patient.referencePatient" class="patient-reference d-flex align-center gap-1">
<v-icon size="13" color="primary-600">mdi-link</v-icon>
<span class="reference-label">Ref. Loket:</span>
<span class="reference-value text-primary-600">{{ patient.referencePatient }}</span>
</div>
</div>
@@ -342,6 +347,22 @@ defineEmits(['action', 'change-klinik', 'process-next', 'call', 'open-klinik-rua
font-weight: 600;
}
.patient-reference {
font-size: 12px;
line-height: 18px;
color: var(--color-neutral-700);
}
.reference-label {
font-weight: 600;
color: var(--color-neutral-700);
}
.reference-value {
font-weight: 700;
font-size: 13px;
}
.create-queue-buttons {
margin-top: 0;
}
+1 -1
View File
@@ -80,7 +80,7 @@ export default defineNuxtConfig({
authUrl: process.env.AUTH_ORIGIN,
// authUrl: process.env.AUTH_ORIGIN || "http://10.10.150.175:3001",
// authUrl: process.env.AUTH_ORIGIN || "http://localhost:3001",
wsBaseUrl: process.env.WS_BASE_URL || 'ws://10.10.150.100:8084/api/v1/ws',
wsBaseUrl: process.env.WS_BASE_URL || 'ws://10.10.123.135:8084/api/v1/ws',
ekstrakExpertiseUrl: process.env.EKSTRAK_EXPERTISE_URL || 'http://10.10.123.218/ekstrakexpertise',
verificationApiBaseUrl: process.env.VERIFICATION_API_BASE_URL || 'http://10.10.123.140:8089/api/v1',
},
+60 -26
View File
@@ -1471,29 +1471,25 @@ const handleCallPatientFromList = async (ruang, patient, tipeLayanan) => {
// Send via WebSocket to Anjungan clients
try {
// Get all possible client IDs for this klinik (anjungan screens)
// Robust Klinik Code: Use patient's code first, then clinic data, then route
const patientKlinikKode = updateData.kodeKlinik || updateData.idKlinik;
const dataKlinikKode = klinikData.value?.kodeKlinik;
const targetKlinikKode = String(patientKlinikKode || dataKlinikKode || kodeKlinik.value);
console.log('🏥 [AdminKlinikRuang] Final Target Klinik Kode:', targetKlinikKode);
const anjunganClientIds = [];
// Always send to client ID without screen number (broadcast to all screens for this klinik)
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
// 1. Always send to base client ID (broadcast to all screens for this klinik)
anjunganClientIds.push(`anjungan-klinik-ruang-${targetKlinikKode}`);
// Also send to specific screen if ruang has nomorScreen
// 2. Also send to specific screen if this room has a nomorScreen
if (ruang.nomorScreen) {
const specificScreenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${ruang.nomorScreen}`;
if (!anjunganClientIds.includes(specificScreenId)) {
anjunganClientIds.push(specificScreenId);
}
anjunganClientIds.push(`anjungan-klinik-ruang-${targetKlinikKode}-screen-${ruang.nomorScreen}`);
}
// Also send to all other screens for this klinik (from ruangList)
ruangList.value.forEach(r => {
if (r.nomorScreen) {
const screenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${r.nomorScreen}`;
if (!anjunganClientIds.includes(screenId)) {
anjunganClientIds.push(screenId);
}
}
});
// NOTE: Removed redundant loop to all other screens.
// Screens should either be Klinik-wide (using base ID) or Room-specific (using screen param in URL).
console.log('📋 All target client IDs:', anjunganClientIds);
@@ -1504,6 +1500,10 @@ const handleCallPatientFromList = async (ruang, patient, tipeLayanan) => {
console.log('📍 Target clients:', anjunganClientIds);
console.log('📦 Nomor antrian:', nomorAntrian);
console.log('📦 Tipe layanan:', tipeLayanan);
console.log('🕙 Call timestamp:', updateData.lastCalledAt);
console.log('🏥 [AdminKlinikRuang] debug - Route kodeKlinik:', kodeKlinik.value);
console.log('🏥 [AdminKlinikRuang] debug - Data kodeKlinik:', klinikData.value?.kodeKlinik);
console.log('🏥 [AdminKlinikRuang] debug - Data id:', klinikData.value?.id);
// Send to all relevant clients in parallel for speed
await Promise.all(anjunganClientIds.map(async (clientId) => {
@@ -1511,18 +1511,27 @@ const handleCallPatientFromList = async (ruang, patient, tipeLayanan) => {
to_client: clientId,
data: {
noantrian: nomorAntrian,
klinikId: klinikData.value?.kodeKlinik,
klinikId: kodeKlinik.value, // DONT USE Data kodeKlinik, use Route param
tipeLayanan: tipeLayanan,
triggerRefresh: true
triggerRefresh: true,
// Include full call event so Anjungan can patch patient state directly
callKlinikEvent: {
noantrian: nomorAntrian,
barcode: updateData.barcode,
kodeKlinik: kodeKlinik.value, // DONT USE Data kodeKlinik, use Route param
tipeLayanan: tipeLayanan,
lastCalledAt: updateData.lastCalledAt,
nomorRuang: String(ruang.nomorRuang)
}
},
};
console.log(`📨 Sending to ${clientId}:`, message);
console.log(`📨 [AdminKlinikRuang] Triggering broadcast to ${clientId}:`, JSON.stringify(message.data.callKlinikEvent));
const result = await sendViaPost(message);
console.log(`✅ Response from ${clientId}:`, result);
console.log(`✅ [AdminKlinikRuang] Response from ${clientId}:`, result);
}));
console.log('✅ All WebSocket messages sent successfully');
console.log('✅ [AdminKlinikRuang] All WebSocket messages sent successfully');
} catch (error) {
console.error('❌ Error sending WebSocket message:', error);
// Don't block the UI if WebSocket fails
@@ -1698,6 +1707,31 @@ const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
queueStore.allPatients[patientIndex].barcode ||
'-';
// --- API call: update visit status to idvisit 16 (PG PEMERIKSAAN AWAL) or 17 (PG PEMERIKSAAN) ---
try {
const visitStatusId = tipeLayanan === 'Pemeriksaan Awal' ? 16 : 17;
const patient = queueStore.allPatients[patientIndex];
const apiPayload = {
patient_visit_healthcare_service_id: patient.healthcareServiceId,
visit_code: patient.barcode || patient.visitCode,
visit_status_id: [visitStatusId]
};
console.log(`📤 [AdminKlinikRuang] Sending call status (idvisit ${visitStatusId}) to API:`, apiPayload);
const apiResponse = await fetch('http://10.10.123.135:8084/api/v1/visit/status/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(apiPayload)
});
if (apiResponse.ok) {
console.log(`✅ [AdminKlinikRuang] Call status ${visitStatusId} saved to API`);
} else {
console.warn(`⚠️ [AdminKlinikRuang] API returned error for call status:`, apiResponse.status);
}
} catch (apiError) {
console.error('❌ [AdminKlinikRuang] Error sending call status to API:', apiError);
// Continue even if API fails — local UI still updates
}
try {
const anjunganClientIds = [];
anjunganClientIds.push(`anjungan-klinik-ruang-${klinikData.value.kodeKlinik}`);
@@ -1771,7 +1805,7 @@ const handlePatientAction = async (ruang, action) => {
};
// Handle proses pasien (untuk pasien yang belum diproses)
const handleProcessPatient = (ruang, patient) => {
const handleProcessPatient = async (ruang, patient) => {
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (patientIndex === -1) {
showSnackbar('Pasien tidak ditemukan', 'error');
@@ -1779,7 +1813,7 @@ const handleProcessPatient = (ruang, patient) => {
}
// Set sebagai current processing
const result = queueStore.processPatientKlinikRuang(
const result = await queueStore.processPatientKlinikRuang(
queueStore.allPatients[patientIndex],
'proses',
klinikData.value.kodeKlinik,
@@ -1792,7 +1826,7 @@ const handleProcessPatient = (ruang, patient) => {
}
};
const handleProcessPendingPatient = (ruang, patient) => {
const handleProcessPendingPatient = async (ruang, patient) => {
// Proses kembali pasien yang dipending
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (patientIndex === -1) {
@@ -1807,7 +1841,7 @@ const handleProcessPendingPatient = (ruang, patient) => {
};
// Set sebagai current processing
const result = queueStore.processPatientKlinikRuang(
const result = await queueStore.processPatientKlinikRuang(
queueStore.allPatients[patientIndex],
'proses',
klinikData.value.kodeKlinik,
+9
View File
@@ -164,6 +164,15 @@
}}</span>
</div>
</v-col>
<v-col cols="12" v-if="currentProcessingPatient.referencePatient">
<div class="detail-item mt-2">
<span class="caption-2 text-muted">No. Reference (Loket)</span>
<span class="body-2 text-semibold text-primary-600">
<v-icon size="14" class="mr-1" color="primary-600">mdi-link</v-icon>
{{ currentProcessingPatient.referencePatient }}
</span>
</div>
</v-col>
</v-row>
</div>
+20 -17
View File
@@ -38,7 +38,7 @@
{{ clinic.name }}
</h3>
<div class="doctor-info">
<div v-if="getDisplayDoctorInfo(clinic)" class="doctor-info">
<v-icon
size="14"
:color="
@@ -186,12 +186,7 @@
<span class="doctor-name">{{ doctor }}</span>
</v-btn>
</div>
<p
v-if="dialogDoctors.length === 0"
class="text-error text-caption mt-2"
>
Tidak ada dokter tersedia untuk klinik ini
</p>
</div>
</div>
@@ -213,9 +208,7 @@
<span class="doctor-info-name-unified">{{ doctor }}</span>
</div>
</div>
<p v-else class="text-error text-caption italic">
Tidak ada dokter tersedia untuk klinik ini
</p>
</div>
<p><strong>Jadwal:</strong> {{ selectedClinic.schedule }}</p>
@@ -375,12 +368,7 @@
</v-list-item>
</template>
</v-select>
<p
v-if="dialogDoctors.length === 0"
class="text-error text-caption"
>
Tidak ada dokter tersedia untuk klinik ini
</p>
</div>
</v-card-text>
<v-divider />
@@ -439,6 +427,7 @@
density="compact"
required
class="mb-3"
no-data-text="Klinik Tutup"
></v-select>
<v-select
@@ -945,7 +934,7 @@ const getDisplayDoctorInfo = (clinic) => {
apiDoctors && apiDoctors.length > 0 ? apiDoctors : clinic.doctors || [];
if (!doctors || doctors.length === 0) {
return "Tidak ada dokter";
return '';
}
const maxDisplay = 1;
@@ -1300,6 +1289,20 @@ const submitBooking = async () => {
return;
}
// Validasi: pastikan ada shift yang tersedia untuk tanggal ini
const availableShifts = getAvailableShiftsForBooking();
if (availableShifts.length === 0) {
showSnackbar("Klinik Tutup pada tanggal yang dipilih.", "error");
return;
}
// Validasi: pastikan shift yang dipilih masih valid untuk tanggal ini
const isShiftValid = availableShifts.some(s => s.value === bookingForm.value.shift);
if (!isShiftValid) {
showSnackbar("Pilih shift yang tersedia terlebih dahulu.", "error");
return;
}
// Validasi dokter untuk pasien Eksekutif
if (isEksekutif.value && !bookingForm.value.doctor) {
showSnackbar("Mohon pilih dokter terlebih dahulu.", "error");
@@ -228,6 +228,56 @@ const klinikData = computed(() => {
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
const broadcastedKlinikPatient = ref(null)
// Watch for Klinik Calls (Cross-Device Sync) from queueStore
watch(() => queueStore.lastKlinikCall, (newCall) => {
if (!newCall || !newCall.noantrian) return;
console.log('📡 [Anjungan] WS lastKlinikCall changed. RAW DATA:', JSON.stringify(newCall));
const callKode = String(newCall.kodeKlinik || '').toUpperCase();
const myKode = String(kodeKlinik.value || '').toUpperCase();
const isMatch = callKode === myKode;
console.log('🔍 [Anjungan] Klinik Code Comparison:', {
received: callKode,
local: myKode,
match: isMatch
});
// Only react if the call is for THIS klinik
if (isMatch) {
console.log('📢 [Anjungan] SUCCESS! Code match found. Processing call:', newCall.noantrian);
// Find the patient in the current list to get full details if available
const existingPatient = klinikPatients.value.find(p =>
(p.barcode && String(p.barcode) === String(newCall.barcode)) ||
(p.noAntrian && p.noAntrian.split(' |')[0] === String(newCall.noantrian))
);
if (existingPatient) {
console.log('✅ [Anjungan] Linked to existing patient in memory:', existingPatient.noAntrian);
} else {
console.warn('⚠️ [Anjungan] Patient not found in local list. Using broadcast details only.');
console.log('🧪 [Anjungan] Available patient numbers in local list:', klinikPatients.value.map(p => p.noAntrian?.split(' |')[0]));
}
// Set broadcasted patient to trigger Hero animation
broadcastedKlinikPatient.value = {
...(existingPatient || {}),
noAntrian: existingPatient?.noAntrian || newCall.noantrian,
barcode: newCall.barcode,
tipeLayanan: newCall.tipeLayanan,
lastCalledAt: newCall.lastCalledAt || new Date().toISOString(),
status: 'di-loket',
ruang: existingPatient?.ruang || existingPatient?.namaRuang || `Ruang ${newCall.nomorRuang || '1'}`,
_source: 'websocket'
};
} else {
console.log(`⏭️ [Anjungan] Skipping call for clinic ${callKode} (this screen is for ${myKode})`);
}
}, { deep: true });
const klinikPatients = computed(() => {
// Get valid room numbers for this klinik+jenisLayanan combination
@@ -317,8 +367,10 @@ const displayedRuang = computed(() => {
const tindakanQueues = allQueues.filter(q => q.tipeLayanan === 'Tindakan')
const getCurrentAndNext = (queues, tipeLayanan) => {
// Current: patients actively being called (di-loket) OR recently called pemeriksaan
const currentCandidates = queues.filter(q =>
q.status === 'di-loket' && q.tipeLayanan === tipeLayanan
(q.status === 'di-loket' || (q.status === 'pemeriksaan' && q.lastCalledAt)) &&
q.tipeLayanan === tipeLayanan
).sort((a, b) => {
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt).getTime() : 0
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt).getTime() : 0
@@ -326,8 +378,11 @@ const displayedRuang = computed(() => {
})
const current = currentCandidates.length > 0 ? currentCandidates[0] : null
// Next: patients waiting 'pemeriksaan' (from API) or 'menunggu' matches real statuses
const next = queues.filter(q =>
q.no !== current?.no && q.status === 'waiting'
q.no !== current?.no &&
['pemeriksaan', 'menunggu', 'waiting'].includes(q.status) &&
!q.lastCalledAt
).slice(0, 3)
return { current, next }
}
@@ -362,24 +417,28 @@ const currentCalledQueue = computed(() => {
const CALL_DISPLAY_DURATION = 60000 // 60 seconds
const now = new Date()
// Force reactivity for time updates if needed (though now is created fresh here)
// To make it truly reactive to time passing, we might need a reactive timestamp
// But since this is a computed property, it recalculates when dependencies change.
// To auto-hide after 60s without data change, we'd need a timer.
// For now, let's just use the current time when data updates.
// If we want auto-hide, we can rely on the timeInterval updating 'currentTime'
// but 'currentTime' is a string.
// Let's use a reactive timestamp or just rely on re-renders for now.
// Better: use the 'currentTime' string change to trigger re-calc if we include it.
// Force reactivity to time passing
const _ = currentTime.value
// Get most recently called patient (status di-loket)
// Sort by lastCalledAt
// 1. Prioritaskan broadcastedKlinikPatient (panggilan baru dari WebSocket)
if (broadcastedKlinikPatient.value) {
const callTime = new Date(broadcastedKlinikPatient.value.lastCalledAt)
const timeDiff = now.getTime() - callTime.getTime()
if (timeDiff <= CALL_DISPLAY_DURATION) {
return broadcastedKlinikPatient.value
} else {
// Clear it if expired
broadcastedKlinikPatient.value = null
}
}
// 2. Fallback ke data paling lambat dari store (status di-loket atau pemeriksaan yang baru dipanggil)
const calledQueues = klinikPatients.value
.filter(p => p.status === 'di-loket')
.filter(p => (p.status === 'di-loket' || p.status === 'pemeriksaan') && p.lastCalledAt)
.sort((a, b) => {
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt) : new Date(a.createdAt || 0)
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt) : new Date(b.createdAt || 0)
const timeA = new Date(a.lastCalledAt)
const timeB = new Date(b.lastCalledAt)
return timeB - timeA
})
@@ -387,7 +446,7 @@ const currentCalledQueue = computed(() => {
const patient = calledQueues[0]
// Check if within duration
const callTime = patient.lastCalledAt ? new Date(patient.lastCalledAt) : new Date(patient.createdAt || 0)
const callTime = new Date(patient.lastCalledAt)
const timeDiff = now.getTime() - callTime.getTime()
if (timeDiff <= CALL_DISPLAY_DURATION) {
@@ -403,14 +462,16 @@ const currentCalledQueue = computed(() => {
const statistics = computed(() => {
if (!kodeKlinik.value || !klinikData.value) {
return { total: 0, waiting: 0, active: 0 }
return { total: 0, anjungan: 0, active: 0 }
}
const all = klinikPatients.value
return {
total: all.length,
anjungan: all.filter(p => p.status === 'anjungan').length,
active: all.filter(p => p.status === 'di-loket').length
// Counting 'anjungan', 'menunggu', 'waiting', 'pending', 'terlambat' as waiting
anjungan: all.filter(p => ['anjungan', 'menunggu', 'waiting', 'pending', 'terlambat'].includes(p.status)).length,
// Counting 'di-loket' and 'pemeriksaan' as active/processing
active: all.filter(p => ['di-loket', 'pemeriksaan'].includes(p.status)).length
}
})
@@ -471,6 +532,8 @@ const fetchAllData = async () => {
await clinicStore.fetchRegulerClinics();
await ruangStore.fetchRuangFromAPI();
queueStore.ensureInitialData();
// Fetch fresh patient data for this clinic
await queueStore.fetchPatientsForClinic(kodeKlinik.value);
console.log('✅ AntrianKlinikRuang refresh: Success');
} catch (err) {
console.error('❌ AntrianKlinikRuang refresh error:', err);
@@ -482,6 +545,7 @@ const anjunganClientId = computed(() => {
if (!kodeKlinik.value) return ''
// Priority 1: Try to get screen number from URL query params
// Use this if you want a specific screen to only show certain rooms
const screenParam = route.query.screen
if (screenParam) {
const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}-screen-${screenParam}`
@@ -489,20 +553,10 @@ const anjunganClientId = computed(() => {
return clientId
}
// Priority 2: Try to get screen number from ruangList (ambil nomorScreen dari ruang pertama yang ada)
if (ruangListForKlinik.value && ruangListForKlinik.value.length > 0) {
// Ambil nomorScreen dari ruang pertama yang memiliki nomorScreen
const firstRuangWithScreen = ruangListForKlinik.value.find(r => r.nomorScreen)
if (firstRuangWithScreen && firstRuangWithScreen.nomorScreen) {
const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}-screen-${firstRuangWithScreen.nomorScreen}`
console.log('🆔 Using client ID from ruangList nomorScreen:', clientId)
return clientId
}
}
// Priority 3: Fallback to client ID without screen number (broadcast)
// NOTE: Priority 2 (guessing from ruangList) removed to prevent ID mismatch
// Default to base Klinik ID (broadcast). This makes the screen receive all calls for this clinic.
const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}`
console.log('🆔 Using default client ID (broadcast):', clientId)
console.log('🆔 Using base Klinik client ID (broadcast):', clientId)
return clientId
})
@@ -602,12 +656,17 @@ onMounted(async () => {
// WebSocket initialization is now centralized in queueStore
if (kodeKlinik.value) {
queueStore.initWebSocket(anjunganClientId.value);
// Register interest so WS messages trigger data refresh for this clinic
queueStore.registerClinicInterest(kodeKlinik.value);
}
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
// wsInstance is now global
// Unregister clinic interest when leaving
if (kodeKlinik.value) {
queueStore.unregisterClinicInterest(kodeKlinik.value);
}
})
// Watch for clientId changes and reconnect if needed
+137 -44
View File
@@ -80,18 +80,18 @@ export const useQueueStore = defineStore('queue', () => {
console.log(`🌐 [queueStore] Global interest unregistered. Total: ${globalInterestCount.value}`);
};
const fetchPatientsForClinic = async (kodeKlinik) => {
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)
// 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 (timeSinceLastFetch < 2000) {
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' };
@@ -169,11 +169,22 @@ export const useQueueStore = defineStore('queue', () => {
registrationType: 'api',
visitId: visit.visit_id || visit.id,
visitCode: visit.visit_code,
referencePatient: 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';
})?.ticket || ''
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);
@@ -281,65 +292,141 @@ export const useQueueStore = defineStore('queue', () => {
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) => {
const messageData = data?.data || 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:', data);
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)
// 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
console.log(`🎯 [queueStore] WS targeting Loket ${targetLoketId}: Refreshing...`);
fetchPatientsForLoket(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
// Check if it matches any of our active interests or if it's a general trigger
// 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') {
console.log(`🎯 [queueStore] WS targeting Clinic ${targetKlinikId}: Refreshing...`);
fetchPatientsForClinic(targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId);
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
console.log(`🎯 [queueStore] WS targeting Clinic ${targetKlinikId}: Refreshing ${clinicToFetch} (FORCED)...`);
fetchPatientsForClinic(clinicToFetch, true);
refreshedSomething = true;
}
}
// 3. If we have global interest (e.g. Check-in page open), always refresh everything on ANY trigger
// 3. Global interest refresh (Force bypass throttle)
if (globalInterestCount.value > 0) {
console.log(`📡 [queueStore] WS trigger: Global Interest active, triggering staggered bulk refresh...`);
fetchAllPatients();
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
// 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: Refreshing ${interestingLokets.length} active lokets:`, interestingLokets);
console.log(`🌐 [queueStore] WS trigger fallback: Refreshing ${interestingLokets.length} active lokets (FORCED):`, interestingLokets);
interestingLokets.forEach(loketId => {
fetchPatientsForLoket(loketId);
fetchPatientsForLoket(loketId, true);
});
refreshedSomething = true;
}
if (interestingClinics.length > 0) {
console.log(`🌐 [queueStore] WS trigger: Refreshing ${interestingClinics.length} active clinics:`, interestingClinics);
console.log(`🌐 [queueStore] WS trigger fallback: Refreshing ${interestingClinics.length} active clinics (FORCED):`, interestingClinics);
interestingClinics.forEach(kodeKlinik => {
fetchPatientsForClinic(kodeKlinik);
fetchPatientsForClinic(kodeKlinik, true);
});
refreshedSomething = true;
}
@@ -608,25 +695,25 @@ export const useQueueStore = defineStore('queue', () => {
};
/**
* Fetch patient data untuk loket tertentu dari API
*/
const fetchPatientsForLoket = async (loketId) => {
if (!loketId) {
console.error('loketId required for fetchPatientsForLoket');
return { success: false, message: 'ID Loket diperlukan' };
}
* 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;
isLoadingPatients.value = true;
apiPatientsError.value = null;
// THROTTLE: Check if we recently fetched (within last 2 seconds)
const now = Date.now();
const lastFetch = lastFetchTime.value[loketId] || 0;
const timeSinceLastFetch = now - lastFetch;
if (timeSinceLastFetch < 2000 && apiPatientsPerLoket.value[loketId]) {
console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
isLoadingPatients.value = false;
// 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',
@@ -914,7 +1001,8 @@ export const useQueueStore = defineStore('queue', () => {
const id = loket.id || loket.no;
if (id) {
// We use await to wait for the fetch to finish, then wait a bit more
await fetchPatientsForLoket(id);
// 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));
@@ -2654,6 +2742,10 @@ export const useQueueStore = defineStore('queue', () => {
switch (action) {
case "proses":
allPatients.value[patientIndex] = {
...allPatients.value[patientIndex],
status: "di-loket"
};
currentProcessingPatient.value[storageKey] = allPatients.value[patientIndex];
message = `Memproses ${pCode}`;
break;
@@ -3344,6 +3436,7 @@ export const useQueueStore = defineStore('queue', () => {
isWsConnected,
sendViaPost,
lastGlobalCall,
lastKlinikCall,
registerInterest,
unregisterInterest,
activeLoketInterest,