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
+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,