diff --git a/components/layout/SideBar.vue b/components/layout/SideBar.vue index 44eecd7..4ea82c5 100644 --- a/components/layout/SideBar.vue +++ b/components/layout/SideBar.vue @@ -164,7 +164,7 @@ - @@ -221,6 +221,7 @@ import { useQueueStore } from "@/stores/queueStore"; import { useMasterStore } from "@/stores/masterStore"; import { useLoketStore } from "@/stores/loketStore"; import { useClinicStore } from "@/stores/clinicStore"; +import { useRuangStore } from "@/stores/ruangStore"; import PageHeader from "@/components/common/PageHeader.vue"; import CurrentPatientCard from "@/components/features/queue/CurrentPatientCard.vue"; import QueueActionsCard from "@/components/features/queue/QueueActionsCard.vue"; @@ -235,6 +236,7 @@ const masterStore = useMasterStore(); const queueStore = useQueueStore(); const loketStore = useLoketStore(); const clinicStore = useClinicStore(); +const ruangStore = useRuangStore(); const { printTicketFromPatient } = useThermalPrint(); // Broadcast Channel @@ -303,14 +305,17 @@ onMounted(async () => { await loketStore.fetchLoketFromAPI(); } - // 3. Fetch specific quota/loket data from API to ensure fresh counts + // 3. Fetch fresh room data from API (and de-duplicate) + await ruangStore.fetchRuangFromAPI(); + + // 4. Fetch specific quota/loket data from API to ensure fresh counts // This matches MasterLoket.vue implementation await fetchQuotaFromAPI(); - // 4. Fetch patient data for this loket + // 5. Fetch patient data for this loket await fetchPatientsForCurrentLoket(); - // 5. Periodic check for daily reset (2 AM) + // 6. Periodic check for daily reset (2 AM) resetCheckInterval = setInterval(() => { const didReset = queueStore.checkAndResetDaily(); if (didReset) { @@ -391,9 +396,17 @@ watch(showKlinikRuangDialog, (newValue) => { } }); -// Get klinik ruang data from masterStore +// Get klinik ruang data from ruangStore const klinikRuangList = computed(() => { - return masterStore.ruangData || []; + const currentLoket = loketStore.getLoketById(parseInt(loketId.value)); + const isLoketEksekutif = currentLoket?.tipeloket === 'EKSEKUTIF' || + currentLoket?.tipeLoket === 'EKSEKUTIF' || + (currentLoket?.namaLoket || '').toUpperCase().includes('EKSEKUTIF'); + + const targetLayanan = isLoketEksekutif ? 'Eksekutif' : 'Reguler'; + + // Filter rooms by service type to match Loket's service level + return (ruangStore.ruangData || []).filter(k => k.jenisLayanan === targetLayanan); }); const filteredKlinikRuang = computed(() => { diff --git a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue index d7fe215..1153396 100644 --- a/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue +++ b/pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue @@ -27,9 +27,9 @@ -
+
- mdi-bullhorn + mdi-bullhorn NOMOR ANTRIAN YANG DIPANGGIL
{{ currentCalledQueue.noAntrian.split(' |')[0] }}
@@ -37,18 +37,18 @@
-
+
{{ ruang.namaKlinik }} - {{ ruang.namaRuang }} - - mdi-account-multiple + + mdi-account-multiple {{ ruang.totalQueues }}
@@ -65,13 +65,13 @@
- mdi-clock-outline -

Tidak Ada Antrian

+ mdi-clock-outline +

Tidak Ada

- +
@@ -83,8 +83,8 @@
- mdi-clock-outline -

Tidak Ada Antrian

+ mdi-clock-outline +

Tidak Ada

@@ -388,17 +388,30 @@ const statistics = computed(() => { } }) -// Dynamic grid logic: divide total by 2 to balance across rows, cap at 5 for larger cards -// Meniru logika AntrianLoket +const displayedRuangCount = computed(() => displayedRuang.value.length) + +// Decide if we should use compact layout based on room count +const isCompact = computed(() => displayedRuangCount.value > 8) + +// Dynamic grid logic: more columns for higher room counts const gridColumns = computed(() => { - const total = displayedRuang.value.length + const total = displayedRuangCount.value if (total <= 1) return 1 - if (total <= 5) return total - return Math.min(5, Math.ceil(total / 2)) + if (total <= 4) return total + if (total <= 8) return Math.ceil(total / 2) + if (total <= 12) return 5 + return 6 // For 13+ rooms, use 6 columns +}) + +const gridGap = computed(() => { + if (displayedRuangCount.value <= 4) return '24px' + if (displayedRuangCount.value <= 8) return '16px' + return '12px' }) const gridStyle = computed(() => ({ - gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)` + gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`, + gap: gridGap.value })) const updateTime = () => { @@ -626,7 +639,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => { .antrian-display-container { background: var(--color-neutral-300); width: 100vw; - height: 100vh; + height: 100dvh; padding: 24px; font-family: 'Inter', 'Roboto', sans-serif; overflow: hidden; @@ -642,9 +655,10 @@ watch(anjunganClientId, (newClientId, oldClientId) => { align-items: center; background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%); border-radius: 16px; - padding: 24px 40px; - margin-bottom: 20px; + padding: 16px 40px; + margin-bottom: 16px; box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3); + flex-shrink: 0; } .header-left { @@ -715,11 +729,31 @@ watch(anjunganClientId, (newClientId, oldClientId) => { .hero-call-section { background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%); border-radius: 20px; - padding: 32px; - margin-bottom: 20px; + padding: 24px 32px; + margin-bottom: 16px; text-align: center; box-shadow: 0 12px 32px rgba(33, 150, 243, 0.35); animation: pulse-highlight 2s ease-in-out infinite; + flex-shrink: 0; +} + +.hero-call-section.is-compact { + padding: 12px 20px; + margin-bottom: 12px; +} + +.hero-call-section.is-compact .call-label { + font-size: 18px; + margin-bottom: 4px; +} + +.hero-call-section.is-compact .call-number { + font-size: 80px; + margin: 4px 0; +} + +.hero-call-section.is-compact .call-clinic { + font-size: 20px; } .call-label { @@ -757,9 +791,14 @@ watch(anjunganClientId, (newClientId, oldClientId) => { display: grid; /* grid-template-columns set via inline style */ gap: 16px; - margin-bottom: 20px; + margin-bottom: 16px; flex: 1; - min-height: 0; /* Allow ensuring internal scroll if needed, but we aim for fit */ + min-height: 0; + overflow: hidden; +} + +.clinics-grid.is-compact { + margin-bottom: 12px; } .clinic-box { @@ -772,12 +811,21 @@ watch(anjunganClientId, (newClientId, oldClientId) => { flex-direction: column; } +.clinic-box.is-compact { + border-width: 1.5px; +} + .clinic-header-bar { background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%); padding: 16px 20px; display: flex; justify-content: space-between; align-items: center; + flex-shrink: 0; +} + +.clinic-box.is-compact .clinic-header-bar { + padding: 8px 12px; } .clinic-title { @@ -790,6 +838,10 @@ watch(anjunganClientId, (newClientId, oldClientId) => { flex: 1; } +.clinic-box.is-compact .clinic-title { + font-size: 14px; +} + .clinic-count { background: rgba(255, 255, 255, 0.2) !important; color: var(--color-neutral-100) !important; @@ -805,6 +857,10 @@ watch(anjunganClientId, (newClientId, oldClientId) => { font-weight: 800; } +.clinic-box.is-compact .count-text { + font-size: 14px; +} + .clinic-content { flex: 1; display: flex; @@ -814,21 +870,36 @@ watch(anjunganClientId, (newClientId, oldClientId) => { background: var(--color-primary-50); } +.clinic-box.is-compact .clinic-content { + padding: 8px 12px; +} + .tipe-layanan-section { margin-bottom: 8px; + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; } .tipe-label { - font-size: clamp(14px, 1.04vw, 20px); - font-weight: 700; + font-size: clamp(16px, 1.2vw, 22px); + font-weight: 800; color: var(--color-primary-700); margin-bottom: 12px; letter-spacing: 1px; text-transform: uppercase; - padding: clamp(4px, 0.42vw, 8px) clamp(8px, 0.63vw, 12px); + padding: clamp(6px, 0.5vw, 10px) clamp(12px, 0.8vw, 20px); background: var(--color-primary-200); border-radius: 8px; display: inline-block; + align-self: center; +} + +.clinic-box.is-compact .tipe-label { + font-size: 13px; + padding: 4px 10px; + margin-bottom: 6px; } .empty-state-small { @@ -836,6 +907,10 @@ watch(anjunganClientId, (newClientId, oldClientId) => { padding: 16px 0; } +.clinic-box.is-compact .empty-state-small { + padding: 4px 0; +} + .empty-text-small { font-size: 12px; color: var(--color-neutral-600); @@ -848,6 +923,10 @@ watch(anjunganClientId, (newClientId, oldClientId) => { margin-bottom: 12px; } +.clinic-box.is-compact .current-section { + margin-bottom: 4px; +} + .current-label { font-size: 16px; font-weight: 700; @@ -857,6 +936,11 @@ watch(anjunganClientId, (newClientId, oldClientId) => { text-transform: uppercase; } +.clinic-box.is-compact .current-label { + font-size: 10px; + margin-bottom: 2px; +} + .current-number { font-size: clamp(48px, 4.17vw, 80px); font-weight: 900; @@ -866,16 +950,22 @@ watch(anjunganClientId, (newClientId, oldClientId) => { text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } +.clinic-box.is-compact .current-number { + font-size: clamp(32px, 3vw, 48px); + letter-spacing: 2px; +} + /* ========== FOOTER STATS ========== */ .footer-stats-bar { background: var(--color-neutral-100); border: 2px solid var(--color-primary-200); border-radius: 16px; - padding: 20px 40px; + padding: 12px 40px; display: flex; align-items: center; gap: 32px; box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12); + flex-shrink: 0; } .stat-item { diff --git a/pages/Setting/MasterKlinikRuang.vue b/pages/Setting/MasterKlinikRuang.vue index 7879fa6..896abf1 100644 --- a/pages/Setting/MasterKlinikRuang.vue +++ b/pages/Setting/MasterKlinikRuang.vue @@ -534,8 +534,9 @@ const handleDelete = (item) => { const openPreviewDialog = (item) => { previewItem.value = item; - // Generate preview URL untuk klinik ruang menggunakan kodeKlinik - previewUrl.value = `/anjungan/antrianklinikruang/${item.kodeKlinik}`; + // Generate preview URL untuk klinik ruang menggunakan kodeKlinik + jenisLayanan + const query = item.jenisLayanan ? `?jenisLayanan=${item.jenisLayanan}` : ''; + previewUrl.value = `/anjungan/antrianklinikruang/${item.kodeKlinik}${query}`; previewDialog.value = true; }; diff --git a/stores/ruangStore.js b/stores/ruangStore.js index e7d58d9..cf64036 100644 --- a/stores/ruangStore.js +++ b/stores/ruangStore.js @@ -349,18 +349,15 @@ export const useRuangStore = defineStore('ruang', () => { const mergedRooms = []; let roomIdCounter = 1; - // Track which clinics are handled by API to avoid duplicates for Reguler - const clinicCodesInAPI = new Set(); + // 3. Group and De-duplicate API data by Clinic + Service Type + const apiGroups = new Map(); // Key: clinic.kode-jenisLayanan, Value: { clinic, apiRooms: [], rawClinicData } + const handledClinicKeys = new Set(); // Track unique combinations of kode-jenisLayanan handled by API - // Process API Clinics and their nested Rooms apiRoomsRaw.forEach(apiClinic => { const clinicId = apiClinic.idklinik; const clinicCode = apiClinic.kodeloket || apiClinic.kodeklinik; - const apiRuangan = apiClinic.ruangan || []; - // Find clinic metadata in store // Find clinic metadata in store - Specifically look for Reguler clinic first - // as API data (idklinik, kodeloket) is for Reguler clinics. const clinic = allClinics.find(c => c.jenisLayanan === 'Reguler' && ( (clinicId && String(c.id) === String(clinicId)) || @@ -374,77 +371,115 @@ export const useRuangStore = defineStore('ruang', () => { ); if (clinic) { - clinicCodesInAPI.add(clinic.kode); + const clinicKey = `${clinic.kode}-${clinic.jenisLayanan || 'Reguler'}`; - // Check if API room data is "generic" (only 1 room with same name as clinic) - // to decide whether to prefer Specialist fallback - const isGenericSingleRoom = apiRuangan.length === 1 && - (apiRuangan[0].namaruangan?.trim().toUpperCase() === clinic.name.toUpperCase() || - !apiRuangan[0].namaruangan || - apiRuangan[0].namaruangan === '-'); - - const hasManySpecialists = clinic.spesialis && clinic.spesialis.length > 1; - - // Priority 1: Use nested "ruangan" array from new API - // Skip this if it's just 1 generic room and we have better data in Specialists - if (apiRuangan.length > 0 && !(isGenericSingleRoom && hasManySpecialists)) { - apiRuangan.forEach((apiRoom, index) => { - const specialistName = (clinic.spesialis && clinic.spesialis[index]) - ? clinic.spesialis[index].Spesialis - : null; - - const fallbackName = `Ruang ${apiRoom.idruangan || apiRoom.nomor_ruang || index + 1}`; - - mergedRooms.push({ - id: roomIdCounter++, - kodeKlinik: clinic.kode, - namaKlinik: clinic.name, - kodeRuang: apiRoom.idruangan || apiRoom.idruang || `${clinic.kode}-${roomIdCounter}`, - namaRuang: apiRoom.namaruangan || apiRoom.nama_ruang || apiRoom.nama || specialistName || fallbackName, - nomorRuang: apiRoom.idruangan || apiRoom.nomor_ruang || (index + 1).toString(), - nomorScreen: apiRoom.nomor_screen || `${clinic.kode}${apiRoom.idruangan || index + 1}`, - jenisLayanan: clinic.jenisLayanan || 'Reguler' - }); - }); - } - // Priority 2: Fallback to specialist metadata (Old API) if new API is empty OR generic - else if (clinic.spesialis && clinic.spesialis.length > 0) { - console.log(`ℹ️ Using specialists fallback for ${clinic.name} (${apiRuangan.length === 1 ? 'API generic' : 'API empty'})`); - clinic.spesialis.forEach((spec, index) => { - mergedRooms.push({ - id: roomIdCounter++, - kodeKlinik: clinic.kode, - namaKlinik: clinic.name, - kodeRuang: `${clinic.kode}-${spec.idspesialis}`, - namaRuang: spec.Spesialis, - nomorRuang: (index + 1).toString(), - nomorScreen: `${clinic.kode}${index + 1}`, - jenisLayanan: 'Reguler' - }); + if (!apiGroups.has(clinicKey)) { + apiGroups.set(clinicKey, { + clinic, + apiRooms: [], + rawClinicData: apiClinic // Keep for fallback fields }); } - // Priority 3: Single room fallback - else { - const fallbackName = apiClinic.nama_ruang || apiClinic.nama || clinic.name || 'Ruang 1'; + + const group = apiGroups.get(clinicKey); + const newRooms = apiClinic.ruangan || []; + + newRooms.forEach(nr => { + // Check for duplicates by ID OR Name within this specific clinic group + const roomId = nr.idruangan || nr.idruang; + const roomName = (nr.namaruangan || nr.nama_ruang || nr.nama || '').trim().toUpperCase(); + + const isDuplicate = group.apiRooms.some(existing => { + const existingId = existing.idruangan || existing.idruang; + const existingName = (existing.namaruangan || existing.nama_ruang || existing.nama || '').trim().toUpperCase(); + + // Duplicate if ID matches OR Name matches (more aggressive de-duplication) + if (roomId && existingId && String(roomId) === String(existingId)) return true; + if (roomName && existingName && roomName === existingName) return true; + return false; + }); + + if (!isDuplicate) { + group.apiRooms.push(nr); + } + }); + } + }); + + // 4. Process Grouped Clinics into mergedRooms + apiGroups.forEach((group, clinicKey) => { + const { clinic, apiRooms, rawClinicData } = group; + handledClinicKeys.add(clinicKey); + + // Check if API room data is "generic" (only 1 room with same name as clinic) + const isGenericSingleRoom = apiRooms.length === 1 && + (apiRooms[0].namaruangan?.trim().toUpperCase() === clinic.name.toUpperCase() || + !apiRooms[0].namaruangan || + apiRooms[0].namaruangan === '-'); + + const hasManySpecialists = clinic.spesialis && clinic.spesialis.length > 1; + + // Priority 1: Use pooled and de-duplicated rooms from API + if (apiRooms.length > 0 && !(isGenericSingleRoom && hasManySpecialists)) { + apiRooms.forEach((apiRoom, index) => { + const specialistName = (clinic.spesialis && clinic.spesialis[index]) + ? clinic.spesialis[index].Spesialis + : null; + + const fallbackName = `Ruang ${apiRoom.idruangan || apiRoom.nomor_ruang || index + 1}`; + mergedRooms.push({ id: roomIdCounter++, kodeKlinik: clinic.kode, namaKlinik: clinic.name, - kodeRuang: apiClinic.idruang || clinic.kode, - namaRuang: fallbackName, - nomorRuang: apiClinic.nomor_ruang || '1', - nomorScreen: apiClinic.nomor_screen || `${clinic.kode}1`, + kodeRuang: apiRoom.idruangan || apiRoom.idruang || `${clinic.kode}-${roomIdCounter}`, + namaRuang: apiRoom.namaruangan || apiRoom.nama_ruang || apiRoom.nama || specialistName || fallbackName, + nomorRuang: apiRoom.idruangan || apiRoom.nomor_ruang || (index + 1).toString(), + nomorScreen: apiRoom.nomor_screen || `${clinic.kode}${apiRoom.idruangan || index + 1}`, jenisLayanan: clinic.jenisLayanan || 'Reguler' }); - } + }); + } + // Priority 2: Fallback to specialist metadata + else if (clinic.spesialis && clinic.spesialis.length > 0) { + console.log(`ℹ️ Using specialists fallback for ${clinic.name} (${apiRooms.length === 1 ? 'API generic' : 'API empty'})`); + clinic.spesialis.forEach((spec, index) => { + mergedRooms.push({ + id: roomIdCounter++, + kodeKlinik: clinic.kode, + namaKlinik: clinic.name, + kodeRuang: `${clinic.kode}-${spec.idspesialis}`, + namaRuang: spec.Spesialis, + nomorRuang: (index + 1).toString(), + nomorScreen: `${clinic.kode}${index + 1}`, + jenisLayanan: clinic.jenisLayanan || 'Reguler' + }); + }); + } + // Priority 3: Single room fallback + else { + const fallbackName = rawClinicData.nama_ruang || rawClinicData.nama || clinic.name || 'Ruang 1'; + mergedRooms.push({ + id: roomIdCounter++, + kodeKlinik: clinic.kode, + namaKlinik: clinic.name, + kodeRuang: rawClinicData.idruang || clinic.kode, + namaRuang: fallbackName, + nomorRuang: rawClinicData.nomor_ruang || '1', + nomorScreen: rawClinicData.nomor_screen || `${clinic.kode}1`, + jenisLayanan: clinic.jenisLayanan || 'Reguler' + }); } }); - // 4. Add Seed Data (Eksekutif) + Reguler clinics not in API + // 5. Add Seed Data (Eksekutif) + Reguler clinics not in API allClinics.forEach(clinic => { - if (clinic.jenisLayanan === 'Eksekutif' || !clinicCodesInAPI.has(clinic.kode)) { + const clinicKey = `${clinic.kode}-${clinic.jenisLayanan || 'Reguler'}`; + + // Skip if this specific clinic+layanan combination was already handled by API + if (!handledClinicKeys.has(clinicKey)) { console.log(`➕ Adding clinic from seed/metadata: ${clinic.name} (${clinic.jenisLayanan})`); // Fallback logic similar to original generateRoomsFromClinics