diff --git a/.env example b/.env example index 73ad85a..502b1ec 100644 --- a/.env example +++ b/.env example @@ -64,3 +64,13 @@ POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage" # ATAU untuk redirect dengan query parameter: # POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success" # POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success" + + +# ATAU untuk redirect dengan query parameter: +# POST_LOGOUT_REDIRECT_URI="https://antrean.rssa.id/LoginPage?logout=success" +# POST_LOGOUT_REDIRECT_URI="https://antrean.dev.rssa.id/LoginPage?logout=success" + +# External API Configuration +# API eksternal untuk validasi JWT token +EXTERNAL_API_BASE_URL="http://10.10.150.100:8084" +EXTERNAL_API_TIMEOUT=10000 diff --git a/components/features/queue/PatientCard.vue b/components/features/queue/PatientCard.vue index 732b71d..3ccae8e 100644 --- a/components/features/queue/PatientCard.vue +++ b/components/features/queue/PatientCard.vue @@ -177,10 +177,10 @@ const handleCardClick = () => { const getStatusColor = (status) => { const colors = { - 'di-loket': "var(--color-secondary-600)", - diproses: "var(--color-primary-600)", - terlambat: "var(--color-primary-600)", - pending: "var(--color-danger-600)" + 'di-loket': "var(--color-primary-600)", // Blue + diproses: "var(--color-primary-600)", // Keep as Blue + terlambat: "var(--color-danger-700)", // Darker Red + pending: "var(--color-secondary-600)" // Orange }; return colors[status] || "var(--color-neutral-600)"; }; diff --git a/debug_visit.json b/debug_visit.json new file mode 100644 index 0000000..5f38114 --- /dev/null +++ b/debug_visit.json @@ -0,0 +1 @@ +{"message":"Data visit berhasil diambil","data":[{"id":446,"visit_code":26021300007,"registration_datetime":"2026-02-13T03:40:42Z","active":true,"healthcare_services":[{"id":528,"ticket":"RN007","check_in":true,"check_in_datetime":"2026-02-13T03:41:04Z","datetime_start":"2026-02-13T03:40:42Z","healtcare_service_code":"RN","active":true,"fk_ms_healthcare_service_id":11,"healthcare_service_name":"ANAK","shift_number":1,"available_start_time":"07:00:00","fk_ref_payment_type_id":2,"payment_type_name":"JKN","fk_ms_registration_counter_id":14,"registration_counter_name":"LOKET 14 REG","visit_statuses":[{"id":2174,"fk_ref_visit_status_id":3,"datetime":"2026-02-13T03:40:49.674718Z","visit_status_name":"PG ANJUNGAN","visit_status_description":"Panggilan loket ke Anjungan"},{"id":2182,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.423472Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2177,"fk_ref_visit_status_id":6,"datetime":"2026-02-13T03:41:04.275703Z","visit_status_name":"RT PENDAFTARAN","visit_status_description":"Tunggu Panggilan Loket"},{"id":2175,"fk_ref_visit_status_id":4,"datetime":"2026-02-13T03:40:49.676542Z","visit_status_name":"RT CHECK-IN","visit_status_description":"Tunggu Pasien Check-In"},{"id":2173,"fk_ref_visit_status_id":2,"datetime":"2026-02-13T03:40:42.946501Z","visit_status_name":"RT ANJUNGAN","visit_status_description":"Ruang Tunggu Anjungan"},{"id":2172,"fk_ref_visit_status_id":1,"datetime":"2026-02-13T03:40:42.944133Z","visit_status_name":"CT ANJUNGAN","visit_status_description":"Cetak Tiket Antrian"},{"id":2184,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:20.080008Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"},{"id":2183,"fk_ref_visit_status_id":28,"datetime":"2026-02-13T03:47:18.425723Z","visit_status_name":"PE PENDAFTARAN","visit_status_description":"Pending Pendaftaran"},{"id":2176,"fk_ref_visit_status_id":5,"datetime":"2026-02-13T03:41:04.274554Z","visit_status_name":"PS CHECK-IN","visit_status_description":"Pasien Sudah Check-In"},{"id":2181,"fk_ref_visit_status_id":8,"datetime":"2026-02-13T03:47:14.538472Z","visit_status_name":"PO PENDAFTARAN","visit_status_description":"Proses Pendaftaran"}]}]}],"meta":{"limit":10,"offset":0,"total":2139,"total_pages":214,"current_page":1,"has_next":true,"has_prev":false}} \ No newline at end of file diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue index 0c3fe2d..5ffcdf4 100644 --- a/pages/AdminKlinikRuang/[kodeKlinik].vue +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -113,7 +113,7 @@ @click="handleCallPatientByTipe(ruang, 'Tindakan')" > mdi-bullhorn - Panggil Tindakan + Panggil Pemeriksaan @@ -254,7 +254,7 @@ class="status-chip status-chip-tindakan" > mdi-check-circle - Tindakan + Pemeriksaan @@ -568,137 +568,137 @@ - - - - Detail Pasien + + + + + Detail Pasien mdi-close - - - - -
- Nomor Antrian - {{ selectedPatientDetail.noAntrian?.split(" |")[0] || '-' }} -
-
- -
- Barcode - {{ selectedPatientDetail.barcode || '-' }} -
-
- -
- No. RM - {{ selectedPatientDetail.noRM || '-' }} -
-
-
- - -
- Klinik - {{ selectedPatientDetail.klinik || '-' }} -
-
- -
- Ruang - {{ selectedPatientDetail.ruang || '-' }} -
-
-
- - -
- Jam Panggil - {{ selectedPatientDetail.jamPanggil || '-' }} -
-
- -
- Pembayaran - {{ selectedPatientDetail.pembayaran || '-' }} -
-
-
- - -
- Layanan -
- - Pemeriksaan Awal - - - Tindakan - + + + +
+
NOMOR ANTRIAN
+
{{ selectedPatientDetail.noAntrian?.split(" |")[0] || '-' }}
+
+ + {{ getStatusLabel(selectedPatientDetail.status) }} + + + {{ selectedPatientDetail.fastTrack === 'YA' ? 'FAST TRACK' : 'REGULER' }} + +
+
+ + + + +
+
+ +
+
No. Rekam Medis
+
{{ selectedPatientDetail.noRM || '-' }}
+
+
+
Jenis Pembayaran
+
{{ selectedPatientDetail.pembayaran || '-' }}
+
+ + +
+
Barcode / NIK
+
{{ selectedPatientDetail.barcode || '-' }}
+
+
+
Jam Panggil
+
+ {{ selectedPatientDetail.jamPanggil ? (selectedPatientDetail.jamPanggil.includes('T') ? selectedPatientDetail.jamPanggil.split('T')[1].substring(0, 5) : selectedPatientDetail.jamPanggil.substring(0, 5)) : '-' }} +
+
+ + +
+
Nomor Antrian Loket
+
+ mdi-link + {{ selectedPatientDetail.referencePatient }} +
+
+
+ +
+ + +
+ + +
+ +
+
+
+ mdi-monitor-dashboard +
+
+
Pemeriksaan Awal
+
+ {{ selectedPatientDetail.calledPemeriksaanAwal ? 'Sudah Dipanggil' : 'Belum Dipanggil' }} +
+
+ mdi-check-circle +
+ +
+
+ mdi-stethoscope +
+
+
Pemeriksaan Dokter
+
+ {{ selectedPatientDetail.calledTindakan ? 'Sudah Dipanggil' : 'Belum Dipanggil' }} +
+
+ mdi-check-circle
- - -
- Status - - {{ getStatusLabel(selectedPatientDetail.status) }} - -
-
- - - -
- Fast Track - - {{ selectedPatientDetail.fastTrack === 'YA' ? 'Ya' : 'Tidak' }} - -
-
-
- - -
- Nomor Antrean Anjungan Sebelumnya - {{ selectedPatientDetail.referencePatient }} -
-
-
+
+ + +
- - + + - mdi-close Tutup @@ -996,7 +996,13 @@ const getAllPatientsForRoom = (ruang) => { const statusPriority = { 'processed': 4, 'diproses': 3, 'pemeriksaan': 2, 'di-loket': 1 }; patients.forEach(p => { - const key = p.visitId || p.barcode || p.no; + // FIXED: Prioritaskan No Antrian (stripped) sebagai key utama karena paling konsisten + // Jika visitId ada di satu data dan tidak di yang lain, mereka tidak akan ter-merge jika visitId jadi prioritas utama + const noAntrianClean = p.noAntrian ? p.noAntrian.split(" |")[0].trim() : null; + const key = noAntrianClean || p.visitId || p.scanCode || p.barcode || p.no; + + if (!key) return; + const existing = deduplicatedMap.get(key); if (!existing) { @@ -1005,8 +1011,14 @@ const getAllPatientsForRoom = (ruang) => { const pPrio = statusPriority[p.status] || 0; const ePrio = statusPriority[existing.status] || 0; + // Jika status lebih tinggi, atau status sama tapi data lebih lengkap (misal ada visitId) if (pPrio > ePrio) { - deduplicatedMap.set(key, p); + deduplicatedMap.set(key, p); + } else if (pPrio === ePrio) { + // Prefer one with visitId if available + if (p.visitId && !existing.visitId) { + deduplicatedMap.set(key, p); + } } } }); @@ -1562,9 +1574,9 @@ const toggleLayananStatus = (tipeLayanan) => { if (patient.tipeLayanan === 'Tindakan') { updateData.tipeLayanan = null; } - showSnackbar('Status panggilan Tindakan dibatalkan', 'info'); + showSnackbar('Status panggilan Pemeriksaan dibatalkan', 'info'); } else { - showSnackbar('Tindakan belum dipanggil', 'warning'); + showSnackbar('Pemeriksaan belum dipanggil', 'warning'); return; } } @@ -1898,7 +1910,14 @@ onMounted(async () => { // 4. Lifecycle cleanup (Keep outside async block to preserve context) let pollInterval; onMounted(() => { - pollInterval = setInterval(fetchAllData, 60000); // Reduce fallback polling to 60s + // Check for daily reset and poll data every minute + pollInterval = setInterval(() => { + const didReset = queueStore.checkAndResetDaily(); + if (didReset) { + console.log("🕒 [AdminKlinikRuang] 2 AM threshold reached. Data reset performed."); + } + fetchAllData(); + }, 60000); }); onUnmounted(() => { @@ -3341,5 +3360,195 @@ onUnmounted(() => { font-size: 11px !important; } } + +/* Patient Detail Redesign Styles */ +.patient-detail-card { + border-radius: 16px !important; + overflow: hidden; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.patient-hero-section { + background: linear-gradient(135deg, var(--color-primary-50) 0%, #fff 100%); + padding: 24px 20px; + text-align: center; + border-bottom: 1px solid var(--color-neutral-300); +} + +.hero-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--color-neutral-700); + margin-bottom: 4px; + font-weight: 700; +} + +.hero-number { + font-size: 42px; + line-height: 1.2; + font-weight: 800; + color: var(--color-neutral-900); + margin-bottom: 8px; + letter-spacing: -1px; +} + +.status-chip-hero { + font-weight: 600 !important; + font-size: 13px !important; + padding: 0 12px !important; + height: 28px !important; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.fast-track-banner { + background: linear-gradient(90deg, #d32f2f, #e53935); + color: white; + padding: 10px 16px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(211, 47, 47, 0.2); + font-size: 14px; + letter-spacing: 0.5px; +} + +.info-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 24px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.info-item.text-right { + text-align: right; + align-items: flex-end; +} + +.info-item.full-width { + grid-column: 1 / -1; + text-align: left; + align-items: flex-start; +} + +.info-label { + font-size: 12px; + color: var(--color-neutral-600); + font-weight: 600; +} + +.info-value { + font-size: 15px; + color: var(--color-neutral-900); + font-weight: 700; +} + +.section-label-small { + font-size: 11px; + color: var(--color-neutral-600); + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.service-actions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.service-action-card { + border: 1px solid var(--color-neutral-300); + border-radius: 12px; + padding: 16px; + cursor: pointer; + background: var(--color-neutral-50); + transition: all 0.2s ease; + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; +} + +.service-action-card:hover { + background: white; + border-color: var(--color-primary-300); + box-shadow: 0 4px 12px rgba(0,0,0,0.05); + transform: translateY(-2px); +} + +.service-action-card.active { + background: var(--color-primary-50); + border-color: var(--color-primary-500); +} + +.service-action-card.active:nth-child(2) { + background: var(--color-secondary-50); + border-color: var(--color-secondary-500); +} + +.action-icon-wrapper { + width: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + background: white; + border: 1px solid rgba(0,0,0,0.05); +} + +.service-action-card.active .action-icon-wrapper { + background: white; +} + +.color-primary { color: var(--color-primary-600); } +.color-secondary { color: var(--color-secondary-600); } + +.action-content { + flex: 1; +} + +.action-title { + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-900); + margin-bottom: 2px; + line-height: 1.3; +} + +.action-status { + font-size: 11px; + color: var(--color-neutral-500); +} + +.service-action-card.active .action-title { + color: var(--color-primary-800); +} + +.service-action-card.active:nth-child(2) .action-title { + color: var(--color-secondary-800); +} + +.check-icon { + position: absolute; + top: 12px; + right: 12px; +} + +.btn-close-large { + text-transform: none; + font-size: 15px; + font-weight: 600; + letter-spacing: 0; +} diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index 7d0c155..c560d94 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -789,10 +789,19 @@ const handleCall = async (count) => { await nextTick(); }; -const handleTableAction = (item, action) => { - processPatient(item, action); +const handleTableAction = async (item, action) => { + await processPatient(item, action); + // Real-time broadcast after process broadcastUpdate(); + + // If action is process, auto-call the patient after a short delay + if (action === "proses") { + // Small delay to ensure state updates + setTimeout(() => { + handleCallPatient(); + }, 300); + } }; const handleProcessNext = () => { @@ -803,23 +812,37 @@ const handleProcessNext = () => { const { sendViaPost } = useWebSocket(); -const broadcastUpdate = async () => { +const broadcastUpdate = async (callData = null) => { try { const displayClientIds = []; // Base broadcast IDs for this loket displayClientIds.push(`anjungan-loket-${loketId.value}`); displayClientIds.push(`anjungan-masuk-${loketId.value}`); + // Also broadcast to clinic specific displays if needed + // displayClientIds.push(`anjungan-klinik-${...}`); console.log('📡 [AdminLoket] Broadcasting update trigger to:', displayClientIds); + const payload = { + loketId: loketId.value, + triggerRefresh: true + }; + + // If this is a CALL event, include the patient data + if (callData) { + payload.callEvent = { + ...callData, + loketId: loketId.value, + loketName: loketName.value, + calledAt: Date.now() + }; + } + for (const clientId of displayClientIds) { await sendViaPost({ to_client: clientId, - data: { - loketId: loketId.value, - triggerRefresh: true - } + data: payload }); } } catch (error) { @@ -835,8 +858,9 @@ const handleCallPatient = () => { snackbarColor.value = "success"; snackbar.value = true; - // WebSocket Broadcast for remote displays - broadcastUpdate(); + // WebSocket Broadcast for remote displays WITH PATIENT DATA + // This allows Anjungan to show popup and play sound even on different device + broadcastUpdate(JSON.parse(JSON.stringify(currentProcessingPatient.value))); // local BROADCAST CALL EVENT for same-browser tabs if (broadcastChannel) { diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index 9c4aebf..ae161ca 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -81,7 +81,7 @@
-
TINDAKAN
+
PEMERIKSAAN
SEKARANG
@@ -360,8 +360,22 @@ const displayedRuang = computed(() => { const currentCalledQueue = computed(() => { if (!kodeKlinik.value || !klinikData.value) return null + 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. + const _ = currentTime.value + // Get most recently called patient (status di-loket) - // Sort by lastCalledAt if available, otherwise by createdAt + // Sort by lastCalledAt const calledQueues = klinikPatients.value .filter(p => p.status === 'di-loket') .sort((a, b) => { @@ -372,9 +386,16 @@ const currentCalledQueue = computed(() => { if (calledQueues.length > 0) { const patient = calledQueues[0] - return { - ...patient, - ruang: patient.ruang || patient.namaRuang || 'Ruang 1' + + // Check if within duration + const callTime = patient.lastCalledAt ? new Date(patient.lastCalledAt) : new Date(patient.createdAt || 0) + const timeDiff = now.getTime() - callTime.getTime() + + if (timeDiff <= CALL_DISPLAY_DURATION) { + return { + ...patient, + ruang: patient.ruang || patient.namaRuang || 'Ruang 1' + } } } @@ -1187,5 +1208,200 @@ watch(anjunganClientId, (newClientId, oldClientId) => { .clinic-box.is-single-view .v-icon { transform: scale(1.5); /* Scale up icons */ } + +/* Responsive adjustment for short screens (laptops/small monitors) */ +/* Responsive adjustment for short screens (laptops/small monitors) */ +@media (max-height: 900px) { + .hero-call-section { + padding: 12px; + margin-bottom: 8px; + max-height: 25vh; /* Reduced from 35vh */ + } + + .call-label { + font-size: 18px; /* Reduced from 20px */ + margin-bottom: 4px; + } + + .call-number { + font-size: 64px; /* Reduced from 80px */ + margin: 4px 0; + } + + .call-clinic { + font-size: 20px; /* Reduced from 24px */ + } + + .clinic-box { + min-height: 140px; /* Reduced from 180px */ + border-width: 1.5px; + } + + .clinic-header-bar { + padding: 8px 12px; + } + + .clinic-title { + font-size: 14px; + } + + .clinic-content { + padding: 8px 12px; + } + + .tipe-label { + font-size: 11px; + padding: 2px 6px; + margin-bottom: 2px; + } + + .current-label { + font-size: 10px; + margin-bottom: 0; + } + + .current-number { + font-size: clamp(32px, 2.5vw, 48px); /* Significantly reduced */ + letter-spacing: 2px; + } + + .empty-state-small { + padding: 4px 0; + } + + .empty-text-small { + font-size: 10px; + margin-top: 2px; + } + + /* Hide "Tidak Ada" text to save space */ + .empty-text-small { + display: none; + } +} + +@media (max-height: 768px) { + .antrian-display-container { + padding: 8px; + } + + .display-header { + padding: 8px 12px; + margin-bottom: 4px; + } + + .logo-circle { + width: 48px; + height: 48px; + } + + .header-logo { + width: 36px; + height: 36px; + } + + .hospital-name { + font-size: 20px; + } + + .time-large { + font-size: 32px; + } + + .date-small { + font-size: 12px; + margin-top: 2px; + } + + .hero-call-section { + padding: 8px; + margin-bottom: 4px; + max-height: 20vh; /* Further reduced */ + border-radius: 12px; + } + + .call-label { + font-size: 14px; + margin-bottom: 2px; + } + + .call-number { + font-size: 48px; + margin: 2px 0; + } + + .call-clinic { + font-size: 16px; + } + + .clinics-grid { + gap: 6px; + margin-bottom: 4px; + } + + .clinic-box { + min-height: 120px; /* Further reduced */ + border-radius: 10px; + border-width: 1px; + } + + .clinic-header-bar { + padding: 6px 10px; + } + + .clinic-count { + height: 20px !important; + } + + .clinic-title { + font-size: 12px; + } + + .clinic-content { + padding: 4px 8px; + } + + .tipe-label { + font-size: 10px; + padding: 2px 4px; + margin-bottom: 2px; + border-radius: 4px; + } + + .current-label { + font-size: 9px; + margin-bottom: 0; + } + + .current-number { + font-size: 28px; + letter-spacing: 1px; + } + + .footer-stats-bar { + padding: 6px 12px; + gap: 12px; + border-radius: 10px; + } + + .stat-icon { + width: 36px; + height: 36px; + border-radius: 8px; + } + + .stat-value { + font-size: 20px; + } + + .stat-label { + font-size: 10px; + } + + .footer-message { + font-size: 12px; + } +} + diff --git a/pages/Anjungan/AntrianLoket/[id].vue b/pages/Anjungan/AntrianLoket/[id].vue index fa116c0..0743e5f 100644 --- a/pages/Anjungan/AntrianLoket/[id].vue +++ b/pages/Anjungan/AntrianLoket/[id].vue @@ -890,6 +890,34 @@ watch(anjunganClientId, (newClientId, oldClientId) => { } }) +// Watch for Global Calls (Cross-Device Sync) +watch(() => queueStore.lastGlobalCall, (newCall) => { + if (!newCall) return; + + const targetId = String(loketId.value); + const senderId = String(newCall.loketId || ''); + + // Only react if the call is for THIS loket + if (senderId === targetId) { + console.log(`🔔 [Anjungan] Global Call received for Loket ${targetId}:`, newCall.noAntrian); + + // Update local state to trigger Hero animation + broadcastedPatient.value = { + ...newCall, + _source: 'websocket' + }; + + // Sync with store using isolated key + if (queueStore.currentProcessingPatient) { + const key = targetId ? `loket-${targetId}` : 'loket'; + queueStore.currentProcessingPatient[key] = newCall; + } + } else { + // Optional: Log ignored calls + // console.log(`🔕 [Anjungan] Ignoring call for Loket ${senderId} (Target: ${targetId})`); + } +}, { deep: true }); + // let wsInstance = null // const isConnected = ref(false) diff --git a/pages/Setting/ScreenAntreanMasuk.vue b/pages/Setting/ScreenAntreanMasuk.vue index 0d6cd5f..9ae1b77 100644 --- a/pages/Setting/ScreenAntreanMasuk.vue +++ b/pages/Setting/ScreenAntreanMasuk.vue @@ -19,6 +19,16 @@
+ + mdi-refresh + Refresh Data +