From d0eee7d062487c60ffeb739c03e6e001f8e224eb Mon Sep 17 00:00:00 2001 From: Fanrouver Date: Fri, 10 Jul 2026 09:42:30 +0700 Subject: [PATCH] feat: implement Grand Paviliun dashboard with specialized room monitoring, queue management, and modular components --- assets/scss/eksekutif.scss | 28 +++ .../AdminKlinik/AdminKlinikEksekutif.vue | 174 +++++++++++++++ components/GrandPaviliun/GpCurrentPatient.vue | 135 +++++++++++ components/GrandPaviliun/GpMonitorSidebar.vue | 98 ++++++++ components/GrandPaviliun/GpQueueList.vue | 63 ++++++ components/GrandPaviliun/GpQueueListItem.vue | 47 ++++ components/GrandPaviliun/GpRoomColumn.vue | 95 ++++++++ .../GrandPaviliun/GpRoomMonitorCard.vue | 71 ++++++ components/GrandPaviliun/GpStatusBadge.vue | 40 ++++ composables/useGrandPaviliun.ts | 211 ++++++++++++++++++ composables/useQueue.js | 8 + pages/AdminKlinikRuang/[kodeKlinik].vue | 6 +- pages/AdminLoket/[id].vue | 46 ++-- stores/queueStore.ts | 74 ++++-- 14 files changed, 1052 insertions(+), 44 deletions(-) create mode 100644 assets/scss/eksekutif.scss create mode 100644 components/AdminKlinik/AdminKlinikEksekutif.vue create mode 100644 components/GrandPaviliun/GpCurrentPatient.vue create mode 100644 components/GrandPaviliun/GpMonitorSidebar.vue create mode 100644 components/GrandPaviliun/GpQueueList.vue create mode 100644 components/GrandPaviliun/GpQueueListItem.vue create mode 100644 components/GrandPaviliun/GpRoomColumn.vue create mode 100644 components/GrandPaviliun/GpRoomMonitorCard.vue create mode 100644 components/GrandPaviliun/GpStatusBadge.vue create mode 100644 composables/useGrandPaviliun.ts diff --git a/assets/scss/eksekutif.scss b/assets/scss/eksekutif.scss new file mode 100644 index 0000000..4a89c4c --- /dev/null +++ b/assets/scss/eksekutif.scss @@ -0,0 +1,28 @@ +// variables for Grand Paviliun (Eksekutif) +.theme-grand-paviliun { + --gp-primary: #3F51B5; // Indigo header + --gp-primary-dark: #303F9F; + --gp-bg: #F5F7FA; // Light grayish blue background + --gp-surface: #FFFFFF; + --gp-text-primary: #1F2937; + --gp-text-secondary: #6B7280; + --gp-border: #E5E7EB; + + --gp-success: #10B981; // Selesai + --gp-danger: #EF4444; // Pending + --gp-warning: #F59E0B; + --gp-info: #3B82F6; + + // Custom colors for specific tags + --gp-room-number: #F97316; // Orange text for RUANG 02 + + background-color: var(--gp-bg); + min-height: 100vh; + font-family: 'Inter', 'Roboto', sans-serif; +} + +// Utility classes +.gp-text-primary { color: var(--gp-text-primary); } +.gp-text-secondary { color: var(--gp-text-secondary); } +.gp-font-bold { font-weight: 700; } +.gp-font-semibold { font-weight: 600; } diff --git a/components/AdminKlinik/AdminKlinikEksekutif.vue b/components/AdminKlinik/AdminKlinikEksekutif.vue new file mode 100644 index 0000000..05ae130 --- /dev/null +++ b/components/AdminKlinik/AdminKlinikEksekutif.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/components/GrandPaviliun/GpCurrentPatient.vue b/components/GrandPaviliun/GpCurrentPatient.vue new file mode 100644 index 0000000..ebe4aad --- /dev/null +++ b/components/GrandPaviliun/GpCurrentPatient.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/components/GrandPaviliun/GpMonitorSidebar.vue b/components/GrandPaviliun/GpMonitorSidebar.vue new file mode 100644 index 0000000..f7f3599 --- /dev/null +++ b/components/GrandPaviliun/GpMonitorSidebar.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/components/GrandPaviliun/GpQueueList.vue b/components/GrandPaviliun/GpQueueList.vue new file mode 100644 index 0000000..8670efb --- /dev/null +++ b/components/GrandPaviliun/GpQueueList.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/components/GrandPaviliun/GpQueueListItem.vue b/components/GrandPaviliun/GpQueueListItem.vue new file mode 100644 index 0000000..ff0db6b --- /dev/null +++ b/components/GrandPaviliun/GpQueueListItem.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/components/GrandPaviliun/GpRoomColumn.vue b/components/GrandPaviliun/GpRoomColumn.vue new file mode 100644 index 0000000..ccea1ce --- /dev/null +++ b/components/GrandPaviliun/GpRoomColumn.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/components/GrandPaviliun/GpRoomMonitorCard.vue b/components/GrandPaviliun/GpRoomMonitorCard.vue new file mode 100644 index 0000000..bacdda4 --- /dev/null +++ b/components/GrandPaviliun/GpRoomMonitorCard.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/components/GrandPaviliun/GpStatusBadge.vue b/components/GrandPaviliun/GpStatusBadge.vue new file mode 100644 index 0000000..a207ad0 --- /dev/null +++ b/components/GrandPaviliun/GpStatusBadge.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/composables/useGrandPaviliun.ts b/composables/useGrandPaviliun.ts new file mode 100644 index 0000000..925a598 --- /dev/null +++ b/composables/useGrandPaviliun.ts @@ -0,0 +1,211 @@ +import { ref, computed } from 'vue'; +import { useQueueStore } from '@/stores/queueStore'; +import { useMasterStore } from '@/stores/masterStore'; +import { useClinicStore } from '@/stores/clinicStore'; + +export const useGrandPaviliun = () => { + const queueStore = useQueueStore(); + const masterStore = useMasterStore(); + const clinicStore = useClinicStore(); + + const loading = ref(false); + + // Expose methods for UI + const fetchEksekutifData = async () => { + loading.value = true; + try { + // Pastikan data master ruangan sudah dimuat + if (!masterStore.ruangData || masterStore.ruangData.length === 0) { + // Assume it's loaded in index or master layouts, but fallback if not + } + + const eksekutifClinics = masterStore.ruangData.filter(r => r.jenisLayanan === 'Eksekutif'); + + // Ambil data antrean untuk semua klinik eksekutif + const promises = eksekutifClinics.map(clinic => { + queueStore.registerClinicInterest(clinic.kodeKlinik); + return queueStore.fetchPatientsForClinic(clinic.kodeKlinik); + }); + + await Promise.all(promises); + + // Connect WebSocket if not connected + if (!queueStore.isWsConnected) { + queueStore.initWebSocket('admin-klinik-ruang-eksekutif'); + } + } catch (e) { + console.error('Error fetching eksekutif data:', e); + } finally { + loading.value = false; + } + }; + + // Computed data for UI + const clinics = computed(() => { + const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif'); + + return eksekutifClinics.map(clinic => { + // Find all patients for this clinic + const clinicPatients = queueStore.allPatients.filter(p => p.kodeKlinik === clinic.kodeKlinik && p.processStage === 'klinik-ruang' && p.status !== 'processed'); + + // Find current processing patient (we take the first room's current patient if multiple, or global) + // Since design shows 1 column per clinic, we find the first patient marked as processing. + // Usually, queueStore.currentProcessingPatient is keyed by `klinik-ruang-${kodeKlinik}-${nomorRuang}` + let currentPatientObj = null; + + // We look through clinic rooms to find a processing patient + for (const ruang of clinic.ruangList) { + const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`; + const processing = queueStore.currentProcessingPatient[key]; + if (processing) { + currentPatientObj = { + ...processing, + roomIndicator: `RUANG ${ruang.nomorRuang}`, + nomorRuang: ruang.nomorRuang + }; + break; // Stop at first one for the column view + } + } + + // Get queue patients (those not currently processing) + const currentPatientNo = currentPatientObj ? currentPatientObj.no : null; + const queuePatients = clinicPatients + .filter(p => p.no !== currentPatientNo) + .map(p => ({ + ...p, + isActive: false // Could be based on selection later + })); + + return { + kodeKlinik: clinic.kodeKlinik, + namaKlinik: clinic.namaKlinik, + currentPatient: currentPatientObj, + queuePatients: queuePatients, + roomOptions: clinic.ruangList + }; + }); + }); + + const monitorRooms = computed(() => { + const rooms = []; + let idCounter = 1; + + const eksekutifClinics = (masterStore.ruangData || []).filter(r => r.jenisLayanan === 'Eksekutif'); + + eksekutifClinics.forEach(clinic => { + clinic.ruangList.forEach(ruang => { + const key = `klinik-ruang-${clinic.kodeKlinik}-${ruang.nomorRuang}`; + const processing = queueStore.currentProcessingPatient[key]; + + // As user specified: Monitoring only shows data when 'Panggil Pemeriksaan' (Tindakan) is true + // Assuming calledTindakan flag exists on processing patient + const isActiveInMonitoring = processing && processing.calledTindakan; + + rooms.push({ + id: idCounter++, + name: `RUANG ${ruang.nomorRuang}`, + activePatient: isActiveInMonitoring ? (processing.noAntrian?.split(" |")[0] || processing.barcode) : null, + clinicName: clinic.namaKlinik + }); + }); + }); + + return rooms; + }); + + // Actions + const handlePemeriksaanAwal = (patient, kodeKlinik, nomorRuang) => { + if (!patient || !kodeKlinik || !nomorRuang) return; + + const roomInfo = masterStore.ruangData + .find(c => c.kodeKlinik === kodeKlinik) + ?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang)); + + if (!roomInfo) return; + + const wsData = { + action: 'callPemeriksaanAwal', + patientId: patient.barcode || patient.visitId, + patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode, + patientName: patient.name || '', + roomName: roomInfo.namaRuang, + clinicCode: kodeKlinik, + tipePanggilan: 'Pemeriksaan Awal' + }; + + queueStore.sendViaPost(wsData); + + // Update local state to mark as called + if (patient.no) { + const idx = queueStore.allPatients.findIndex(p => p.no === patient.no); + if (idx !== -1) { + queueStore.allPatients[idx].calledPemeriksaanAwal = true; + } + } + }; + + const handlePanggilPemeriksaan = (patient, kodeKlinik, nomorRuang) => { + if (!patient || !kodeKlinik || !nomorRuang) return; + + const roomInfo = masterStore.ruangData + .find(c => c.kodeKlinik === kodeKlinik) + ?.ruangList.find(r => String(r.nomorRuang) === String(nomorRuang)); + + if (!roomInfo) return; + + const wsData = { + action: 'callTindakan', + patientId: patient.barcode || patient.visitId, + patientCode: patient.noAntrian?.split(" |")[0] || patient.barcode, + patientName: patient.name || '', + roomName: roomInfo.namaRuang, + clinicCode: kodeKlinik, + tipePanggilan: 'Tindakan' + }; + + queueStore.sendViaPost(wsData); + + // Update local state to mark as called + if (patient.no) { + const idx = queueStore.allPatients.findIndex(p => p.no === patient.no); + if (idx !== -1) { + queueStore.allPatients[idx].calledTindakan = true; + } + } + }; + + const processPatientAction = async (patient, action, kodeKlinik, nomorRuang) => { + if (!patient || !kodeKlinik || !nomorRuang) return { success: false, message: 'Data tidak lengkap' }; + + const result = await queueStore.processPatientKlinikRuang( + patient, + action, + kodeKlinik, + nomorRuang + ); + + if (action === 'pending' && result.success) { + const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no); + if (patientIndex !== -1) { + queueStore.allPatients[patientIndex] = { + ...queueStore.allPatients[patientIndex], + status: 'pending' + }; + const key = `klinik-ruang-${kodeKlinik}-${nomorRuang}`; + queueStore.currentProcessingPatient[key] = null; + } + } + + return result; + }; + + return { + clinics, + monitorRooms, + loading, + fetchEksekutifData, + handlePemeriksaanAwal, + handlePanggilPemeriksaan, + processPatientAction + }; +}; diff --git a/composables/useQueue.js b/composables/useQueue.js index b58b466..7e2d004 100644 --- a/composables/useQueue.js +++ b/composables/useQueue.js @@ -94,6 +94,14 @@ export const useQueue = (adminType = "loket", specificId = null) => { const isAssignedToThis = p.loketId && String(p.loketId) === String(targetId); if (isAssignedToThis) return true; + // Add strict isolation between Eksekutif and Reguler for unassigned tickets + const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E')); + const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000; + + if (isPatientEksekutif !== isLoketEksekutif) { + return false; + } + const isServedByThis = !p.loketId && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan) && thisLoket.pelayanan.includes(p.kodeKlinik); return isServedByThis; diff --git a/pages/AdminKlinikRuang/[kodeKlinik].vue b/pages/AdminKlinikRuang/[kodeKlinik].vue index da2098c..1bd6ab5 100644 --- a/pages/AdminKlinikRuang/[kodeKlinik].vue +++ b/pages/AdminKlinikRuang/[kodeKlinik].vue @@ -1,6 +1,8 @@ @@ -798,6 +801,7 @@ import { useRuangStore } from '@/stores/ruangStore'; import PageHeader from '@/components/common/PageHeader.vue'; import PatientCard from '@/components/features/queue/PatientCard.vue'; import AppSnackbar from '@/components/common/AppSnackbar.vue'; +import AdminKlinikEksekutif from '@/components/AdminKlinik/AdminKlinikEksekutif.vue'; import { useThermalPrint } from '@/composables/useThermalPrint'; const route = useRoute(); diff --git a/pages/AdminLoket/[id].vue b/pages/AdminLoket/[id].vue index 018527b..b4d8a40 100644 --- a/pages/AdminLoket/[id].vue +++ b/pages/AdminLoket/[id].vue @@ -709,28 +709,16 @@ const allPatientsForStage = computed(() => { // Helper to check if patient belongs to this loket (for seed data only) const isPatientForThisLoket = (p) => { - // Only check for EKSEKUTIF patients (seed data) - const isPatientEksekutif = - (p.pembayaran || "").toUpperCase().includes("EKSEKUTIF") || - (p.pembayaran || "").toUpperCase().includes("VIP"); + // 1. Strict isolation using ticket prefix + const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E')); + + if (isPatientEksekutif !== isLoketEksekutif) { + return false; + } - if (isLoketEksekutif) { - // Loket Eksekutif HANYA melayani pasien Eksekutif - if (!isPatientEksekutif) return false; - - // For EKSEKUTIF loket: accept all EKSEKUTIF patients - // EKSEKUTIF lokets typically serve ALL clinics for executive patients - // So we don't need to check kodeKlinik matching - // Only check explicit loketId assignment if present - if (p.loketId) { - return String(p.loketId) === String(targetLoketId); - } - - // If no loketId assigned, accept all EKSEKUTIF patients - return true; - } else { - // Loket Reguler TIDAK melayani pasien Eksekutif (this shouldn't happen with API data) - if (isPatientEksekutif) return false; + // 2. Explicit Loket assignment takes precedence + if (p.loketId) { + return String(p.loketId) === String(targetLoketId); } // For REGULER seed data only: check loket assignment @@ -854,16 +842,22 @@ const filteredMenungguCount = computed(() => { if (isLoketEksekutif) { // For EKSEKUTIF, use seed data menunggu count (filtered by loket) return menungguPatients.value.filter((p) => { - const isPatientEksekutif = - (p.pembayaran || "").toUpperCase().includes("EKSEKUTIF") || - (p.pembayaran || "").toUpperCase().includes("VIP"); + // 1. Strict isolation using ticket prefix + const isPatientEksekutif = p.noAntrian && (String(p.noAntrian).startsWith('E') || String(p.noAntrian).startsWith('F-E')); if (!isPatientEksekutif) return false; - // Accept all EKSEKUTIF patients if no explicit loketId + // 2. Explicit Loket assignment takes precedence if (p.loketId) { return String(p.loketId) === String(targetLoketId); } - return true; + + // 3. Fallback to clinic mapping if unassigned + const allowedServices = currentLoket?.pelayanan || []; + if (p.kodeKlinik) { + return allowedServices.includes(p.kodeKlinik); + } + + return false; }).length; } else { // For REGULER, count from allPatients (reactive) diff --git a/stores/queueStore.ts b/stores/queueStore.ts index 35e0083..6495479 100644 --- a/stores/queueStore.ts +++ b/stores/queueStore.ts @@ -875,11 +875,10 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean = (loket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF'); if (isEksekutif) { - // Return EKSEKUTIF patients from seed data + // Return EKSEKUTIF patients assigned to this loket return allPatients.value.filter(p => { - const isPembayaranEksekutif = (p.pembayaran || '').toUpperCase().includes('EKSEKUTIF') || - (p.pembayaran || '').toUpperCase().includes('VIP'); - return isPembayaranEksekutif && p.processStage === 'loket'; + const isMatchingLoket = String(p.loketId) === String(loketId); + return p.processStage === 'loket' && isMatchingLoket; }); } @@ -1485,6 +1484,20 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean = // Enforce clinic mapping (pelayanan) if (thisLoket && thisLoket.pelayanan && Array.isArray(thisLoket.pelayanan)) { if (thisLoket.pelayanan.includes(p.kodeKlinik)) { + // Bypass payment check for Eksekutif patients + const isPatientEksekutif = p.noAntrian && (p.noAntrian.startsWith('E') || p.noAntrian.startsWith('F-E')); + const isLoketEksekutif = thisLoket.tipeLoket === 'EKSEKUTIF' || thisLoket.id >= 1000; + + if (isPatientEksekutif && isLoketEksekutif) { + return true; + } + if (!isPatientEksekutif && isLoketEksekutif) { + return false; + } + if (isPatientEksekutif && !isLoketEksekutif) { + return false; + } + // NEW: Check payment compatibility if (!isPaymentCompatible(p.pembayaran, thisLoket.pembayaran)) { return false; @@ -2609,11 +2622,22 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean = let targetLoketName = oldPatient.loket; if (newKlinik.kode) { + const isEksekutif = (oldPatient.pembayaran || '').toUpperCase().includes('EKSEKUTIF') || + (oldPatient.pembayaran || '').toUpperCase().includes('VIP'); + const allLokets = loketStore.lokets || []; - const foundLoket = allLokets.find(l => - l.pelayanan && Array.isArray(l.pelayanan) && - (l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0])) - ); + const foundLoket = allLokets.find(l => { + const handlesClinic = l.pelayanan && Array.isArray(l.pelayanan) && + (l.pelayanan.includes(newKlinik.kode) || l.pelayanan.includes(newKlinik.kode?.split('-')[0])); + + if (!handlesClinic) return false; + + if (isEksekutif) { + return l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF'; + } else { + return l.id < 1000 && l.tipeLoket !== 'EKSEKUTIF'; + } + }); if (foundLoket) { targetLoketId = foundLoket.id; @@ -2782,15 +2806,31 @@ const fetchPatientsForLoket = async (loketId: string | number, force: boolean = // 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) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) - ); - - 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})`); + if (isEksekutif) { + const targetLoket = allLokets.find(l => + l.pelayanan && Array.isArray(l.pelayanan) && + (l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) && + (l.id >= 1000 || l.tipeLoket === 'EKSEKUTIF') + ); + + if (targetLoket) { + newPatient.loketId = targetLoket.id; + if (!newPatient.loket) newPatient.loket = targetLoket.namaLoket; + console.log(`✅ Auto-assigned Eksekutif Ticket ${newPatient.noAntrian} to Loket ${targetLoket.id} (${targetLoket.namaLoket})`); + } else { + console.warn(`⚠️ Warning: No Eksekutif Loket found for clinic ${newPatient.kodeKlinik}. Ticket will not be routed to any loket.`); + } + } else { + const targetLoket = allLokets.find(l => + l.pelayanan && Array.isArray(l.pelayanan) && + (l.pelayanan.includes(newPatient.kodeKlinik) || l.pelayanan.includes(newPatient.kodeKlinik?.split('-')[0])) + ); + + 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})`); + } } }