Files
web-antrean/pages/Anjungan/AntreanMasuk/[id].vue
Fanrouver f3b50bd0a3 upade
2026-01-21 16:38:26 +07:00

1341 lines
32 KiB
Vue

<!-- pages/Anjungan/AntreanMasuk/[id].vue -->
<template>
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
<div class="logo-circle">
<img
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
>
</div>
<div class="header-text">
<h1 class="hospital-name">RSUD dr. Saiful Anwar Provinsi Jawa Timur</h1>
<!-- <p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p> -->
</div>
</div>
<div class="header-right">
<div class="datetime-display">
<div class="time-large">{{ currentTime }}</div>
<div class="date-small">{{ currentDate }}</div>
</div>
</div>
</div>
<!-- Main Grid - Display Loket Cards -->
<div class="loket-grid-container">
<!-- Loket Grid -->
<div class="lokets-grid" :style="gridStyle">
<div
v-for="loketData in displayedLokets"
:key="loketData.id"
class="loket-card"
>
<!-- Loket Header -->
<div class="loket-header-bar">
<div class="loket-header-content">
<span class="loket-name">{{ loketData.nama }}</span>
<v-chip
size="default"
class="ml-2 loket-header-chip"
color="primary-700"
>
<v-icon start size="20" color="white">mdi-account-multiple</v-icon>
{{ loketData.totalQueues }}
</v-chip>
</div>
</div>
<!-- Queue Display - Vertical Scrollable -->
<div
:ref="el => setQueueDisplayRef(loketData.id, el)"
class="queue-display"
:class="{
'has-scroll': loketData.queues.length > 20,
'ticker-scroll': loketData.queues.length > 0
}"
>
<!-- Ticket Cards List - Infinite Ticker -->
<div v-if="loketData.queues.length > 0" class="ticket-cards-wrapper">
<div
class="ticket-cards-list ticker-container"
:style="ticketGridStyle"
>
<!-- Original Content -->
<div
v-for="(queue, index) in loketData.queues"
:key="`${loketData.id}-${queue.no}-${queue.barcode || index}`"
class="ticket-card"
:class="{ 'ticket-card-fast-track': queue.fastTrack === 'YA' }"
>
<!-- Fast Track Badge - Pojok Kanan Atas -->
<div v-if="queue.fastTrack === 'YA'" class="ticket-fast-track">
<v-icon color="white" size="14">mdi-flash</v-icon>
</div>
<div class="ticket-number">{{ queue.noAntrian?.split(" |")[0] || queue.no || '' }}</div>
</div>
<!-- Duplicated Content for Seamless Infinite Loop -->
<div
v-for="(queue, index) in loketData.queues"
:key="`${loketData.id}-dup-${queue.no}-${queue.barcode || index}`"
class="ticket-card"
:class="{ 'ticket-card-fast-track': queue.fastTrack === 'YA' }"
>
<!-- Fast Track Badge - Pojok Kanan Atas -->
<div v-if="queue.fastTrack === 'YA'" class="ticket-fast-track">
<v-icon color="white" size="14">mdi-flash</v-icon>
</div>
<div class="ticket-number">{{ queue.noAntrian?.split(" |")[0] || queue.no || '' }}</div>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-else
class="empty-queue"
>
<v-icon size="64" color="grey-lighten-2" class="empty-icon">mdi-ticket-outline</v-icon>
<div class="empty-text">Tidak Ada Antrian</div>
<div class="empty-subtext">Antrian akan muncul di sini</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer Stats -->
<div class="footer-stats-bar">
<div class="stat-item">
<div class="stat-icon stat-icon-total">
<v-icon size="32" color="primary-600">mdi-format-list-numbered</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.total ?? 0 }}</div>
<div class="stat-label">Total Antrian</div>
</div>
</div>
<div class="stat-divider" />
<div class="stat-item">
<div class="stat-icon stat-icon-waiting">
<v-icon size="32" color="primary-600">mdi-clock-alert-outline</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.waiting ?? 0 }}</div>
<div class="stat-label">Menunggu</div>
</div>
</div>
<div class="stat-divider" />
<div class="stat-item">
<div class="stat-icon stat-icon-active">
<v-icon size="32" color="primary-600">mdi-account-check</v-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ statistics?.active ?? 0 }}</div>
<div class="stat-label">Dipanggil untuk Check-in</div>
</div>
</div>
<div class="footer-message">
<v-icon size="24" color="primary-600" class="mr-2">mdi-information</v-icon>
<span>Harap perhatikan nomor tiket Anda yang dipanggil untuk check-in</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useQueueStore } from '@/stores/queueStore'
import { useMasterStore } from '@/stores/masterStore'
import { useAntreanMasukScreenStore } from '@/stores/antreanMasukScreenStore'
import { useRoute } from '#app'
definePageMeta({
layout: false,
})
const route = useRoute()
const queueStore = useQueueStore()
const masterStore = useMasterStore()
const antreanMasukScreenStore = useAntreanMasukScreenStore()
// Get screen ID from route (not loket ID)
const screenId = computed(() => {
const id = route.params.id
// Handle both string and array cases
const idValue = Array.isArray(id) ? id[0] : id
const parsed = parseInt(idValue)
return isNaN(parsed) ? null : parsed
})
// Get screen configuration
const screenData = computed(() => {
const id = screenId.value
if (!id) return null
const screen = antreanMasukScreenStore.antreanMasukScreenItems.find(s => s.id === id)
return screen || null
})
// Get all loket IDs configured for this screen
const configuredLoketIds = computed(() => {
return screenData.value?.loket || []
})
const currentTime = ref('')
const currentDate = ref('')
let timeInterval = null
// Toggle untuk menyembunyikan/menampilkan loket tanpa antrian
// true = sembunyikan loket tanpa antrian (default)
// false = tampilkan semua loket termasuk yang tanpa antrian
const hideEmptyLokets = ref(false)
// Refs for queue display containers (for auto-scroll)
const queueDisplayRefs = ref({})
const setQueueDisplayRef = (loketId, el) => {
if (el) {
queueDisplayRefs.value[loketId] = el
}
}
// Get all patients with processStage 'loket'
// Force reactivity by accessing the computed result directly
const loketPatients = computed(() => {
try {
// Get the computed result from store
const stageResult = queueStore.getPatientsByStage('loket')
// Access .value to get the actual computed value
// This ensures reactivity after store hydration
if (stageResult && stageResult.value) {
const patients = stageResult.value.all || []
// Ensure it's an array and filter out any null/undefined
return Array.isArray(patients) ? patients.filter(p => p != null) : []
}
return []
} catch (error) {
console.error('Error getting loket patients:', error)
return []
}
})
// Filter: Only show tickets from anjungan that are called for check-in
// These are tickets printed from anjungan that have been called by admin loket
// Status berubah dari 'menunggu' menjadi 'waiting' ketika dipanggil oleh admin loket
// Status 'waiting' = sudah dipanggil tapi belum check-in
// Filter berdasarkan loket yang dikonfigurasi di screen
const calledForCheckIn = computed(() => {
const loketIds = configuredLoketIds.value
// Jika tidak ada loket yang dikonfigurasi, return empty array
if (!loketIds || loketIds.length === 0) {
return []
}
return loketPatients.value
.filter(p => {
// Must be called (waiting status = sudah dipanggil dari status menunggu)
// and not yet checked in (processStage still 'loket')
const isCalled = p.status === 'waiting' && p.processStage === 'loket'
// Filter berdasarkan loketId yang dikonfigurasi di screen
const patientLoketId = p.loketId || 1 // Default ke loket 1 jika tidak ada
const isForConfiguredLoket = loketIds.includes(patientLoketId)
return isCalled && isForConfiguredLoket
})
// Tidak perlu sorting - tampilkan semua sesuai urutan dari store
})
// Removed displayedQueues - tidak diperlukan lagi karena menggunakan allCalledQueues
// Group queues by loketId and create loket cards
const displayedLokets = computed(() => {
const loketIds = configuredLoketIds.value
if (!loketIds || loketIds.length === 0) {
return []
}
// Ensure calledForCheckIn is an array
const queues = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
// Group queues by loketId
const queuesByLoket = new Map()
loketIds.forEach(loketId => {
const loket = masterStore.getLoketById(loketId)
const loketQueues = queues
.filter(q => {
const patientLoketId = q.loketId || 1
return patientLoketId === loketId
})
.sort((a, b) => {
// Sort by createdAt or no for consistent ordering
if (a.createdAt && b.createdAt) {
return new Date(a.createdAt) - new Date(b.createdAt)
}
return (a.no || 0) - (b.no || 0)
})
queuesByLoket.set(loketId, {
id: loketId,
nama: loket ? loket.namaLoket : `Loket ${loketId}`,
queues: loketQueues,
totalQueues: loketQueues.length
})
})
// Filter: hanya tampilkan loket yang memiliki antrian (jika hideEmptyLokets = true)
const allLokets = Array.from(queuesByLoket.values())
if (hideEmptyLokets.value) {
return allLokets.filter(loket => loket.queues.length > 0)
}
return allLokets
})
// Calculate grid columns based on number of displayed lokets
// Maksimal 7 loket per row
const gridColumns = computed(() => {
const count = displayedLokets.value.length
// Jika jumlah loket <= 7, tampilkan sesuai jumlah (1-7 kolom)
// Jika lebih dari 7, tetap maksimal 7 kolom per row
return Math.min(count, 14)
})
const gridStyle = computed(() => {
return {
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`
}
})
// Calculate ticket grid columns based on loket grid columns
// When loket is wider (fewer columns), tickets can form more columns
const ticketGridColumns = computed(() => {
const loketCols = gridColumns.value
// Jika hanya 1 kolom loket (full width), tiket bisa 3 kolom
if (loketCols === 1) return 3
// Jika 2 kolom loket, tiket bisa 2 kolom
if (loketCols === 2) return 2
// Jika 3 kolom loket, tiket bisa 2 kolom
if (loketCols === 3) return 2
// Jika 4+ kolom loket, tiket 1 kolom (vertikal)
return 1
})
const ticketGridStyle = computed(() => {
return {
gridTemplateColumns: `repeat(${ticketGridColumns.value}, 1fr)`
}
})
const statistics = computed(() => {
// Ensure we always return default values even if data is not ready
try {
const loketIds = configuredLoketIds.value
// Jika tidak ada loket yang dikonfigurasi, return 0
if (!loketIds || loketIds.length === 0) {
return {
total: 0,
waiting: 0,
active: 0
}
}
// Ensure loketPatients is an array
const patients = Array.isArray(loketPatients.value) ? loketPatients.value : []
// Filter semua pasien untuk loket-loket yang dikonfigurasi di screen
const allLoketTickets = patients.filter(p => {
if (!p) return false
// Filter berdasarkan loketId yang dikonfigurasi
const patientLoketId = p.loketId || 1 // Default ke loket 1 jika tidak ada
return loketIds.includes(patientLoketId)
})
// Menunggu = belum dipanggil (status 'menunggu')
const menungguCount = allLoketTickets.filter(p => p && p.status === 'menunggu').length
// Dipanggil untuk check-in = sudah dipanggil (status 'waiting')
const calledForCheckInArray = Array.isArray(calledForCheckIn.value) ? calledForCheckIn.value : []
const activeCount = calledForCheckInArray.length
return {
total: allLoketTickets.length || 0,
waiting: menungguCount || 0, // Jumlah yang masih menunggu untuk dipanggil
active: activeCount || 0 // Jumlah yang sudah dipanggil dan ditampilkan di layar
}
} catch {
// Return default values if there's any error
return {
total: 0,
waiting: 0,
active: 0
}
}
})
const updateTime = () => {
const now = new Date()
// Format jam dengan titik dua (HH:MM:SS)
const timeString = now.toLocaleTimeString('id-ID', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
currentTime.value = timeString.replace(/\./g, ':')
currentDate.value = now.toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})
}
// Ticker scroll dengan smooth continuous animation
let tickerAnimations = {}
const startAutoScroll = () => {
// Clear existing animations
Object.values(tickerAnimations).forEach(animation => {
if (animation) {
cancelAnimationFrame(animation.frameId)
}
})
tickerAnimations = {}
nextTick(() => {
displayedLokets.value.forEach(loketData => {
const container = queueDisplayRefs.value[loketData.id]
if (container && loketData.queues.length > 0) {
const maxScroll = container.scrollHeight - container.clientHeight
// Jika konten tidak cukup untuk di-scroll, skip
if (maxScroll <= 0) {
return
}
// Tunggu sebentar untuk memastikan DOM sudah render dengan sempurna
setTimeout(() => {
// Hitung tinggi konten asli dengan lebih akurat menggunakan scrollHeight
// Karena konten di-duplicate, scrollHeight total = 2x contentHeight
const totalScrollHeight = container.scrollHeight
const contentHeight = totalScrollHeight / 2
// Pastikan contentHeight valid
if (contentHeight <= 0 || contentHeight > maxScroll) {
// Fallback: hitung berdasarkan card pertama
const firstCard = container.querySelector('.ticket-card')
if (firstCard) {
const cardHeight = firstCard.offsetHeight
const gap = 10
const totalCards = loketData.queues.length
const gridCols = ticketGridColumns.value || 1
const rows = Math.ceil(totalCards / gridCols)
const calculatedHeight = (cardHeight + gap) * rows
if (calculatedHeight > 0) {
contentHeight = calculatedHeight
} else {
return // Skip jika tidak bisa dihitung
}
} else {
return // Skip jika tidak ada card
}
}
let currentScrollTop = 0
const scrollSpeed = 0.2 // pixels per frame (smooth ticker speed - diperlambat)
const animate = () => {
currentScrollTop += scrollSpeed
// Infinite loop: gunakan modulo untuk seamless transition tanpa jitter
// Modulo memastikan transisi smooth tanpa jump
if (currentScrollTop >= contentHeight) {
// Reset dengan modulo untuk seamless loop
currentScrollTop = currentScrollTop % contentHeight
// Pastikan tidak ada nilai negatif
if (currentScrollTop < 0) {
currentScrollTop = 0
}
}
// Set scrollTop langsung (lebih smooth daripada scrollTo)
container.scrollTop = currentScrollTop
tickerAnimations[loketData.id] = {
frameId: requestAnimationFrame(animate)
}
}
// Start animation
tickerAnimations[loketData.id] = {
frameId: requestAnimationFrame(animate)
}
}, 150) // Delay untuk memastikan DOM dan layout sudah selesai render
}
})
})
}
// Watch loketPatients to ensure reactivity after refresh
watch(loketPatients, (newPatients) => {
// Force update by accessing the computed
// This ensures reactivity after store hydration
if (newPatients && newPatients.length > 0) {
console.log('Loket patients updated:', newPatients.length)
}
}, { immediate: true, deep: true })
// Watch store's getPatientsByStage to ensure reactivity after refresh
watch(() => {
try {
const result = queueStore.getPatientsByStage('loket')
return result?.value?.all || []
} catch {
return []
}
}, (newPatients) => {
// Force reactivity update
if (newPatients && newPatients.length > 0) {
console.log('Store loket patients updated:', newPatients.length)
}
}, { immediate: true, deep: true })
// Watch displayedLokets untuk restart auto-scroll
watch(displayedLokets, () => {
startAutoScroll()
}, { deep: true })
onMounted(() => {
// Wait for stores to be hydrated from persisted state
// Use nextTick to ensure stores are ready
nextTick(() => {
// Redirect to index if screen not found
if (!screenData.value) {
navigateTo('/anjungan/antreanmasuk')
return
}
// Force reactivity by accessing store data after delays
// This ensures store is fully hydrated after refresh
setTimeout(() => {
// Access loketPatients to trigger computed
const _ = loketPatients.value
// Access calledForCheckIn to trigger computed
const __ = calledForCheckIn.value
// Access displayedLokets to trigger computed
const ___ = displayedLokets.value
}, 100)
// Additional delay to ensure store hydration is complete
setTimeout(() => {
// Force re-computation by accessing again
const _ = loketPatients.value
const __ = calledForCheckIn.value
const ___ = displayedLokets.value
}, 300)
updateTime()
timeInterval = setInterval(updateTime, 1000)
// Start auto-scroll setelah delay singkat
setTimeout(() => {
startAutoScroll()
}, 500)
})
})
onUnmounted(() => {
if (timeInterval) clearInterval(timeInterval)
// Clear all ticker animations
Object.values(tickerAnimations).forEach(animation => {
if (animation && animation.frameId) {
cancelAnimationFrame(animation.frameId)
}
})
tickerAnimations = {}
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: #FFFFFF;
width: 100vw;
height: 100vh;
min-height: 100vh;
padding: 16px;
font-family: 'Roboto', sans-serif;
overflow: auto;
display: flex;
flex-direction: column;
margin: 0;
box-sizing: border-box;
position: relative;
}
/* Ensure body/html don't cause layout issues */
:deep(body),
:deep(html) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
:deep(#__nuxt) {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
/* ========== HEADER ========== */
.display-header {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 12px;
padding: 20px 32px;
margin-bottom: 16px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
flex-shrink: 0;
box-sizing: border-box;
}
.header-left {
display: flex;
align-items: center;
gap: 20px;
}
.logo-circle {
background: rgba(255, 255, 255, 0.95);
width: 70px;
height: 70px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-logo {
width: 56px;
height: 56px;
object-fit: contain;
padding: 4px;
}
.header-text {
flex: 1;
}
.hospital-name {
font-size: 32px;
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 1px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1.1;
}
.display-subtitle {
font-size: 20px;
font-weight: 600;
color: var(--color-neutral-100);
margin: 6px 0 0 0;
opacity: 0.95;
}
.klinik-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.loket-info {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-100);
margin: 4px 0 0 0;
opacity: 0.85;
}
.header-right {
display: flex;
align-items: center;
}
.datetime-display {
text-align: right;
}
.time-large {
font-size: 64px;
font-weight: 900;
color: var(--color-neutral-100);
letter-spacing: 2px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-small {
font-size: 14px;
color: var(--color-neutral-100);
margin-top: 6px;
font-weight: 500;
opacity: 0.95;
}
/* ========== LOKET GRID CONTAINER ========== */
.loket-grid-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
}
.lokets-grid {
display: grid;
gap: 12px;
width: 100%;
flex: 1;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
/* grid-template-columns is set dynamically via :style binding */
}
.loket-card {
background: #FFFFFF;
border-radius: 8px;
border: 1px solid #E0E0E0;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: 200px;
/* Tidak set max-height untuk allow card fleksibel */
}
/* Loket Header */
.loket-header-bar {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
color: var(--color-neutral-100);
padding: 14px 18px;
box-shadow: 0 2px 8px rgba(58, 97, 201, 0.2);
border-radius: 8px 8px 0 0;
flex-shrink: 0;
}
.loket-header-content {
display: flex;
align-items: center;
font-weight: 700;
font-size: 16px;
gap: 8px;
width: 100%;
}
.loket-name {
flex: 1;
color: var(--color-neutral-100);
font-weight: 700;
font-size: 24px;
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
}
.loket-header-chip {
color: var(--color-neutral-100) !important;
font-weight: 700;
font-size: 16px;
height: 32px !important;
padding: 0 16px !important;
min-width: auto !important;
}
.loket-header-chip .v-icon {
margin-right: 6px;
}
/* Queue Display - Vertical Scrollable */
.queue-display {
flex: 1;
padding: 16px;
padding-bottom: 5vh;
overflow: hidden;
background: #FFFFFF;
min-height: 0;
box-sizing: border-box;
position: relative;
perspective: 1000px;
transform-style: preserve-3d;
}
.queue-display.has-scroll {
max-height: calc(100vh - 400px);
overflow-y: hidden;
}
/* Ticker scroll effect */
.queue-display.ticker-scroll {
overflow-y: hidden;
}
.ticket-cards-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.ticker-container {
will-change: transform;
transform: translateZ(0); /* Hardware acceleration */
display: grid;
gap: 10px;
width: 100%;
padding-bottom: 20px;
}
.ticket-cards-list {
display: grid;
gap: 10px;
width: 100%;
padding-bottom: 20px; /* Additional bottom padding for last card */
/* grid-template-columns is set dynamically via :style binding */
}
.ticket-card {
background: var(--color-neutral-400, #E1E5EA);
border: 1px solid var(--color-neutral-500, #CDD4DC);
border-radius: 8px;
padding: 20px 28px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
min-height: 100px;
position: relative;
cursor: default;
}
.ticket-card-fast-track {
background: var(--color-secondary-50, rgba(255, 132, 65, 0.15)) !important;
border-color: var(--color-secondary-300, rgba(255, 132, 65, 0.4)) !important;
border-width: 2px;
}
.ticket-card:hover {
box-shadow: 0 4px 12px rgba(58, 97, 201, 0.15);
border-color: var(--color-primary-500, #567EE7);
transform: translateY(-2px);
background: var(--color-neutral-300, #F5F7FA);
}
.ticket-card-fast-track:hover {
background: var(--color-secondary-100, rgba(255, 132, 65, 0.2)) !important;
border-color: var(--color-secondary-500, #FF8441) !important;
}
.ticket-number {
font-size: 40px;
font-weight: 800;
color: var(--color-primary-600, #3A61C9);
letter-spacing: 1px;
line-height: 1.1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.ticket-klinik {
font-size: 11px;
font-weight: 500;
color: #717171;
text-align: center;
margin-top: 4px;
}
.ticket-fast-track {
position: absolute;
top: 6px;
right: 6px;
background: var(--color-danger-600, #DE473B);
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(222, 71, 59, 0.4);
z-index: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(239, 83, 80, 0.15); /* Red background dengan opacity */
border: 2px solid rgba(229, 57, 53, 0.4); /* Red border */
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 2px 8px rgba(229, 57, 53, 0.2);
}
.ticket-fast-track:hover {
background: #E53935; /* Solid red on hover */
border-color: #E53935;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(229, 57, 53, 0.4);
}
.ticket-fast-track .v-icon {
color: #E53935 !important;
transition: all 0.2s ease;
font-size: 16px !important;
}
.ticket-fast-track:hover .v-icon {
color: white !important;
}
.empty-queue {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
min-height: 300px;
color: var(--color-neutral-600, #89939E);
}
.empty-icon {
margin-bottom: 20px;
opacity: 0.6;
transition: all 0.3s ease;
font-size: 64px !important;
}
.empty-queue:hover .empty-icon {
opacity: 0.8;
transform: scale(1.05);
}
.empty-text {
font-size: 18px;
font-weight: 600;
color: var(--color-neutral-700, #717171);
margin-bottom: 8px;
}
.empty-subtext {
font-size: 13px;
font-weight: 400;
color: var(--color-neutral-600, #89939E);
opacity: 0.8;
}
/* ========== FOOTER STATS ========== */
.footer-stats-bar {
background: #FFFFFF;
border: 2px solid var(--color-primary-200);
border-radius: 14px;
padding: 20px 40px;
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 24px;
box-shadow: 0 4px 16px rgba(25, 118, 210, 0.08);
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
margin-top: auto;
min-height: 90px;
height: auto;
}
.stat-item {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 70px;
}
.stat-icon {
width: 60px;
height: 60px;
min-width: 60px;
min-height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon-total,
.stat-icon-waiting {
background: var(--color-primary-200);
}
.stat-icon-active {
background: var(--color-primary-200);
}
.stat-info {
text-align: left;
flex-shrink: 0;
min-width: fit-content;
width: auto;
height: 70px;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-value {
font-size: 52px;
font-weight: 900;
color: #212121;
line-height: 1;
white-space: nowrap;
height: auto;
display: flex;
align-items: center;
}
.stat-label {
font-size: 14px;
color: #717171;
font-weight: 600;
margin-top: 6px;
height: auto;
line-height: 1.2;
}
.stat-divider {
width: 2px;
height: 70px;
min-width: 2px;
min-height: 70px;
background: var(--color-primary-200);
flex-shrink: 0;
}
.footer-message {
flex: 1;
text-align: right;
font-size: 20px;
font-weight: 600;
color: #4D4D4D;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 70px;
padding-left: 24px;
}
/* ========== RESPONSIVE ========== */
/* Fullscreen layout - no fixed dimensions to prevent breaking on refresh */
@media (max-width: 1366px) {
.antrian-display-container {
padding: 16px;
}
.display-header {
padding: 16px 24px;
}
.logo-circle {
width: 64px;
height: 64px;
}
.logo-circle .v-icon {
font-size: 40px !important;
}
.hospital-name {
font-size: 36px;
}
.display-subtitle {
font-size: 16px;
}
.time-large {
font-size: 48px;
}
.date-small {
font-size: 14px;
}
.hero-call-section {
padding: 24px;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
.loket-name {
font-size: 20px;
}
.ticket-number {
font-size: 28px;
}
.footer-stats-bar {
padding: 16px 32px;
}
.stat-icon {
width: 56px;
height: 56px;
}
.stat-icon .v-icon {
font-size: 28px !important;
}
.stat-value {
font-size: 36px;
}
.stat-label {
font-size: 13px;
}
.stat-divider {
height: 50px;
}
.footer-message {
font-size: 18px;
}
}
@media (max-width: 1024px) {
.display-header {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-left {
flex-direction: column;
gap: 12px;
}
.datetime-display {
text-align: center;
}
.footer-stats-bar {
flex-direction: column;
gap: 16px;
}
.stat-divider {
display: none;
}
.footer-message {
text-align: center;
justify-content: center;
}
}
@media (max-width: 960px) {
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
.ticket-number {
font-size: 20px;
color: #3556AE;
}
.ticket-card {
min-height: 70px;
padding: 10px;
}
}
@media (min-width: 961px) and (max-width: 1400px) {
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
}
}
@media (min-width: 1401px) and (max-width: 1919px) {
.lokets-grid {
gap: 14px;
/* grid-template-columns is set dynamically via :style binding */
}
}
/* Fixed layout for 1920x1080 and above */
@media (min-width: 1920px) {
.lokets-grid {
gap: 16px;
/* grid-template-columns is set dynamically via :style binding */
}
}
/* Untuk layar SANGAT BESAR (55" dan lebih) - 2560px+ */
@media (min-width: 2560px) {
.antrian-display-container {
padding: clamp(32px, 2.5vw, 48px);
}
.hospital-name {
font-size: clamp(48px, 6vw, 96px);
}
.time-large {
font-size: clamp(56px, 8vw, 120px);
}
.loket-name {
font-size: clamp(32px, 5vw, 80px);
}
.loket-header-chip {
font-size: clamp(16px, 2.5vw, 36px);
height: clamp(40px, 4vw, 72px) !important;
padding: 0 clamp(16px, 2.5vw, 40px) !important;
}
.ticket-number {
font-size: clamp(48px, 6.5vw, 104px);
}
.ticket-card {
min-height: clamp(100px, 12.5vh, 220px);
padding: clamp(16px, 2.2vw, 40px) clamp(24px, 3vw, 56px);
}
.queue-display {
padding-bottom: clamp(160px, 18vh, 240px);
}
.stat-value {
font-size: clamp(64px, 8vw, 128px);
}
.stat-label {
font-size: clamp(18px, 2.5vw, 40px);
}
.footer-message {
font-size: clamp(28px, 4vw, 56px);
height: clamp(80px, 10vh, 160px);
}
.stat-item {
height: clamp(80px, 10vh, 160px);
gap: clamp(16px, 2.5vw, 40px);
}
.stat-icon {
width: clamp(80px, 10vh, 160px);
height: clamp(80px, 10vh, 160px);
min-width: clamp(80px, 10vh, 160px);
min-height: clamp(80px, 10vh, 160px);
}
.stat-divider {
height: clamp(80px, 10vh, 160px);
min-height: clamp(80px, 10vh, 160px);
}
.stat-info {
height: clamp(80px, 10vh, 160px);
}
.footer-stats-bar {
padding: clamp(24px, 3.5vw, 48px) clamp(40px, 5vw, 80px);
gap: clamp(24px, 3vw, 48px);
}
}
@media (max-width: 768px) {
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.loket-name {
font-size: 18px;
}
.ticket-number {
font-size: 20px;
color: #3556AE;
}
}
/* Prevent text selection */
* {
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>