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 @@
-
+
-
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