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