##update fix session dan tampilan screen
This commit is contained in:
+29
-11
@@ -10,6 +10,7 @@ export interface WebSocketConfig {
|
||||
onError?: (error: Event) => void
|
||||
reconnectInterval?: number
|
||||
maxReconnectAttempts?: number
|
||||
fallbackPostUrl?: string // Optional: use a specific URL for the POST fallback (e.g., a proxy)
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
@@ -24,7 +25,18 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
const reconnectTimer = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
const wsUrl = computed(() => {
|
||||
const url = new URL(config.url)
|
||||
let baseUrl = config.url
|
||||
|
||||
// Automatically upgrade to secure protocols if caller uses HTTPS
|
||||
if (typeof window !== 'undefined' && window.location.protocol === 'https:') {
|
||||
if (baseUrl.startsWith('ws://')) {
|
||||
baseUrl = baseUrl.replace('ws://', 'wss://')
|
||||
} else if (baseUrl.startsWith('http://')) {
|
||||
baseUrl = baseUrl.replace('http://', 'https://')
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl)
|
||||
url.searchParams.set('client_id', config.clientId)
|
||||
return url.toString()
|
||||
})
|
||||
@@ -106,17 +118,23 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
// 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://')
|
||||
let postUrl = ''
|
||||
|
||||
if (config.fallbackPostUrl) {
|
||||
// Use the explicitly provided fallback URL (likely a relative proxy path)
|
||||
postUrl = config.fallbackPostUrl
|
||||
} else {
|
||||
// Derive post URL from WebSocket URL (legacy behavior)
|
||||
let baseUrl = config.url
|
||||
if (baseUrl.startsWith('ws://')) {
|
||||
baseUrl = baseUrl.replace('ws://', 'http://')
|
||||
} else if (baseUrl.startsWith('wss://')) {
|
||||
baseUrl = baseUrl.replace('wss://', 'https://')
|
||||
}
|
||||
|
||||
const urlObj = new URL(baseUrl)
|
||||
postUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -920,6 +920,7 @@ const adminClientId = `admin-klinik-ruang-${kodeKlinik.value}`;
|
||||
const { sendViaPost } = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: adminClientId,
|
||||
fallbackPostUrl: '/stats-api/ws'
|
||||
});
|
||||
|
||||
const showSnackbar = (message, color = 'success') => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="scaler-wrapper">
|
||||
<div class="antrian-display-container">
|
||||
<div class="antrian-display-container">
|
||||
<!-- Header -->
|
||||
<div class="display-header">
|
||||
<div class="header-left">
|
||||
@@ -17,7 +16,7 @@
|
||||
<div class="header-text">
|
||||
<h1 class="hospital-name">ANTRIAN KLINIK RUANG</h1>
|
||||
<p class="display-subtitle">RSUD dr. Saiful Anwar Provinsi Jawa Timur</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="datetime-display">
|
||||
@@ -38,11 +37,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Main Grid - Display Ruang -->
|
||||
<div class="clinics-grid">
|
||||
<div class="clinics-grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="ruang in displayedRuang"
|
||||
:key="`${ruang.kodeKlinik}-${ruang.nomorRuang}`"
|
||||
class="clinic-box"
|
||||
:class="{ 'is-single-view': displayedRuang.length === 1 }"
|
||||
>
|
||||
<!-- Clinic Header -->
|
||||
<div class="clinic-header-bar">
|
||||
@@ -133,7 +133,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -147,6 +146,16 @@ definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
|
||||
// Set viewport meta tag for proper responsive behavior
|
||||
useHead({
|
||||
meta: [
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const queueStore = useQueueStore()
|
||||
const masterStore = useMasterStore()
|
||||
@@ -290,46 +299,27 @@ const displayedRuang = computed(() => {
|
||||
const priorityDiff = (statusPriority[a.status] || 99) - (statusPriority[b.status] || 99)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
// Sort by queue number
|
||||
const numA = parseInt(a.noAntrian.match(/\d+/)?.[0] || '999')
|
||||
const numB = parseInt(b.noAntrian.match(/\d+/)?.[0] || '999')
|
||||
const numA = parseInt(a.noAntrian.replace(/\D/g, '') || '999')
|
||||
const numB = parseInt(b.noAntrian.replace(/\D/g, '') || '999')
|
||||
return numA - numB
|
||||
})
|
||||
|
||||
// Split by tipeLayanan berdasarkan tipeLayanan terakhir yang dipanggil
|
||||
// Pasien yang dipanggil akan muncul di kolom sesuai tipeLayanan terakhir yang dipanggil
|
||||
// Jika pasien dipanggil untuk Pemeriksaan Awal, muncul di kolom Pemeriksaan Awal
|
||||
// Jika pasien dipanggil untuk Tindakan, muncul di kolom Tindakan (dan hilang dari Pemeriksaan Awal)
|
||||
const pemeriksaanAwalQueues = allQueues.filter(q => {
|
||||
// Gunakan tipeLayanan (yang diupdate saat dipanggil) untuk menentukan kolom
|
||||
return q.tipeLayanan === 'Pemeriksaan Awal';
|
||||
});
|
||||
const tindakanQueues = allQueues.filter(q => {
|
||||
// Gunakan tipeLayanan (yang diupdate saat dipanggil) untuk menentukan kolom
|
||||
return q.tipeLayanan === 'Tindakan';
|
||||
})
|
||||
const pemeriksaanAwalQueues = allQueues.filter(q => q.tipeLayanan === 'Pemeriksaan Awal')
|
||||
const tindakanQueues = allQueues.filter(q => q.tipeLayanan === 'Tindakan')
|
||||
|
||||
// Get current and next queues for each tipeLayanan
|
||||
// Current queue is the one with status 'di-loket' and matching tipeLayanan
|
||||
// Jika ada pasien baru yang dipanggil, akan menggantikan pasien sebelumnya di kolom tersebut
|
||||
const getCurrentAndNext = (queues, tipeLayanan) => {
|
||||
// Find current: pasien dengan status 'di-loket' dan tipeLayanan yang sesuai
|
||||
// Sort by lastCalledAt untuk mendapatkan yang terakhir dipanggil (yang baru dipanggil akan menggantikan yang lama)
|
||||
const currentCandidates = queues.filter(q =>
|
||||
q.status === 'di-loket' && q.tipeLayanan === tipeLayanan
|
||||
).sort((a, b) => {
|
||||
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt) : new Date(a.createdAt || 0)
|
||||
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt) : new Date(b.createdAt || 0)
|
||||
return timeB - timeA // Yang terakhir dipanggil di atas
|
||||
const timeA = a.lastCalledAt ? new Date(a.lastCalledAt).getTime() : 0
|
||||
const timeB = b.lastCalledAt ? new Date(b.lastCalledAt).getTime() : 0
|
||||
return timeB - timeA
|
||||
})
|
||||
// Ambil yang terakhir dipanggil sebagai current (menggantikan yang sebelumnya)
|
||||
const current = currentCandidates.length > 0 ? currentCandidates[0] : null
|
||||
|
||||
// Next queues: exclude current patient dan pasien dengan status 'di-loket' (yang sudah dipanggil)
|
||||
const current = currentCandidates.length > 0 ? currentCandidates[0] : null
|
||||
const next = queues.filter(q =>
|
||||
q.no !== current?.no &&
|
||||
q.status === 'waiting' // Hanya yang waiting (belum dipanggil)
|
||||
).slice(0, 3) // Show max 3 next queues
|
||||
q.no !== current?.no && q.status === 'waiting'
|
||||
).slice(0, 3)
|
||||
return { current, next }
|
||||
}
|
||||
|
||||
@@ -394,6 +384,19 @@ const statistics = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Dynamic grid logic: divide total by 2 to balance across rows, cap at 5 for larger cards
|
||||
// Meniru logika AntrianLoket
|
||||
const gridColumns = computed(() => {
|
||||
const total = displayedRuang.value.length
|
||||
if (total <= 1) return 1
|
||||
if (total <= 5) return total
|
||||
return Math.min(5, Math.ceil(total / 2))
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`
|
||||
}))
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
// Format jam dengan titik dua (HH:MM:SS)
|
||||
@@ -460,9 +463,10 @@ const initWebSocket = () => {
|
||||
console.log('🆔 Client ID:', anjunganClientId.value)
|
||||
|
||||
wsInstance = useWebSocket({
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
onMessage: (data) => {
|
||||
url: wsBaseUrl,
|
||||
clientId: anjunganClientId.value,
|
||||
fallbackPostUrl: '/stats-api/ws',
|
||||
onMessage: (data) => {
|
||||
console.log('📨 WebSocket raw message received:', data)
|
||||
console.log('📨 Message type:', typeof data)
|
||||
|
||||
@@ -562,33 +566,6 @@ const initWebSocket = () => {
|
||||
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) {
|
||||
@@ -607,10 +584,6 @@ onMounted(() => {
|
||||
console.log('🚀 WebSocket connection initiated')
|
||||
}
|
||||
}
|
||||
|
||||
// Setup responsive scaling
|
||||
updateScale()
|
||||
window.addEventListener('resize', updateScale)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -619,7 +592,6 @@ onUnmounted(() => {
|
||||
wsInstance.disconnect()
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
}
|
||||
window.removeEventListener('resize', updateScale)
|
||||
})
|
||||
|
||||
// Watch for clientId changes and reconnect if needed
|
||||
@@ -636,36 +608,16 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.scaler-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-neutral-300);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.antrian-display-container {
|
||||
background: var(--color-neutral-300);
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 24px;
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0; /* Prevent flex from shrinking the fixed-size container */
|
||||
|
||||
/* Responsive scaling menggunakan CSS variables dan JavaScript */
|
||||
transform-origin: center center;
|
||||
--scale-factor: 1;
|
||||
transform: scale(var(--scale-factor));
|
||||
}
|
||||
|
||||
/* ========== HEADER ========== */
|
||||
@@ -723,14 +675,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.klinik-info {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-100);
|
||||
margin: 4px 0 0 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.datetime-display {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -796,10 +740,11 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
/* ========== CLINICS GRID ========== */
|
||||
.clinics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* grid-template-columns set via inline style */
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex: 1;
|
||||
min-height: 0; /* Allow ensuring internal scroll if needed, but we aim for fit */
|
||||
}
|
||||
|
||||
.clinic-box {
|
||||
@@ -808,7 +753,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -822,7 +766,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
}
|
||||
|
||||
.clinic-title {
|
||||
font-size: 22px;
|
||||
font-size: clamp(14px, 1.15vw, 22px);
|
||||
font-weight: 800;
|
||||
color: var(--color-neutral-100);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -860,13 +804,13 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
}
|
||||
|
||||
.tipe-label {
|
||||
font-size: 20px;
|
||||
font-size: clamp(14px, 1.04vw, 20px);
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-700);
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 12px;
|
||||
padding: clamp(4px, 0.42vw, 8px) clamp(8px, 0.63vw, 12px);
|
||||
background: var(--color-primary-200);
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
@@ -899,7 +843,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 80px;
|
||||
font-size: clamp(48px, 4.17vw, 80px);
|
||||
font-weight: 900;
|
||||
color: var(--color-neutral-900);
|
||||
letter-spacing: 4px;
|
||||
@@ -907,42 +851,6 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.next-section {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.next-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-numbers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
background: var(--color-primary-600);
|
||||
color: var(--color-neutral-100);
|
||||
padding: 8px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.25);
|
||||
letter-spacing: 1px;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Empty state removed - only show in individual columns
|
||||
|
||||
/* ========== FOOTER STATS ========== */
|
||||
.footer-stats-bar {
|
||||
background: var(--color-neutral-100);
|
||||
@@ -971,10 +879,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
}
|
||||
|
||||
.stat-icon-total,
|
||||
.stat-icon-waiting {
|
||||
background: var(--color-primary-200);
|
||||
}
|
||||
|
||||
.stat-icon-waiting,
|
||||
.stat-icon-active {
|
||||
background: var(--color-primary-200);
|
||||
}
|
||||
@@ -1016,120 +921,7 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
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 ========== */
|
||||
// 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);
|
||||
}
|
||||
|
||||
.tipe-label {
|
||||
font-size: clamp(16px, 1.04vw, 20px);
|
||||
padding: clamp(6px, 0.42vw, 8px) clamp(10px, 0.63vw, 12px);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
.antrian-display-container {
|
||||
padding: 16px;
|
||||
@@ -1144,104 +936,24 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
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-clinic {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.clinics-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.clinic-box {
|
||||
min-height: 220px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clinic-header-bar {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.clinic-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.clinic-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.current-number {
|
||||
font-size: 72px;
|
||||
}
|
||||
|
||||
.tipe-label {
|
||||
font-size: 18px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.next-item {
|
||||
font-size: 20px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,57 +963,31 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.datetime-display {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clinics-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
|
||||
.footer-stats-bar {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.stat-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.footer-message {
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clinics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hospital-name {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.time-large {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.call-number {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.current-number {
|
||||
font-size: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-highlight {
|
||||
0%, 100% {
|
||||
@@ -1327,5 +1013,67 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
|
||||
/* Single View Optimizations */
|
||||
.clinic-box.is-single-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .clinic-header-bar {
|
||||
padding: 32px 40px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .clinic-title {
|
||||
font-size: 56px; /* Massive title */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .clinic-count {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .count-text {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .clinic-content {
|
||||
padding: 40px;
|
||||
justify-content: space-evenly; /* Distribute vertical space */
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .tipe-layanan-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1; /* Take up available space */
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .tipe-label {
|
||||
font-size: 32px; /* Much larger label */
|
||||
padding: 12px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .current-label {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .current-number {
|
||||
font-size: 180px; /* Enormous number */
|
||||
line-height: 1;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .empty-text-small {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.clinic-box.is-single-view .v-icon {
|
||||
transform: scale(1.5); /* Scale up icons */
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -342,10 +342,23 @@ onMounted(async () => {
|
||||
|
||||
.kliniks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.kliniks-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.kliniks-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.klinik-card {
|
||||
|
||||
@@ -252,10 +252,23 @@ const getKlinikName = (kode, loket = null) => {
|
||||
|
||||
.lokets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.lokets-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.lokets-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.loket-card {
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
|
||||
<v-alert v-if="successMessage" type="success" class="mb-4">
|
||||
{{ successMessage }}
|
||||
</v-alert>
|
||||
|
||||
@@ -5,6 +5,51 @@ export default defineEventHandler(async (event) => {
|
||||
console.log('🔐 Keycloak Login Handler Called')
|
||||
console.log('📍 Method:', getMethod(event))
|
||||
|
||||
// === STALE SESSION CLEANUP ===
|
||||
// Check for existing session and clean up if invalid/expired
|
||||
const existingSessionId = getCookie(event, 'user_session')
|
||||
|
||||
if (existingSessionId) {
|
||||
console.log('🔍 Existing session cookie found, validating...')
|
||||
|
||||
try {
|
||||
const { getSession, deleteSession } = await import('~/server/utils/sessionStore')
|
||||
const session = getSession(existingSessionId)
|
||||
|
||||
if (session) {
|
||||
// Check if session is expired
|
||||
const isExpired = Date.now() > session.expiresAt
|
||||
|
||||
if (isExpired) {
|
||||
console.log('🧹 Cleaning up expired session...')
|
||||
deleteSession(existingSessionId)
|
||||
deleteCookie(event, 'user_session')
|
||||
deleteCookie(event, 'oauth_state')
|
||||
console.log('✅ Expired session cleared')
|
||||
} else {
|
||||
console.log('⚠️ Valid session exists, clearing to allow fresh login...')
|
||||
deleteSession(existingSessionId)
|
||||
deleteCookie(event, 'user_session')
|
||||
deleteCookie(event, 'oauth_state')
|
||||
console.log('✅ Existing session cleared for fresh login')
|
||||
}
|
||||
} else {
|
||||
console.log('🧹 Session cookie exists but no session in store, clearing cookie...')
|
||||
deleteCookie(event, 'user_session')
|
||||
deleteCookie(event, 'oauth_state')
|
||||
console.log('✅ Stale cookie cleared')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Error during session cleanup:', error)
|
||||
// Clear cookies anyway to be safe
|
||||
deleteCookie(event, 'user_session')
|
||||
deleteCookie(event, 'oauth_state')
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No existing session found, proceeding with fresh login')
|
||||
}
|
||||
// === END STALE SESSION CLEANUP ===
|
||||
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// server/api/auth/validate-session.post.ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const sessionId = getCookie(event, 'user_session')
|
||||
|
||||
console.log('🔍 Session validation endpoint called')
|
||||
console.log('🍪 Session cookie exists:', !!sessionId)
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('❌ No session cookie found')
|
||||
return { valid: false, reason: 'no_session' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { getSession, deleteSession } = await import('~/server/utils/sessionStore')
|
||||
const session = getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
console.log('❌ Session not found in store')
|
||||
deleteCookie(event, 'user_session')
|
||||
return { valid: false, reason: 'session_not_found' }
|
||||
}
|
||||
|
||||
// Check local expiry
|
||||
if (Date.now() > session.expiresAt) {
|
||||
console.log('⏰ Session has expired locally')
|
||||
deleteSession(sessionId)
|
||||
deleteCookie(event, 'user_session')
|
||||
return { valid: false, reason: 'session_expired' }
|
||||
}
|
||||
|
||||
// Validate with Keycloak userinfo endpoint
|
||||
try {
|
||||
const userInfoUrl = `${config.keycloakIssuer}/protocol/openid-connect/userinfo`
|
||||
console.log('🔗 Validating with Keycloak:', userInfoUrl)
|
||||
|
||||
const userInfo = await $fetch(userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Session is valid with Keycloak')
|
||||
return {
|
||||
valid: true,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name
|
||||
}
|
||||
}
|
||||
} catch (keycloakError: any) {
|
||||
// If Keycloak returns 401, the session is invalid on their side
|
||||
if (keycloakError.status === 401 || keycloakError.statusCode === 401) {
|
||||
console.log('❌ Keycloak session has expired (401)')
|
||||
deleteSession(sessionId)
|
||||
deleteCookie(event, 'user_session')
|
||||
return { valid: false, reason: 'keycloak_session_expired' }
|
||||
}
|
||||
|
||||
// For other errors, log but don't invalidate the session
|
||||
console.warn('⚠️ Keycloak validation error (non-401):', keycloakError.message)
|
||||
throw keycloakError
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('❌ Session validation error:', error)
|
||||
// On error, clear the session to be safe
|
||||
deleteCookie(event, 'user_session')
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'validation_error',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user