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