update layout klinik dan websocket layar klinik

This commit is contained in:
bagus-arie05
2026-01-12 11:21:54 +07:00
parent 27209def4b
commit 6e7160c935
4 changed files with 642 additions and 60 deletions
+167
View File
@@ -0,0 +1,167 @@
// composables/useWebSocket.ts
import { ref, computed, onUnmounted } from 'vue'
export interface WebSocketConfig {
url: string
clientId: string
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
reconnectInterval?: number
maxReconnectAttempts?: number
}
export interface WebSocketMessage {
to_client: string
data: any
}
export const useWebSocket = (config: WebSocketConfig) => {
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const reconnectAttempts = ref(0)
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
const wsUrl = computed(() => {
const url = new URL(config.url)
url.searchParams.set('client_id', config.clientId)
return url.toString()
})
const connect = () => {
try {
// Close existing connection if any
if (ws.value && ws.value.readyState !== WebSocket.CLOSED) {
ws.value.close()
}
ws.value = new WebSocket(wsUrl.value)
ws.value.onopen = () => {
console.log('WebSocket connected:', config.clientId)
isConnected.value = true
reconnectAttempts.value = 0
config.onOpen?.()
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
config.onMessage?.(data)
} catch (error) {
console.error('Error parsing WebSocket message:', error)
}
}
ws.value.onclose = () => {
console.log('WebSocket closed:', config.clientId)
isConnected.value = false
config.onClose?.()
// Attempt to reconnect
if (reconnectAttempts.value < (config.maxReconnectAttempts || 5)) {
reconnectAttempts.value++
const interval = config.reconnectInterval || 3000
reconnectTimer.value = setTimeout(() => {
console.log(`Reconnecting... Attempt ${reconnectAttempts.value}`)
connect()
}, interval)
} else {
console.error('Max reconnection attempts reached')
}
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
config.onError?.(error)
}
} catch (error) {
console.error('Error creating WebSocket:', error)
config.onError?.(error as Event)
}
}
const disconnect = () => {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
if (ws.value) {
ws.value.close()
ws.value = null
}
isConnected.value = false
}
const sendMessage = (message: any) => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify(message))
return true
}
console.warn('WebSocket is not connected')
return false
}
// Send data via POST to WebSocket server
const sendViaPost = async (message: WebSocketMessage) => {
try {
// Extract base URL from WebSocket URL (convert ws:// to http:// or wss:// to https://)
let baseUrl = config.url
if (baseUrl.startsWith('ws://')) {
baseUrl = baseUrl.replace('ws://', 'http://')
} else if (baseUrl.startsWith('wss://')) {
baseUrl = baseUrl.replace('wss://', 'https://')
}
// Remove query parameters and ensure we have the correct POST endpoint
const urlObj = new URL(baseUrl)
const postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
console.log('📡 POST URL:', postUrl)
console.log('📦 POST Body:', JSON.stringify(message, null, 2))
const response = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(message),
})
console.log('📥 POST Response status:', response.status, response.statusText)
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error')
console.error('❌ POST Error response:', errorText)
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`)
}
const result = await response.json().catch(() => {
console.log('️ Response is not JSON, assuming success')
return { success: true }
})
console.log('✅ POST Response data:', result)
return result
} catch (error) {
console.error('❌ Error sending message via POST:', error)
throw error
}
}
onUnmounted(() => {
disconnect()
})
return {
ws: computed(() => ws.value),
isConnected: computed(() => isConnected.value),
connect,
disconnect,
sendMessage,
sendViaPost,
reconnectAttempts: computed(() => reconnectAttempts.value),
}
}
+1
View File
@@ -81,6 +81,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',
},
},
+150 -32
View File
@@ -88,11 +88,17 @@
</v-chip>
</div>
<div class="patient-queue-list">
<div
<v-tooltip
v-for="patient in paginatedAllPatients(ruang)"
:key="patient.no"
:class="getPatientCardClass(patient)"
:text="patient.status === 'pending' ? 'Pasien Pending' : ''"
:disabled="patient.status !== 'pending'"
>
<template v-slot:activator="{ props }">
<div
v-bind="props"
:class="getPatientCardClass(patient)"
>
<!-- Left Section: Number and Status -->
<div class="patient-card-left">
<div class="patient-queue-number">
@@ -166,7 +172,9 @@
</v-btn>
</div>
</div>
</div>
</div>
</template>
</v-tooltip>
</div>
<!-- Pagination -->
<v-pagination
@@ -353,6 +361,7 @@ import { useMasterStore } from '@/stores/masterStore';
import PageHeader from '@/components/common/PageHeader.vue';
import PatientCard from '@/components/features/queue/PatientCard.vue';
import AppSnackbar from '@/components/common/AppSnackbar.vue';
import { useWebSocket } from '@/composables/useWebSocket';
const route = useRoute();
const router = useRouter();
@@ -409,6 +418,19 @@ const showPindahRuangDialog = ref(false);
const selectedPatientForPindah = ref(null);
const selectedRuangBaru = ref(null);
// WebSocket configuration
const config = useRuntimeConfig();
const wsBaseUrl = config.public?.wsBaseUrl || 'ws://10.10.150.100:8084/api/v1/ws';
// WebSocket client ID for admin (you can make this dynamic)
const adminClientId = `admin-klinik-ruang-${kodeKlinik.value}`;
// Initialize WebSocket for sending messages
const { sendViaPost } = useWebSocket({
url: wsBaseUrl,
clientId: adminClientId,
});
const showSnackbar = (message, color = 'success') => {
snackbarText.value = message;
snackbarColor.value = color;
@@ -600,29 +622,14 @@ const confirmPindahRuang = () => {
};
// Handle call patient from list (dari card pasien di daftar)
const handleCallPatientFromList = (ruang, patient, tipeLayanan) => {
const handleCallPatientFromList = async (ruang, patient, tipeLayanan) => {
if (!patient || !patient.no) {
showSnackbar('Pasien tidak valid', 'error');
return;
}
// Pastikan pasien sudah diproses terlebih dahulu
const current = getCurrentProcessingForRoom(ruang);
if (!current || current.no !== patient.no) {
// Jika pasien belum diproses, proses dulu
const processResult = queueStore.processPatientKlinikRuang(
patient,
'proses',
klinikData.value.kodeKlinik,
ruang.nomorRuang
);
if (!processResult.success) {
showSnackbar(processResult.message, 'error');
return;
}
}
// Update tipeLayanan pasien untuk tracking panggilan terakhir
// Hanya memanggil, tidak memproses pasien (tidak naik ke current processing)
const patientIndex = queueStore.allPatients.findIndex(p => p.no === patient.no);
if (patientIndex === -1) {
showSnackbar('Pasien tidak ditemukan', 'error');
@@ -633,7 +640,7 @@ const handleCallPatientFromList = (ruang, patient, tipeLayanan) => {
// Update tipeLayanan pasien agar muncul di kolom yang sesuai di Anjungan
const updateData = {
...queueStore.allPatients[patientIndex],
status: 'di-loket',
status: queueStore.allPatients[patientIndex].status, // Tetap status yang sama, tidak diubah ke 'di-loket'
tipeLayanan: tipeLayanan, // Update tipeLayanan untuk display di Anjungan
lastCalledAt: new Date().toISOString(),
lastCalledTipeLayanan: tipeLayanan
@@ -648,13 +655,70 @@ const handleCallPatientFromList = (ruang, patient, tipeLayanan) => {
queueStore.allPatients[patientIndex] = updateData;
// Update current processing (tetap 1 pasien, tidak dipisah per tipe)
const key = `klinik-ruang-${klinikData.value.kodeKlinik}-${ruang.nomorRuang}`;
queueStore.currentProcessingPatient[key] = queueStore.allPatients[patientIndex];
// TIDAK mengupdate current processing - pasien tetap di daftar pasien
const patientCode = queueStore.allPatients[patientIndex].noAntrian?.split(" |")[0] ||
queueStore.allPatients[patientIndex].barcode ||
'-';
// Send via WebSocket to Anjungan clients
try {
// Get all possible client IDs for this klinik (anjungan screens)
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}`);
// Also send to specific screen if ruang has nomorScreen
if (ruang.nomorScreen) {
const specificScreenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${ruang.nomorScreen}`;
if (!anjunganClientIds.includes(specificScreenId)) {
anjunganClientIds.push(specificScreenId);
}
}
// 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);
}
}
});
console.log('📋 All target client IDs:', anjunganClientIds);
// Extract nomor antrian (ambil bagian sebelum " |")
const nomorAntrian = updateData.noAntrian?.split(" |")[0] || updateData.barcode || '-';
console.log('📤 Sending WebSocket message via POST...');
console.log('📍 Target clients:', anjunganClientIds);
console.log('📦 Nomor antrian:', nomorAntrian);
console.log('📦 Tipe layanan:', tipeLayanan);
// Send to all relevant clients
for (const clientId of anjunganClientIds) {
// Kirim nomor antrian + tipe layanan agar Anjungan tahu harus tampilkan di kolom mana
const message = {
to_client: clientId,
data: {
noantrian: nomorAntrian,
tipeLayanan: tipeLayanan // "Pemeriksaan Awal" atau "Tindakan"
},
};
console.log(`📨 Sending to ${clientId}:`, message);
const result = await sendViaPost(message);
console.log(`✅ Response from ${clientId}:`, result);
}
console.log('✅ All WebSocket messages sent successfully');
} catch (error) {
console.error('❌ Error sending WebSocket message:', error);
// Don't block the UI if WebSocket fails
}
showSnackbar(`Memanggil pasien ${patientCode} untuk ${tipeLayanan}`, 'success');
};
@@ -751,7 +815,7 @@ const getStatusLabel = (status) => {
// Handle call patient by tipe (untuk pasien yang sedang diproses)
const handleCallPatientByTipe = (ruang, tipeLayanan) => {
const handleCallPatientByTipe = async (ruang, tipeLayanan) => {
const current = getCurrentProcessingForRoom(ruang);
if (!current || !current.no) {
showSnackbar('Tidak ada pasien yang sedang diproses', 'error');
@@ -791,6 +855,65 @@ const handleCallPatientByTipe = (ruang, tipeLayanan) => {
const patientCode = queueStore.allPatients[patientIndex].noAntrian?.split(" |")[0] ||
queueStore.allPatients[patientIndex].barcode ||
'-';
// Send via WebSocket to Anjungan clients
try {
// Get all possible client IDs for this klinik (anjungan screens)
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}`);
// Also send to specific screen if ruang has nomorScreen
if (ruang.nomorScreen) {
const specificScreenId = `anjungan-klinik-ruang-${klinikData.value.kodeKlinik}-screen-${ruang.nomorScreen}`;
if (!anjunganClientIds.includes(specificScreenId)) {
anjunganClientIds.push(specificScreenId);
}
}
// 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);
}
}
});
console.log('📋 All target client IDs:', anjunganClientIds);
// Extract nomor antrian (ambil bagian sebelum " |")
const nomorAntrian = updateData.noAntrian?.split(" |")[0] || updateData.barcode || '-';
console.log('📤 Sending WebSocket message via POST...');
console.log('📍 Target clients:', anjunganClientIds);
console.log('📦 Nomor antrian:', nomorAntrian);
console.log('📦 Tipe layanan:', tipeLayanan);
// Send to all relevant clients
for (const clientId of anjunganClientIds) {
// Kirim nomor antrian + tipe layanan agar Anjungan tahu harus tampilkan di kolom mana
const message = {
to_client: clientId,
data: {
noantrian: nomorAntrian,
tipeLayanan: tipeLayanan // "Pemeriksaan Awal" atau "Tindakan"
},
};
console.log(`📨 Sending to ${clientId}:`, message);
const result = await sendViaPost(message);
console.log(`✅ Response from ${clientId}:`, result);
}
console.log('✅ All WebSocket messages sent successfully');
} catch (error) {
console.error('❌ Error sending WebSocket message:', error);
// Don't block the UI if WebSocket fails
}
showSnackbar(`Memanggil pasien ${patientCode} untuk ${tipeLayanan}`, 'success');
};
@@ -1241,8 +1364,6 @@ onMounted(() => {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.patient-queue-item {
@@ -1260,11 +1381,8 @@ onMounted(() => {
.patient-queue-item-pending-status {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
border: 2px solid #ef5350;
}
.patient-queue-item-pending-status:hover {
background: linear-gradient(135deg, #ffcdd2 0%, #ef9a9a 100%);
border-color: #e53935;
cursor: default;
transition: none !important;
}
/* Pasien yang sudah digenerate/diproses */
@@ -81,12 +81,6 @@
<p class="empty-text-small">Tidak Ada Antrian</p>
</div>
</div>
<!-- Empty State for entire room -->
<div v-if="ruang.totalQueues === 0" class="empty-state">
<v-icon size="48" color="grey-lighten-3">mdi-clock-outline</v-icon>
<p class="empty-text">Tidak Ada Antrian</p>
</div>
</div>
</div>
</div>
@@ -136,10 +130,11 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useMasterStore } from '@/stores/masterStore'
import { useRoute } from '#app'
import { useWebSocket } from '@/composables/useWebSocket'
definePageMeta({
layout: false,
@@ -369,6 +364,184 @@ const updateTime = () => {
})
}
// WebSocket configuration for Anjungan
const config = useRuntimeConfig()
const wsBaseUrl = config.public?.wsBaseUrl || 'ws://10.10.150.100:8084/api/v1/ws'
// WebSocket client ID for anjungan (you can make this dynamic based on screen number)
const anjunganClientId = computed(() => {
if (!kodeKlinik.value) return ''
// Priority 1: Try to get screen number from URL query params
const screenParam = route.query.screen
if (screenParam) {
const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}-screen-${screenParam}`
console.log('🆔 Using client ID from URL query screen param:', clientId)
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)
const clientId = `anjungan-klinik-ruang-${kodeKlinik.value}`
console.log('🆔 Using default client ID (no screen number):', clientId)
return clientId
})
// WebSocket instance
let wsInstance = null
const isConnected = ref(false)
// Initialize WebSocket connection
const initWebSocket = () => {
if (!kodeKlinik.value || !anjunganClientId.value) {
console.warn('Cannot initialize WebSocket: missing kodeKlinik or clientId')
return null
}
console.log('🔌 Initializing WebSocket connection...')
console.log('📍 URL:', wsBaseUrl)
console.log('🆔 Client ID:', anjunganClientId.value)
wsInstance = useWebSocket({
url: wsBaseUrl,
clientId: anjunganClientId.value,
onMessage: (data) => {
console.log('📨 WebSocket raw message received:', data)
console.log('📨 Message type:', typeof data)
// Backend mengirim data langsung atau dalam wrapper
// Handle both cases
let messageData = data
// Jika data ada dalam property 'data', ambil itu
if (data && typeof data === 'object' && data.data) {
messageData = data.data
console.log('📦 Extracted data from wrapper:', messageData)
}
// Handle patient called message - format: { noantrian: "AA001", tipeLayanan: "Pemeriksaan Awal" }
if (messageData && messageData.noantrian) {
const nomorAntrian = messageData.noantrian
const tipeLayanan = messageData.tipeLayanan || null // "Pemeriksaan Awal" atau "Tindakan"
console.log('✅ Processing patient call for nomor antrian:', nomorAntrian, 'tipeLayanan:', tipeLayanan)
// Find patient by nomor antrian (check both noAntrian and barcode)
const patientIndex = queueStore.allPatients.findIndex(p => {
const pNoAntrian = p.noAntrian?.split(" |")[0] || p.noAntrian
return (pNoAntrian === nomorAntrian || p.barcode === nomorAntrian) &&
p.kodeKlinik === kodeKlinik.value &&
p.processStage === 'klinik-ruang'
})
if (patientIndex !== -1) {
// Update patient status to 'di-loket' (called) dan update tipeLayanan serta tracking flags
const oldPatient = { ...queueStore.allPatients[patientIndex] }
const updateData = {
...queueStore.allPatients[patientIndex],
status: 'di-loket',
lastCalledAt: new Date().toISOString(),
lastCalledTipeLayanan: tipeLayanan,
}
// Update tipeLayanan agar pasien muncul di kolom yang sesuai
if (tipeLayanan) {
updateData.tipeLayanan = tipeLayanan
}
// Update tracking flags berdasarkan tipeLayanan
if (tipeLayanan === 'Pemeriksaan Awal') {
updateData.calledPemeriksaanAwal = true
} else if (tipeLayanan === 'Tindakan') {
updateData.calledTindakan = true
}
queueStore.allPatients[patientIndex] = updateData
console.log('✅ Patient data updated in store based on nomor antrian:', nomorAntrian)
console.log('📊 Old:', { status: oldPatient.status, tipeLayanan: oldPatient.tipeLayanan })
console.log('📊 New:', {
status: updateData.status,
tipeLayanan: updateData.tipeLayanan,
calledPemeriksaanAwal: updateData.calledPemeriksaanAwal,
calledTindakan: updateData.calledTindakan
})
} else {
// If patient doesn't exist, log warning
console.warn('⚠️ Patient not found in store with nomor antrian:', nomorAntrian)
console.log('📋 Available patients:', klinikPatients.value.map(p => ({
noAntrian: p.noAntrian?.split(" |")[0],
barcode: p.barcode,
kodeKlinik: p.kodeKlinik
})))
}
} else {
console.log('️ Message format is invalid or missing noantrian:', messageData)
}
},
onOpen: () => {
console.log('✅ Anjungan WebSocket connected successfully!')
console.log('🆔 Connected as:', anjunganClientId.value)
isConnected.value = true
},
onClose: () => {
console.log('⚠️ Anjungan WebSocket closed')
isConnected.value = false
},
onError: (error) => {
console.error('❌ Anjungan WebSocket error:', error)
isConnected.value = false
},
reconnectInterval: 3000,
maxReconnectAttempts: 10,
})
// Watch connection status
if (wsInstance.isConnected) {
watch(wsInstance.isConnected, (connected) => {
isConnected.value = connected
})
}
return wsInstance
}
// Responsive scaling function
const updateScale = () => {
const container = document.querySelector('.antrian-display-container')
if (!container) return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const baseWidth = 1920
const baseHeight = 1080
// Calculate scale factors
const scaleX = viewportWidth / baseWidth
const scaleY = viewportHeight / baseHeight
// Use smaller scale to fit both dimensions
const scale = Math.min(scaleX, scaleY)
// Apply scale using CSS variable
container.style.setProperty('--scale-factor', scale.toString())
// Adjust container dimensions for smaller screens
if (viewportWidth < baseWidth || viewportHeight < baseHeight) {
container.style.width = `${baseWidth}px`
container.style.height = `${baseHeight}px`
}
}
onMounted(() => {
// Redirect to index if klinik not found
if (!klinikData.value) {
@@ -378,27 +551,60 @@ onMounted(() => {
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Initialize and connect WebSocket
if (kodeKlinik.value) {
wsInstance = initWebSocket()
if (wsInstance) {
wsInstance.connect()
console.log('🚀 WebSocket connection initiated')
}
}
// Setup responsive scaling
updateScale()
window.addEventListener('resize', updateScale)
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
if (wsInstance) {
wsInstance.disconnect()
console.log('🔌 WebSocket disconnected')
}
window.removeEventListener('resize', updateScale)
})
// Watch for clientId changes and reconnect if needed
watch(anjunganClientId, (newClientId, oldClientId) => {
if (newClientId && newClientId !== oldClientId && wsInstance) {
console.log('🔄 Client ID changed, reconnecting...')
wsInstance.disconnect()
wsInstance = initWebSocket()
if (wsInstance) {
wsInstance.connect()
}
}
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: var(--color-neutral-300);
min-height: 100vh;
max-width: 1920px;
max-height: 1080px;
width: 100vw;
height: 100vh;
width: 1920px;
height: 1080px;
padding: 24px;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
box-sizing: border-box;
// Responsive scaling menggunakan CSS variables dan JavaScript
transform-origin: top center;
--scale-factor: 1;
transform: scale(var(--scale-factor));
}
/* ========== HEADER ========== */
@@ -660,17 +866,7 @@ onUnmounted(() => {
text-align: center;
}
.empty-state {
text-align: center;
padding: 32px 0;
}
.empty-text {
font-size: 16px;
color: var(--color-neutral-600);
margin-top: 12px;
font-weight: 600;
}
// Empty state removed - only show in individual columns
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
@@ -745,12 +941,112 @@ onUnmounted(() => {
justify-content: flex-end;
}
.ws-status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
margin-left: 16px;
}
.ws-status-indicator.ws-connected {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
.ws-status-indicator.ws-disconnected {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
}
.ws-status-text {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ========== RESPONSIVE ========== */
@media (min-width: 1920px) and (min-height: 1080px) {
.antrian-display-container {
width: 1920px;
height: 1080px;
padding: 24px;
// Base responsive rules untuk komponen individual
@media (max-width: 1919px) or (max-height: 1079px) {
// Adjust font sizes and spacing secara proporsional
.display-header {
padding: clamp(12px, 1.25vw, 24px) clamp(20px, 2.08vw, 40px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.logo-circle {
width: clamp(48px, 4.17vw, 80px);
height: clamp(48px, 4.17vw, 80px);
}
.hospital-name {
font-size: clamp(28px, 2.5vw, 48px);
}
.display-subtitle {
font-size: clamp(14px, 1.04vw, 20px);
}
.time-large {
font-size: clamp(36px, 2.92vw, 56px);
}
.date-small {
font-size: clamp(12px, 0.94vw, 18px);
}
.hero-call-section {
padding: clamp(16px, 1.67vw, 32px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.call-label {
font-size: clamp(20px, 1.46vw, 28px);
}
.call-number {
font-size: clamp(80px, 7.29vw, 140px);
}
.call-clinic {
font-size: clamp(24px, 1.88vw, 36px);
}
.clinics-grid {
gap: clamp(8px, 0.83vw, 16px);
margin-bottom: clamp(12px, 1.85vw, 20px);
}
.clinic-header-bar {
padding: clamp(10px, 1.04vw, 16px) clamp(12px, 1.04vw, 20px);
}
.clinic-title {
font-size: clamp(16px, 1.15vw, 22px);
}
.clinic-content {
padding: clamp(12px, 1.04vw, 20px);
}
.current-number {
font-size: clamp(56px, 4.17vw, 80px);
}
.footer-stats-bar {
padding: clamp(12px, 1.04vw, 20px) clamp(24px, 2.08vw, 40px);
gap: clamp(16px, 1.67vw, 32px);
}
.stat-value {
font-size: clamp(28px, 2.08vw, 40px);
}
.stat-label {
font-size: clamp(11px, 0.73vw, 14px);
}
}