1341 lines
32 KiB
Vue
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>
|
|
|