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:
+137
-44
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user