feat: implement queue store with clinic API integration, WebSocket sync, and patient data management
This commit is contained in:
+41
-172
@@ -1,193 +1,62 @@
|
||||
// composables/useQueueAPI.ts
|
||||
// Composable untuk API calls terkait antrian pasien
|
||||
|
||||
export interface Patient {
|
||||
no: number;
|
||||
jamPanggil: string;
|
||||
barcode: string;
|
||||
noAntrian: string;
|
||||
shift: string;
|
||||
klinik: string;
|
||||
fastTrack: string;
|
||||
pembayaran: string;
|
||||
status: 'anjungan' | 'pending' | 'di-loket' | 'di-klinik' | 'selesai' | 'terlambat';
|
||||
processStage: 'loket' | 'klinik' | 'penunjang';
|
||||
createdAt: string;
|
||||
registrationType?: 'online' | 'onsite';
|
||||
visitType?: string;
|
||||
visitDate?: string;
|
||||
}
|
||||
|
||||
export interface QueueAPIResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
import { useRuntimeConfig } from '#app';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export const useQueueAPI = () => {
|
||||
const config = useRuntimeConfig();
|
||||
const baseURL = config.public.apiBaseUrl || '/api/queue';
|
||||
const verificationApiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const externalApiBase = config.public.externalApiBaseUrl;
|
||||
|
||||
/**
|
||||
* Fetch all patients from database
|
||||
*/
|
||||
const fetchAllPatients = async (): Promise<Patient[]> => {
|
||||
const fetchRawLoketPatients = async (loketId: string | number) => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient[]>>(`${baseURL}/patients`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const rawData: any = await $fetch(`${verificationApiBase}/loket/${loketId}`);
|
||||
if (rawData.metadata && rawData.metadata.code !== 200) {
|
||||
throw new Error(rawData.message || 'API returned error status');
|
||||
}
|
||||
throw new Error(response.message || 'Failed to fetch patients');
|
||||
return rawData.data || [];
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching patients:', error);
|
||||
throw error;
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch single patient by ID or barcode
|
||||
*/
|
||||
const fetchPatient = async (idOrBarcode: string): Promise<Patient | null> => {
|
||||
const fetchRawClinicPatients = async (clinicId: string | number) => {
|
||||
const url = `${externalApiBase}/visit?klinik_id=${clinicId}&limit=500`;
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients/${idOrBarcode}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
const rawResponse: any = await $fetch(url);
|
||||
return rawResponse?.data || [];
|
||||
} catch (error: any) {
|
||||
throw new Error(`HTTP error! status: ${error.response?.status || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const updateTicketStatus = async (barcode: string, statuspasien: string, statuspasien2: string, idklinikstatus: string, idklinikstatus2: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
statuspasien2,
|
||||
idklinikstatus,
|
||||
idklinikstatus2
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error fetching patient:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new patient (register from Anjungan)
|
||||
*/
|
||||
const createPatient = async (patientData: Partial<Patient>): Promise<Patient> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(`${baseURL}/patients`, {
|
||||
method: 'POST',
|
||||
body: patientData,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
const completeTicketStatus = async (idloket: string, barcode: string, statuspasien: string, idklinikstatus: string) => {
|
||||
return await $fetch(`${verificationApiBase}/tiket/selesai`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
idloket: String(idloket || ""),
|
||||
barcode: barcode || "",
|
||||
statuspasien,
|
||||
idklinikstatus
|
||||
}
|
||||
throw new Error(response.message || 'Failed to create patient');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error creating patient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update patient status (check-in, process, etc)
|
||||
*/
|
||||
const updatePatient = async (
|
||||
idOrBarcode: string,
|
||||
updates: Partial<Patient>
|
||||
): Promise<Patient> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse<Patient>>(
|
||||
`${baseURL}/patients/${idOrBarcode}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: updates,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.message || 'Failed to update patient');
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error updating patient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check-in patient (update status to di-loket)
|
||||
*/
|
||||
const checkInPatient = async (idOrBarcode: string): Promise<Patient> => {
|
||||
return updatePatient(idOrBarcode, { status: 'di-loket' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Process patient at loket (update status and processStage)
|
||||
*/
|
||||
const processPatientAtLoket = async (
|
||||
idOrBarcode: string,
|
||||
updates: { status?: string; processStage?: string }
|
||||
): Promise<Patient> => {
|
||||
return updatePatient(idOrBarcode, updates as Partial<Patient>);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync local state with database
|
||||
*/
|
||||
const syncWithDatabase = async (localPatients: Patient[]): Promise<Patient[]> => {
|
||||
try {
|
||||
// Fetch latest from database
|
||||
const dbPatients = await fetchAllPatients();
|
||||
|
||||
// Merge strategy: prefer database data, but keep local if newer
|
||||
const merged = new Map<string, Patient>();
|
||||
|
||||
// Add database patients
|
||||
dbPatients.forEach(patient => {
|
||||
merged.set(patient.barcode, patient);
|
||||
});
|
||||
|
||||
// Add local patients that don't exist in DB or are newer
|
||||
localPatients.forEach(localPatient => {
|
||||
const existing = merged.get(localPatient.barcode);
|
||||
if (!existing || new Date(localPatient.createdAt) > new Date(existing.createdAt)) {
|
||||
merged.set(localPatient.barcode, localPatient);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(merged.values());
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error syncing with database:', error);
|
||||
// Return local patients as fallback
|
||||
return localPatients;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch sync: save multiple patients to database
|
||||
*/
|
||||
const batchSyncPatients = async (patients: Patient[]): Promise<boolean> => {
|
||||
try {
|
||||
const response = await $fetch<QueueAPIResponse>(`${baseURL}/patients/batch`, {
|
||||
method: 'POST',
|
||||
body: { patients },
|
||||
});
|
||||
|
||||
return response.success || false;
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error batch syncing patients:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
fetchAllPatients,
|
||||
fetchPatient,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
checkInPatient,
|
||||
processPatientAtLoket,
|
||||
syncWithDatabase,
|
||||
batchSyncPatients,
|
||||
fetchRawLoketPatients,
|
||||
fetchRawClinicPatients,
|
||||
updateTicketStatus,
|
||||
completeTicketStatus
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRuntimeConfig } from '#app';
|
||||
import { useWebSocket } from '@/composables/useWebSocket';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export interface QueueSyncDeps {
|
||||
allPatients: Ref<QueuePatient[]>;
|
||||
currentProcessingPatient: Ref<Record<string, QueuePatient>>;
|
||||
activeLoketInterest: Ref<Record<string, number>>;
|
||||
activeClinicInterest: Ref<Record<string, number>>;
|
||||
globalInterestCount: Ref<number>;
|
||||
fetchPatientsForLoket: (id: string | number, force?: boolean) => void;
|
||||
fetchPatientsForClinic: (id: string, force?: boolean) => void;
|
||||
fetchAllPatients: () => void;
|
||||
}
|
||||
|
||||
export const useQueueSync = (deps: QueueSyncDeps) => {
|
||||
const isWsConnected = ref<boolean>(false);
|
||||
const wsClientId = ref<string>(`client-${Math.random().toString(36).substring(7)}`);
|
||||
const lastGlobalCall = ref<any>(null);
|
||||
const lastKlinikCall = ref<any>(null);
|
||||
|
||||
const onWsMessage = (data: any) => {
|
||||
// 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;
|
||||
|
||||
// Handle Call Events and WS messages
|
||||
if (messageData?.triggerRefresh) {
|
||||
if (messageData.klinikId) {
|
||||
// console.log(`🔄 [queueSync] Received refresh trigger for clinic ${messageData.klinikId}`);
|
||||
|
||||
// Handle current processing update if provided
|
||||
if (messageData.currentProcessingUpdate) {
|
||||
// console.log(`🎯 [queueSync] Applying current processing update:`, messageData.currentProcessingUpdate);
|
||||
|
||||
Object.keys(messageData.currentProcessingUpdate).forEach(key => {
|
||||
deps.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 = deps.allPatients.value.findIndex(p => p.no === processingPatient.no);
|
||||
if (idx !== -1) {
|
||||
deps.allPatients.value[idx] = { ...deps.allPatients.value[idx], status: 'di-loket' };
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deps.fetchPatientsForClinic(messageData.klinikId, true);
|
||||
}
|
||||
}
|
||||
if (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('🏥 [queueSync] 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
|
||||
const idx = deps.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) {
|
||||
const updatedPatient = {
|
||||
...deps.allPatients.value[idx],
|
||||
tipeLayanan: ev.tipeLayanan,
|
||||
lastCalledAt: ev.lastCalledAt || new Date().toISOString(),
|
||||
lastCalledTipeLayanan: ev.tipeLayanan,
|
||||
status: 'di-loket' as any,
|
||||
calledPemeriksaanAwal: ev.tipeLayanan === 'Pemeriksaan Awal' ? true : deps.allPatients.value[idx].calledPemeriksaanAwal,
|
||||
calledTindakan: ev.tipeLayanan === 'Tindakan' ? true : deps.allPatients.value[idx].calledTindakan
|
||||
};
|
||||
|
||||
deps.allPatients.value[idx] = updatedPatient;
|
||||
// console.log(`✅ [queueSync] Successfully patched patient ${ev.noantrian} status to di-loket (lastCalledAt: ${updatedPatient.lastCalledAt})`);
|
||||
} else {
|
||||
console.warn(`⚠️ [queueSync] Patient ${ev.noantrian} not found in store for clinic ${ev.kodeKlinik}.`);
|
||||
}
|
||||
}
|
||||
// TRIGGER STRATEGIC REFRESHES
|
||||
let refreshedSomething = false;
|
||||
|
||||
if (targetLoketId) {
|
||||
deps.fetchPatientsForLoket(targetLoketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (targetKlinikId) {
|
||||
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
|
||||
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
|
||||
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
|
||||
deps.fetchPatientsForClinic(clinicToFetch, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.globalInterestCount.value > 0) {
|
||||
deps.fetchAllPatients();
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
// ALWAYS refresh our own active interests when a WebSocket message is received
|
||||
const interestingLokets = Object.keys(deps.activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(deps.activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => {
|
||||
if (String(loketId) !== String(targetLoketId)) {
|
||||
deps.fetchPatientsForLoket(loketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => {
|
||||
if (String(kodeKlinik) !== String(targetKlinikId)) {
|
||||
deps.fetchPatientsForClinic(kodeKlinik, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!refreshedSomething) {
|
||||
// console.log(`🔕 [queueSync] 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: `${config.public.externalApiBaseUrl}/ws`,
|
||||
reconnectInterval: 2000,
|
||||
maxReconnectAttempts: 9999,
|
||||
onOpen: () => {
|
||||
// console.log('✅ [queueSync] WebSocket connected');
|
||||
isWsConnected.value = true;
|
||||
},
|
||||
onClose: () => {
|
||||
// console.log('❌ [queueSync] WebSocket disconnected');
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error('⚠️ [queueSync] WebSocket error:', err);
|
||||
isWsConnected.value = false;
|
||||
},
|
||||
onMessage: onWsMessage
|
||||
});
|
||||
|
||||
let _autoSyncInterval: any = null;
|
||||
|
||||
const startAutoSync = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return;
|
||||
|
||||
// console.log('🔄 [queueSync] Starting store-level auto-sync (30s interval)');
|
||||
|
||||
_autoSyncInterval = setInterval(async () => {
|
||||
const hasLoketInterest = Object.keys(deps.activeLoketInterest.value).length > 0;
|
||||
const hasClinicInterest = Object.keys(deps.activeClinicInterest.value).length > 0;
|
||||
const hasGlobalInterest = deps.globalInterestCount.value > 0;
|
||||
|
||||
if (hasGlobalInterest) {
|
||||
deps.fetchAllPatients();
|
||||
} else {
|
||||
if (hasLoketInterest) {
|
||||
Object.keys(deps.activeLoketInterest.value).forEach(loketId => {
|
||||
deps.fetchPatientsForLoket(loketId, true);
|
||||
});
|
||||
}
|
||||
if (hasClinicInterest) {
|
||||
Object.keys(deps.activeClinicInterest.value).forEach(kodeKlinik => {
|
||||
deps.fetchPatientsForClinic(kodeKlinik, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
const stopAutoSync = () => {
|
||||
if (_autoSyncInterval) {
|
||||
clearInterval(_autoSyncInterval);
|
||||
_autoSyncInterval = null;
|
||||
// console.log('⏹️ [queueSync] Store-level auto-sync stopped');
|
||||
}
|
||||
};
|
||||
|
||||
const initWebSocket = (customClientId: string | null = null) => {
|
||||
if (isConnected.value && customClientId === wsClientId.value) {
|
||||
// console.log('🔌 [queueSync] WebSocket already connected with same ID.');
|
||||
startAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customClientId) {
|
||||
wsClientId.value = customClientId;
|
||||
disconnect();
|
||||
}
|
||||
|
||||
// console.log(`🔌 [queueSync] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
|
||||
connect();
|
||||
startAutoSync();
|
||||
};
|
||||
|
||||
return {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket: disconnect,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
};
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
// Close existing connection if any, and CLEAN HANDLERS to prevent recursion
|
||||
if (ws.value) {
|
||||
if (ws.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log('🔌 Closing existing WebSocket before new connection...')
|
||||
// console.log('🔌 Closing existing WebSocket before new connection...')
|
||||
clearHandlers(ws.value)
|
||||
ws.value.close()
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
ws.value = new WebSocket(connectionUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('✅ WebSocket connected:', currentClientId.value)
|
||||
// console.log('✅ WebSocket connected:', currentClientId.value)
|
||||
isConnected.value = true
|
||||
reconnectAttempts.value = 0
|
||||
config.onOpen?.()
|
||||
@@ -91,7 +91,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
console.log('❌ WebSocket closed:', currentClientId.value)
|
||||
// console.log('❌ WebSocket closed:', currentClientId.value)
|
||||
isConnected.value = false
|
||||
config.onClose?.()
|
||||
|
||||
@@ -100,7 +100,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
|
||||
reconnectAttempts.value++
|
||||
const interval = config.reconnectInterval || 3000
|
||||
console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
|
||||
// console.log(`⏳ Reconnecting in ${interval}ms... Attempt ${reconnectAttempts.value}`)
|
||||
reconnectTimer.value = setTimeout(() => {
|
||||
connect()
|
||||
}, interval)
|
||||
@@ -125,7 +125,7 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
reconnectTimer.value = null
|
||||
}
|
||||
if (ws.value) {
|
||||
console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
|
||||
// console.log('🔌 Manual disconnect: Cleaning handlers and closing...')
|
||||
clearHandlers(ws.value)
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
|
||||
@@ -63,6 +63,56 @@ Format output:
|
||||
|
||||
<!-- Tambahkan entry baru di bawah baris ini, urutan terbaru di atas -->
|
||||
|
||||
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 2: WebSocket & Typings)
|
||||
|
||||
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
|
||||
**Durasi:** 1 jam
|
||||
**Status:** ✅ Done
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Membuat composable mandiri `composables/useQueueSync.ts` dan memindahkan lebih dari 200 baris logika *WebSocket* (`onWsMessage`, `connect`, `startAutoSync`) dari dalam *store*.
|
||||
- Menjalankan *scripting* untuk me-resolve sebagian besar error peringatan `implicit any type` dan `catch(error: any)` di dalam `queueStore.ts`.
|
||||
- Menerapkan injeksi dependencies dari `queueStore` ke dalam parameter `useQueueSync()` tanpa memutus *reactivity* dari Pinia state.
|
||||
|
||||
### Keputusan Teknis
|
||||
> **Dependency Injection pada Composable:** Karena fungsionalitas WebSocket membutuhkan akses terhadap state (`allPatients`, `currentProcessingPatient`) dan actions (`fetchPatientsForLoket`, dll), maka variabel-variabel tersebut dilempar (injected) melalui parameter `deps` ke dalam fungsi `useQueueSync(deps)`. Hal ini mencegah masalah *circular dependencies* di ekosistem Nuxt/Pinia.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Pinia-Plugin-PersistedState *type mismatch* pada `paths` config | Menambahkan flag `// @ts-ignore` untuk properti `paths` karena kompatibel secara *runtime* namun melanggar validasi tipe di rilis plugin yang terpasang. | QMD: Technical Debt |
|
||||
|
||||
### Besok
|
||||
- Menguji secara manual interaksi antar *terminal* (Anjungan, Loket, Klinik) untuk memverifikasi fungsionalitas `useQueueSync`.
|
||||
- Menulis Unit Tests (jika platform sudah siap).
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-23 — Refactoring queueStore.js ke TypeScript (Phase 1)
|
||||
|
||||
**Sprint/Phase:** Phase 6 (Verifikasi Akun & Kiosk) / Refactoring Technical Debt
|
||||
**Durasi:** 1 jam
|
||||
**Status:** 🔄 In Progress
|
||||
|
||||
### Yang Dikerjakan
|
||||
- Mengubah ekstensi file `queueStore.js` menjadi `queueStore.ts`.
|
||||
- Membuat file definisi tipe antrean di `types/queue.ts` (`QueuePatient`).
|
||||
- Mengekstrak raw fetch API calls (Loket, Visit, Update, Selesai Tiket) ke composable mandiri `composables/useQueueAPI.ts`.
|
||||
- Melakukan transisi pada fungsi-fungsi besar (`fetchPatientsForLoket`, `callNext`, `callMultiplePatients`, `processPatient`) di dalam `queueStore.ts` untuk menggunakan `useQueueAPI` guna memangkas boilerplate fetch.
|
||||
|
||||
### Keputusan Teknis
|
||||
> **Incremental Refactoring:** Menghindari pecahnya `queueStore` secara drastis dengan tetap mempertahankan satu *store instance* (`useQueueStore`) namun mengabstraksi beban logikanya keluar (*API* & *Sync layer*). Ini meminimalisasi *blast radius* / *breaking changes* ke komponen UI yang sudah jalan.
|
||||
|
||||
### Masalah & Solusi
|
||||
| Masalah | Solusi | Referensi |
|
||||
|---------|--------|-----------|
|
||||
| Pembengkakan ukuran *store file* (>3500 baris, 145KB). | Mulai memisahkan logika pemanggilan API eksternal dan mendefinisikan strukturnya dengan TypeScript. | QMD: Technical Debt |
|
||||
|
||||
### Besok
|
||||
- Membuat tipe yang strict untuk `state` dan seluruh *method signatures* di `queueStore.ts`.
|
||||
- Mengekstraksi fungsionalitas WebSocket ke `composables/useQueueSync.ts`.
|
||||
- Jika sudah ringan, memecah *store* menjadi `loketQueueStore` dan `clinicQueueStore`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-19 — Fitur Detail Akun Verifikasi
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = r'e:\antrean operasi\web-antrean\stores\queueStore.ts'
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 1. Add import for useQueueSync at the top
|
||||
if 'useQueueSync' not in content:
|
||||
content = content.replace("import { useQueueAPI } from '@/composables/useQueueAPI';", "import { useQueueAPI } from '@/composables/useQueueAPI';\nimport { useQueueSync } from '@/composables/useQueueSync';")
|
||||
|
||||
# 2. Remove the WEBSOCKET INTEGRATION block
|
||||
ws_pattern = re.compile(r' // WEBSOCKET INTEGRATION \(CENTRALIZED\).*? const disconnectWebSocket = \(\) => \{\n disconnect\(\);\n \};', re.DOTALL)
|
||||
|
||||
content = ws_pattern.sub(' // WS and AutoSync logic extracted to composables/useQueueSync.ts', content)
|
||||
|
||||
# 3. Add the initialization right before return
|
||||
init_code = """
|
||||
// Initialize Queue Sync
|
||||
const queueSync = useQueueSync({
|
||||
allPatients,
|
||||
currentProcessingPatient,
|
||||
activeLoketInterest,
|
||||
activeClinicInterest,
|
||||
globalInterestCount,
|
||||
fetchPatientsForLoket,
|
||||
fetchPatientsForClinic,
|
||||
fetchAllPatients
|
||||
});
|
||||
|
||||
const {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
} = queueSync;
|
||||
|
||||
return {
|
||||
"""
|
||||
|
||||
content = content.replace(" return {\n // State\n allPatients,", init_code + " // State\n allPatients,")
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
print("Success")
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
sys.exit(1)
|
||||
+14
-1
@@ -94,6 +94,14 @@ export const useDoctorStore = defineStore('doctor', () => {
|
||||
break; // Success!
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const status = err.response ? err.response.status : (err.status || 'unknown');
|
||||
|
||||
// Do not retry for 500 (Internal Server Error) or 404 (Not Found)
|
||||
// as these are likely permanent failures for this specific clinic
|
||||
if (status === 500 || status === 404) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (i < retries) {
|
||||
const delay = 500 * (i + 1);
|
||||
console.warn(`⚠️ [doctorStore] Retry ${i+1}/${retries} for klinik ${idklinik} after ${delay}ms...`);
|
||||
@@ -130,7 +138,12 @@ export const useDoctorStore = defineStore('doctor', () => {
|
||||
return doctorNames;
|
||||
} catch (error) {
|
||||
const status = error.response ? error.response.status : (error.status || 'unknown');
|
||||
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
|
||||
|
||||
if (status === 500 || status === 404) {
|
||||
console.warn(`⚠️ [doctorStore] Data dokter kosong/tidak ditemukan untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
|
||||
} else {
|
||||
console.error(`❌ [doctorStore] Gagal mengambil dokter untuk klinik ID ${idklinik} (${clinic.name}) - Status: ${status}`);
|
||||
}
|
||||
|
||||
// Track failure for blacklisting
|
||||
if (!failedClinics.value[idklinik]) {
|
||||
|
||||
@@ -2,43 +2,48 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useClinicStore } from './clinicStore';
|
||||
import { usePenunjangStore } from './penunjangStore';
|
||||
import { useLoketStore } from './loketStore';
|
||||
import { usePenunjangStore } from './penunjangStore';
|
||||
import { useWebSocket } from '@/composables/useWebSocket';
|
||||
import { useQueueAPI } from '@/composables/useQueueAPI';
|
||||
import { useQueueSync } from '@/composables/useQueueSync';
|
||||
import type { QueuePatient } from '@/types/queue';
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
const config = useRuntimeConfig();
|
||||
const clinicStore = useClinicStore();
|
||||
const penunjangStore = usePenunjangStore();
|
||||
const loketStore = useLoketStore();
|
||||
const queueAPI = useQueueAPI();
|
||||
|
||||
// ============================================
|
||||
// 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 allPatients = ref<QueuePatient[]>([]);
|
||||
const apiPatientsPerLoket = ref<Record<string, QueuePatient[]>>({});
|
||||
const isLoadingPatients = ref<boolean>(false);
|
||||
const apiPatientsError = ref<string | null>(null);
|
||||
const quotaUsed = ref<number>(5);
|
||||
const currentProcessingPatient = ref<Record<string, QueuePatient>>({});
|
||||
|
||||
const lastUpdated = ref(Date.now());
|
||||
const lastFetchTime = ref({});
|
||||
const lastGlobalFetchTime = ref(0); // Cooldown for bulk refreshes
|
||||
const lastUpdated = ref<number>(Date.now());
|
||||
const lastFetchTime = ref<Record<string, number>>({});
|
||||
const lastGlobalFetchTime = ref<number>(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 activeLoketInterest = ref<Record<string, number>>({}); // { [loketId]: count }
|
||||
const activeClinicInterest = ref<Record<string, number>>({}); // { [kodeKlinik]: count }
|
||||
const globalInterestCount = ref<number>(0); // Tracks pages that need ALL loket data (e.g. CheckInPasien)
|
||||
|
||||
const registerInterest = (loketId) => {
|
||||
const registerInterest = (loketId: string | number) => {
|
||||
if (!loketId) return;
|
||||
const id = String(loketId);
|
||||
activeLoketInterest.value[id] = (activeLoketInterest.value[id] || 0) + 1;
|
||||
};
|
||||
|
||||
const unregisterInterest = (loketId) => {
|
||||
const unregisterInterest = (loketId: string | number) => {
|
||||
if (!loketId) return;
|
||||
const id = String(loketId);
|
||||
if (activeLoketInterest.value[id]) {
|
||||
@@ -49,13 +54,13 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const registerClinicInterest = (kodeKlinik) => {
|
||||
const registerClinicInterest = (kodeKlinik: string) => {
|
||||
if (!kodeKlinik) return;
|
||||
const code = String(kodeKlinik);
|
||||
activeClinicInterest.value[code] = (activeClinicInterest.value[code] || 0) + 1;
|
||||
};
|
||||
|
||||
const unregisterClinicInterest = (kodeKlinik) => {
|
||||
const unregisterClinicInterest = (kodeKlinik: string) => {
|
||||
if (!kodeKlinik) return;
|
||||
const code = String(kodeKlinik);
|
||||
if (activeClinicInterest.value[code]) {
|
||||
@@ -74,7 +79,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
globalInterestCount.value = Math.max(0, globalInterestCount.value - 1);
|
||||
};
|
||||
|
||||
const fetchPatientsForClinic = async (kodeKlinik, force = false) => {
|
||||
const fetchPatientsForClinic = async (kodeKlinik: string, force: boolean = false) => {
|
||||
if (!kodeKlinik) return { success: false, message: 'Kode Klinik diperlukan' };
|
||||
|
||||
isLoadingPatients.value = true;
|
||||
@@ -86,7 +91,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const timeSinceLastFetch = now - lastFetch;
|
||||
|
||||
if (!force && timeSinceLastFetch < 2000) {
|
||||
console.log(`⏭️ [queueStore] Skipping fetch for clinic ${kodeKlinik} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
// 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' };
|
||||
}
|
||||
@@ -100,14 +105,9 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
throw new Error(`Klinik ID tidak ditemukan untuk kode: ${kodeKlinik}`);
|
||||
}
|
||||
|
||||
const url = `${config.public.externalApiBaseUrl}/visit?klinik_id=${clinic.id}&limit=500`;
|
||||
console.log(`🔄 [queueStore] Fetching patients for clinic ${kodeKlinik} (ID: ${clinic.id})...`);
|
||||
// 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 data = await queueAPI.fetchRawClinicPatients(clinic.id);
|
||||
|
||||
const mappedClinicPatients = [];
|
||||
data.forEach((visit, index) => {
|
||||
@@ -270,7 +270,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
syncCountersWithState();
|
||||
return { success: true, message: `${mappedClinicPatients.length} pasien dimuat` };
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error fetching clinic patients (${kodeKlinik}):`, error);
|
||||
apiPatientsError.value = error.message;
|
||||
return { success: false, message: error.message };
|
||||
@@ -280,251 +280,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 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;
|
||||
|
||||
// Handle Call Events and WS messages
|
||||
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) {
|
||||
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) {
|
||||
fetchPatientsForLoket(targetLoketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
if (targetKlinikId) {
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
if (interestingClinics.includes(String(targetKlinikId)) || targetKlinikId === 'broadcast') {
|
||||
const clinicToFetch = targetKlinikId === 'broadcast' ? interestingClinics[0] : targetKlinikId;
|
||||
fetchPatientsForClinic(clinicToFetch, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (globalInterestCount.value > 0) {
|
||||
fetchAllPatients();
|
||||
refreshedSomething = true;
|
||||
}
|
||||
|
||||
// ALWAYS refresh our own active interests when a WebSocket message is received,
|
||||
// because shared lists (e.g. unassigned patients in 'menunggu') might have changed.
|
||||
const interestingLokets = Object.keys(activeLoketInterest.value);
|
||||
const interestingClinics = Object.keys(activeClinicInterest.value);
|
||||
|
||||
if (interestingLokets.length > 0) {
|
||||
interestingLokets.forEach(loketId => {
|
||||
if (String(loketId) !== String(targetLoketId)) {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
refreshedSomething = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (interestingClinics.length > 0) {
|
||||
interestingClinics.forEach(kodeKlinik => {
|
||||
if (String(kodeKlinik) !== String(targetKlinikId)) {
|
||||
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: `${config.public.externalApiBaseUrl}/ws`,
|
||||
reconnectInterval: 2000, // 2 seconds between reconnect attempts
|
||||
maxReconnectAttempts: 9999, // Effectively infinite — never give up on remote machines
|
||||
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
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// STORE-LEVEL AUTO-POLLING (cross-device sync fallback)
|
||||
// ============================================
|
||||
// Runs on every browser instance every 30 seconds.
|
||||
// Fetches data based on whatever active interests are registered
|
||||
// (lokets, clinics, or global). This ensures any device always
|
||||
// has fresh data regardless of WS delivery reliability.
|
||||
let _autoSyncInterval = null;
|
||||
|
||||
const startAutoSync = () => {
|
||||
// Guard: only run on client, and only start once
|
||||
if (typeof window === 'undefined') return;
|
||||
if (_autoSyncInterval) return; // Already running
|
||||
|
||||
console.log('🔄 [queueStore] Starting store-level auto-sync (30s interval)');
|
||||
|
||||
_autoSyncInterval = setInterval(async () => {
|
||||
const hasLoketInterest = Object.keys(activeLoketInterest.value).length > 0;
|
||||
const hasClinicInterest = Object.keys(activeClinicInterest.value).length > 0;
|
||||
const hasGlobalInterest = globalInterestCount.value > 0;
|
||||
|
||||
if (hasGlobalInterest) {
|
||||
fetchAllPatients();
|
||||
} else {
|
||||
if (hasLoketInterest) {
|
||||
Object.keys(activeLoketInterest.value).forEach(loketId => {
|
||||
fetchPatientsForLoket(loketId, true);
|
||||
});
|
||||
}
|
||||
if (hasClinicInterest) {
|
||||
Object.keys(activeClinicInterest.value).forEach(kodeKlinik => {
|
||||
fetchPatientsForClinic(kodeKlinik, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
};
|
||||
|
||||
const stopAutoSync = () => {
|
||||
if (_autoSyncInterval) {
|
||||
clearInterval(_autoSyncInterval);
|
||||
_autoSyncInterval = null;
|
||||
console.log('⏹️ [queueStore] Store-level auto-sync stopped');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize Global WebSocket
|
||||
*/
|
||||
const initWebSocket = (customClientId = null) => {
|
||||
if (isConnected.value && customClientId === wsClientId.value) {
|
||||
console.log('🔌 [queueStore] WebSocket already connected with same ID.');
|
||||
// Auto-sync should still start even if WS is already connected
|
||||
startAutoSync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (customClientId) {
|
||||
wsClientId.value = customClientId;
|
||||
// Re-connect with new ID if changed
|
||||
disconnect();
|
||||
}
|
||||
|
||||
console.log(`🔌 [queueStore] Connecting to WebSocket: ${wsBaseUrl} as ${wsClientId.value}`);
|
||||
connect();
|
||||
|
||||
// Start store-level auto-polling if not already running (client-side only).
|
||||
// This guarantees cross-device sync even when WS messages are missed.
|
||||
startAutoSync();
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect Global WebSocket
|
||||
*/
|
||||
const disconnectWebSocket = () => {
|
||||
disconnect();
|
||||
isWsConnected.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync patient status to apiPatientsPerLoket for reactivity
|
||||
@@ -602,12 +357,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
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',
|
||||
"30": 'pending', "31": 'terlambat', "32": 'pending', "33": 'terlambat'
|
||||
33: 'terlambat' // TR PEMERIKSAAN
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -742,7 +492,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
/**
|
||||
* Fetch patient data untuk loket tertentu dari API
|
||||
*/
|
||||
const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const fetchPatientsForLoket = async (loketId: string | number, force: boolean = false) => {
|
||||
if (!loketId) {
|
||||
console.error('loketId required for fetchPatientsForLoket');
|
||||
return { success: false, message: 'ID Loket diperlukan' };
|
||||
@@ -757,7 +507,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
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)`);
|
||||
// console.log(`⏭️ [queueStore] Skipping fetch for loket ${loketId} (last fetched ${Math.round(timeSinceLastFetch/1000)}s ago)`);
|
||||
isLoadingPatients.value = false;
|
||||
return {
|
||||
success: true,
|
||||
@@ -773,22 +523,8 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
checkAndResetDaily();
|
||||
|
||||
try {
|
||||
console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/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 || [];
|
||||
// console.log(`🔄 [queueStore] Fetching patients for loket ${loketId}...`);
|
||||
const patientsRaw = await queueAPI.fetchRawLoketPatients(loketId);
|
||||
|
||||
// Fetch temporary subspesialis mapping
|
||||
let subspesialisMap = {};
|
||||
@@ -797,7 +533,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
if (subRes.ok) {
|
||||
subspesialisMap = await subRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to fetch temporary subspesialis mapping', e);
|
||||
}
|
||||
|
||||
@@ -999,7 +735,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// 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) => {
|
||||
allPatients.value.forEach((patient: any, index: number) => {
|
||||
if (patient.barcode) {
|
||||
const storageKey = `patient-status-${patient.barcode}`;
|
||||
const savedData = localStorage.getItem(storageKey);
|
||||
@@ -1021,7 +757,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
// Clean up old data
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Error parsing LocalStorage data:', e);
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
@@ -1041,7 +777,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
data: mappedPatients
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error fetching patients for loket ${loketId}:`, error);
|
||||
apiPatientsError.value = error.message;
|
||||
|
||||
@@ -1066,7 +802,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
* Global fetcher for all patients across all available lokets
|
||||
* Uses staggered fetching to prevent 429 Too Many Requests errors.
|
||||
*/
|
||||
const fetchAllPatients = async (force = false) => {
|
||||
const fetchAllPatients = async (force: boolean = false) => {
|
||||
// 1. Cooldown Check: Prevent global refresh spam (max once every 5 seconds)
|
||||
const now = Date.now();
|
||||
if (!force && now - lastGlobalFetchTime.value < 5000) {
|
||||
@@ -1075,7 +811,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}
|
||||
lastGlobalFetchTime.value = now;
|
||||
|
||||
console.log('🔄 [queueStore] Fetching all patients for all lokets (Staggered)...');
|
||||
// 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');
|
||||
@@ -1129,7 +865,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
/**
|
||||
* Get patients for a specific loket (from API or seed data based on loket type)
|
||||
*/
|
||||
const getPatientsForLoket = (loketId) => {
|
||||
const getPatientsForLoket = (loketId: string | number) => {
|
||||
return computed(() => {
|
||||
const loket = loketStore.getLoketById(parseInt(loketId));
|
||||
|
||||
@@ -1527,7 +1263,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
/**
|
||||
* Filter strictly to only show today's patients (after 2 AM)
|
||||
*/
|
||||
const isTodayPatient = (patient) => {
|
||||
const isTodayPatient = (patient: any) => {
|
||||
if (!patient) return false;
|
||||
|
||||
// Status processing overrides filter (always show if currently processing)
|
||||
@@ -1617,7 +1353,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
setTimeout(() => { isSyncing = false; }, 50);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Error hydrating from storage event:', e);
|
||||
isSyncing = false;
|
||||
}
|
||||
@@ -1670,7 +1406,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const resetPatients = () => {
|
||||
allPatients.value = cloneSeed();
|
||||
quotaUsed.value = 5;
|
||||
currentProcessingPatient.value = { loket: null, klinik: null, penunjang: null };
|
||||
currentProcessingPatient.value = {};
|
||||
syncCountersWithState(); // Re-initialize counters after reset
|
||||
};
|
||||
|
||||
@@ -1815,20 +1551,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient is called
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
fetch(`${apiBase}/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 => {
|
||||
queueAPI.updateTicketStatus(
|
||||
nextPatient.barcode || "",
|
||||
"3",
|
||||
"4",
|
||||
"1",
|
||||
"1"
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
console.log(`✅ Successfully posted status update for patient ${nextPatient.barcode}`);
|
||||
} else {
|
||||
@@ -1837,7 +1566,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ Error posting status update for patient ${nextPatient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error initiating status update for patient ${nextPatient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -1909,7 +1638,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
const patientsToCall = menungguList.slice(0, maxCallable);
|
||||
const callTimestamp = new Date().toISOString();
|
||||
|
||||
patientsToCall.forEach(async (patient) => {
|
||||
patientsToCall.forEach(async (patient: any) => {
|
||||
const index = allPatients.value.findIndex(p => p.no === patient.no);
|
||||
if (index !== -1) {
|
||||
const newStatus = "anjungan";
|
||||
@@ -1927,27 +1656,20 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient is called
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
const response = await fetch(`${apiBase}/tiket/update`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
barcode: patient.barcode || "",
|
||||
statuspasien: "3",
|
||||
statuspasien2: "4",
|
||||
idklinikstatus: "1",
|
||||
idklinikstatus2: "1"
|
||||
})
|
||||
});
|
||||
const response = await queueAPI.updateTicketStatus(
|
||||
patient.barcode || "",
|
||||
"3",
|
||||
"4",
|
||||
"1",
|
||||
"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) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Error posting status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -1986,19 +1708,12 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// POST to external API when patient finishes at loket
|
||||
try {
|
||||
const apiBase = config.public.verificationApiBaseUrl || 'http://10.10.123.140:8089/api/v1';
|
||||
fetch(`${apiBase}/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 => {
|
||||
queueAPI.completeTicketStatus(
|
||||
String(patient.loketId || specificId || ""),
|
||||
patient.barcode || "",
|
||||
"9",
|
||||
"2"
|
||||
).then(response => {
|
||||
if (response.ok) {
|
||||
console.log(`✅ [queueStore] Successfully posted selesai status for patient ${patient.barcode}`);
|
||||
} else {
|
||||
@@ -2007,7 +1722,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error posting selesai status for patient ${patient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating selesai status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -2088,7 +1803,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
console.error(`❌ [TERLAMBAT] API rejected request:`, responseData);
|
||||
message = `Gagal: ${responseData.message || 'API error'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [TERLAMBAT] Error:`, error);
|
||||
message = `Error: ${error.message}`;
|
||||
}
|
||||
@@ -2145,7 +1860,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
console.error(`❌ [PENDING] API rejected request:`, responseData);
|
||||
message = `Gagal: ${responseData.message || 'API error'}`;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [PENDING] Error:`, error);
|
||||
message = `Error: ${error.message}`;
|
||||
}
|
||||
@@ -2188,7 +1903,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error activating patient ${patient.barcode}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating activation for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
} else {
|
||||
@@ -2253,7 +1968,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
}).catch(error => {
|
||||
console.error(`❌ [queueStore] Error updating patient ${patient.barcode} to sedang diproses:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [queueStore] Error initiating status update for patient ${patient.barcode}:`, error);
|
||||
}
|
||||
} else {
|
||||
@@ -2483,7 +2198,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
|
||||
// Pindah pasien ke klinik ruang lain dengan nomor antrian tetap
|
||||
const pindahKlinikRuang = (patient, targetKlinikRuang, targetRuang) => {
|
||||
|
||||
const patientIndex = allPatients.value.findIndex(p => p.no === patient.no);
|
||||
if (patientIndex === -1) {
|
||||
return { success: false, message: "Pasien tidak ditemukan" };
|
||||
}
|
||||
@@ -2820,7 +2535,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
} else {
|
||||
console.log('✅ [queueStore] Successfully finished patient via API');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling finish API:', error);
|
||||
// Continue with local status update even if API fails
|
||||
}
|
||||
@@ -2846,7 +2561,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling terlambat API:', error);
|
||||
}
|
||||
|
||||
@@ -2870,7 +2585,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error calling pending API:', error);
|
||||
}
|
||||
|
||||
@@ -3238,7 +2953,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
subspesialis: subSpesialis
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
console.error('Failed to save temporary subSpesialis mapping', e);
|
||||
}
|
||||
}
|
||||
@@ -3250,7 +2965,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
patient: newPatient
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error generating ticket via API:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -3292,7 +3007,7 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
data: result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('❌ [queueStore] Error syncing check-in via API:', error);
|
||||
return {
|
||||
success: false,
|
||||
@@ -3422,6 +3137,31 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
return { success: false, message: "Gagal memproses antrean." };
|
||||
};
|
||||
|
||||
|
||||
// Initialize Queue Sync
|
||||
const queueSync = useQueueSync({
|
||||
allPatients,
|
||||
currentProcessingPatient,
|
||||
activeLoketInterest,
|
||||
activeClinicInterest,
|
||||
globalInterestCount,
|
||||
fetchPatientsForLoket,
|
||||
fetchPatientsForClinic,
|
||||
fetchAllPatients
|
||||
});
|
||||
|
||||
const {
|
||||
isWsConnected,
|
||||
wsClientId,
|
||||
lastGlobalCall,
|
||||
lastKlinikCall,
|
||||
initWebSocket,
|
||||
disconnectWebSocket,
|
||||
sendViaPost,
|
||||
startAutoSync,
|
||||
stopAutoSync
|
||||
} = queueSync;
|
||||
|
||||
return {
|
||||
// State
|
||||
allPatients,
|
||||
@@ -3504,12 +3244,13 @@ const fetchPatientsForLoket = async (loketId, force = false) => {
|
||||
persist: {
|
||||
key: 'queue-store-state',
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
// @ts-ignore - plugin version mismatch
|
||||
paths: ['quotaUsed', 'lastUpdated'],
|
||||
serializer: {
|
||||
deserialize: JSON.parse,
|
||||
serialize: JSON.stringify,
|
||||
},
|
||||
restore: (value) => {
|
||||
restore: (value: any) => {
|
||||
// Ensure allPatients is always an array
|
||||
if (value && value.allPatients && !Array.isArray(value.allPatients)) {
|
||||
value.allPatients = [];
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface QueuePatient {
|
||||
no: number;
|
||||
barcode: string;
|
||||
noAntrian: string;
|
||||
jamPanggil: string;
|
||||
klinik: string;
|
||||
kodeKlinik: string;
|
||||
klinikId?: number;
|
||||
healthcareServiceId?: number;
|
||||
ruang?: string;
|
||||
nomorRuang?: string;
|
||||
kodeRuang?: string;
|
||||
pembayaran?: string;
|
||||
status: 'menunggu' | 'di-loket' | 'anjungan' | 'pemeriksaan' | 'pending' | 'terlambat' | 'selesai' | 'skip' | 'processed';
|
||||
processStage: 'loket' | 'klinik-ruang';
|
||||
createdAt: string;
|
||||
visitType?: string;
|
||||
noRM?: string;
|
||||
fastTrack?: "YA" | "TIDAK";
|
||||
registrationType?: 'api' | 'manual';
|
||||
visitId?: number;
|
||||
visitCode?: string;
|
||||
referencePatient?: string;
|
||||
loketId?: number;
|
||||
calledByAdmin?: boolean;
|
||||
lastCalledAt?: string;
|
||||
lastCalledTipeLayanan?: string;
|
||||
calledPemeriksaanAwal?: boolean;
|
||||
calledTindakan?: boolean;
|
||||
tipeLayanan?: string;
|
||||
idtiket?: string;
|
||||
ticket?: string;
|
||||
posisi?: any[];
|
||||
deskripsi?: string;
|
||||
manuallyMoved?: boolean;
|
||||
movedAt?: number;
|
||||
idvisit?: number;
|
||||
visitDate?: string;
|
||||
namaDokter?: string | null;
|
||||
penanggungJawab?: string | null;
|
||||
alasanFastTrack?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiPatientResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: QueuePatient[];
|
||||
}
|
||||
Reference in New Issue
Block a user