update layout all

This commit is contained in:
Fanrouver
2026-01-29 08:17:26 +07:00
parent 19633afde1
commit ae8b06d66d
22 changed files with 971 additions and 508 deletions
+66
View File
@@ -0,0 +1,66 @@
# Dokumentasi Implementasi Proxy API (Nuxt 3)
Dokumen ini menjelaskan cara menggunakan dan menambah proxy API di file `nuxt.config.ts` untuk menangani masalah CORS dan menyederhanakan pemanggilan endpoint backend.
## 1. Menambah Proxy Baru di `nuxt.config.ts`
Jika Anda memiliki API backend lain (misalnya departemen berbeda atau IP berbeda), Anda cukup menambah baris baru di dalam objek `routeRules`.
### Contoh Konfigurasi:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// Proxy untuk Statistik (yang sudah ada)
"/stats-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/**",
},
// Proxy untuk API Pasien (Contoh Tambahan)
"/patient-api/**": {
proxy: "http://10.10.150.101:8000/api/v2/**",
},
// Proxy untuk API Antrean (IP sama, prefix path berbeda)
"/queue-api/**": {
proxy: "http://10.10.150.100:8084/api/v1/queue/**",
},
},
});
```
---
## 2. Cara Menggunakan di Composable
Setelah didaftarkan di `nuxt.config.ts`, Anda tidak perlu lagi menulis IP Address di dalam kode Vue/TypeScript Anda. Gunakan path prefix yang sudah didefinisikan.
### Contoh File: `composables/usePatientAPI.ts`
```typescript
export const usePatientAPI = () => {
const fetchPatient = async (id: string) => {
// Alamat asli: http://10.10.150.101:8000/api/v2/patient/123
// Cukup panggil seperti ini:
return await $fetch(`/patient-api/patient/${id}`);
};
return { fetchPatient };
};
```
---
## 3. Keuntungan Menggunakan Proxy
1. **Menghindari CORS**: Browser tidak akan memblokir request karena seolah-olah request dikirim ke server yang sama dengan frontend.
2. **Keamanan**: IP backend asli tidak terekspos secara langsung di kode frontend (Client-side).
3. **Kemudahan Maintenance**: Jika IP Server backend berubah, Anda hanya perlu mengubah satu baris di `nuxt.config.ts` tanpa harus mencari dan mengganti di seluruh file `.vue` atau `.ts`.
---
## 4. Tips Debugging Proxy
- Jika menggunakan **Vite Dev Server**, pastikan Anda menjalankan ulang `npm run dev` setelah mengubah `nuxt.config.ts`.
- Cek tab **Network** di Developer Tools (F12). Anda akan melihat request ke `localhost:3000/stats-api/...` dan server Nuxt akan meneruskannya ke IP backend secara otomatis.
+4 -4
View File
@@ -1,7 +1,7 @@
<template>
<NuxtLayout>
<v-app>
<v-app>
<NuxtLayout>
<NuxtPage />
</v-app>
</NuxtLayout>
</NuxtLayout>
</v-app>
</template>
+48 -22
View File
@@ -1,17 +1,17 @@
/* assets/styles/loginpage/login.scss */
@use '~/assets/scss/_colors.scss' as *;
@use "~/assets/scss/_colors.scss" as *;
/* Main Background - Using Primary Color */
.login-background {
background: $primary-500; /* Primary-500: #567EE7 */
min-height: 100vh;
height: 100dvh;
position: relative;
overflow: hidden;
}
.login-background::before {
/* No radial gradients */
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
@@ -40,16 +40,39 @@
}
.floating-medical-icon .v-icon {
color: rgba(255, 255, 255, 0.15) !important; /* Light white for icons on primary background */
color: rgba(
255,
255,
255,
0.15
) !important; /* Light white for icons on primary background */
}
/* Specific icon positioning - now static */
.icon-1 { top: 15%; left: 5%; }
.icon-2 { top: 20%; right: 10%; }
.icon-3 { bottom: 10%; left: 15%; }
.icon-4 { top: 60%; left: 10%; }
.icon-5 { bottom: 20%; right: 5%; }
.icon-6 { top: 35%; left: 20%; }
.icon-1 {
top: 15%;
left: 5%;
}
.icon-2 {
top: 20%;
right: 10%;
}
.icon-3 {
bottom: 10%;
left: 15%;
}
.icon-4 {
top: 60%;
left: 10%;
}
.icon-5 {
bottom: 20%;
right: 5%;
}
.icon-6 {
top: 35%;
left: 20%;
}
/* Main Card - White */
.main-card {
@@ -122,8 +145,8 @@
color: $neutral-100 !important; /* White text on primary button */
border: none;
box-shadow:
0 8px 25px rgba(86, 126, 231, 0.3), /* Primary shadow */
0 4px 12px rgba(86, 126, 231, 0.2);
0 8px 25px rgba(86, 126, 231, 0.3),
/* Primary shadow */ 0 4px 12px rgba(86, 126, 231, 0.2);
transition: all 0.3s ease;
text-transform: none;
font-size: 1rem;
@@ -148,7 +171,12 @@
}
.register-btn-dark:hover {
background: rgba(86, 126, 231, 0.05) !important; /* Light primary hover background */
background: rgba(
86,
126,
231,
0.05
) !important; /* Light primary hover background */
border-color: $primary-600 !important; /* Primary-600 border on hover */
transform: translateY(-1px);
}
@@ -191,27 +219,26 @@
/* Dialog Accents */
.v-card-title .v-icon,
.v-list-item .v-icon {
color: $primary-500 !important; /* Primary accents */
color: $primary-500 !important; /* Primary accents */
}
.v-btn[color="#FF9B1B"] {
background-color: $primary-500 !important;
color: $neutral-100 !important;
background-color: $primary-500 !important;
color: $neutral-100 !important;
}
.v-alert[color="orange"] {
border-left: 8px solid $primary-500 !important;
color: $neutral-900 !important;
border-left: 8px solid $primary-500 !important;
color: $neutral-900 !important;
}
/* Responsive Design Adjustments for single column */
@media (max-width: 960px) {
.main-card {
margin: 1rem;
max-width: 450px !important; /* Enforce max-width on smaller screens */
}
.login-section-box {
min-height: auto;
padding: 2rem !important;
@@ -219,11 +246,10 @@
}
@media (max-width: 600px) {
.main-card {
margin: 0.5rem;
}
.login-section-box {
padding: 1.5rem !important;
}
+4 -1
View File
@@ -18,6 +18,9 @@
alt="Antrean Logo"
class="sidebar-logo"
:class="{ 'sidebar-logo-rail': rail }"
width="32"
height="32"
style="width: 32px; height: 32px; object-fit: contain;"
/>
</div>
<v-list-item-title
@@ -249,7 +252,7 @@
</v-navigation-drawer>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, onMounted, ref, watch } from "vue";
import { onMounted, ref, watch } from "vue";
import { navigateTo } from "#app";
import { useLocalStorage } from "@vueuse/core";
import ProfilePopup from "./ProfilePopup.vue";
+54
View File
@@ -0,0 +1,54 @@
// composables/useVisitAPI.ts
export interface VisitStats {
today_patients: number;
today_active_queues_by_service: Record<string, number>;
average_waiting_seconds: number;
monthly_trend: Array<{
count: number;
month: string;
payment_type: string;
}>;
total_by_payment_type: Record<string, number>;
payment_type_distribution_percent: Record<string, number>;
average_waiting_by_service_seconds: Record<string, number>;
from: string;
to: string;
}
export const useVisitAPI = () => {
const config = useRuntimeConfig();
// We use the configured proxy path to avoid CORS issues
const statsURL = '/stats-api/visit/stats';
/**
* Fetch visit statistics
*/
const fetchStats = async (filters: { from?: string; to?: string; service_code?: string; payment_type?: string } = {}): Promise<VisitStats | null> => {
try {
const queryParams = new URLSearchParams();
if (filters.from) queryParams.append('from', filters.from);
if (filters.to) queryParams.append('to', filters.to);
if (filters.service_code) queryParams.append('service_code', filters.service_code);
if (filters.payment_type) queryParams.append('payment_type', filters.payment_type);
const url = `${statsURL}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const response = await $fetch<{ message: string; data: VisitStats }>(url, {
method: 'GET',
});
if (response && response.data) {
return response.data;
}
return null;
} catch (error: any) {
console.error('❌ Error fetching visit stats:', error);
return null;
}
};
return {
fetchStats,
};
};
+14 -16
View File
@@ -1,16 +1,14 @@
<template>
<v-app id="inspire">
<SideBar
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<SideBar
:items="filteredNavItems"
v-model:drawer="drawer"
:rail="rail"
@toggle-rail="rail = !rail"
/>
<v-main app>
<slot />
</v-main>
</v-app>
<v-main app>
<slot />
</v-main>
</template>
<script setup lang="ts">
@@ -158,7 +156,7 @@ const filteredNavItems = computed(() => {
.map((item) => {
const menuPerm = permissionMap.get(item.name.toLowerCase());
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = menuPerm ? menuPerm.canAccess : false;
const allowThis = menuPerm ? (menuPerm as any).canAccess : false;
const hasChildren = filteredChildren.length > 0;
if (!allowThis && !hasChildren) return null;
@@ -168,10 +166,10 @@ const filteredNavItems = computed(() => {
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
.filter((item): item is NavItem => item !== null);
};
return applyFilter(navItemsStore.navItems);
return applyFilter(navItemsStore.navItems) as any[];
}
// If no permissions found, show all items
@@ -222,7 +220,7 @@ const filteredNavItems = computed(() => {
}
const filteredChildren = item.children ? applyFilter(item.children) : [];
const allowThis = perm ? (perm.active || perm.read) : false;
const allowThis = perm ? (perm.active || (perm as any).read) : false;
const hasChildren = filteredChildren.length > 0;
// If permission allows and has children, show item with filtered children
@@ -235,7 +233,7 @@ const filteredNavItems = computed(() => {
...(hasChildren ? { children: filteredChildren } : {}),
};
})
.filter(Boolean);
.filter((item): item is NavItem => item !== null);
};
return applyFilter(navItemsStore.navItems);
+3 -5
View File
@@ -1,9 +1,7 @@
<template>
<v-app>
<v-main>
<slot />
</v-main>
</v-app>
<v-main>
<slot />
</v-main>
</template>
<style>
+6
View File
@@ -107,6 +107,12 @@ export default defineNuxtConfig({
};
})(),
routeRules: {
'/stats-api/**': {
proxy: 'http://10.10.150.100:8084/api/v1/**'
},
},
vite: {
css: {
preprocessorOptions: {
+11 -1
View File
@@ -36,7 +36,8 @@
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
@@ -20707,6 +20708,15 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue3-carousel": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/vue3-carousel/-/vue3-carousel-0.17.0.tgz",
"integrity": "sha512-kvb2CRpsEtLGSbiOq/fXfayMJeiwMMjnNSbqWI/Ee7tgfY/ofNanRuhpd7WRVtDdzJY1HvygdLawlBhIyJTM2w==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vuetify": {
"version": "3.11.6",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.6.tgz",
+2 -1
View File
@@ -42,7 +42,8 @@
"vue": "^3.5.18",
"vue-chartjs": "^5.3.2",
"vue-draggable-next": "^2.3.0",
"vue-router": "^4.5.1"
"vue-router": "^4.5.1",
"vue3-carousel": "^0.17.0"
},
"devDependencies": {
"@nuxtjs/google-fonts": "^3.2.0",
+3
View File
@@ -8,6 +8,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="28"
height="28"
style="width: 28px; height: 28px; object-fit: contain;"
/>
</div>
<div class="header-text">
+2 -2
View File
@@ -8,8 +8,8 @@
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit= contain;
height="40"
style="width: 40px; height: 40px; object-fit= contain;
/>
</div>
<div class="header-content">
+181 -296
View File
@@ -9,7 +9,10 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
>
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
<h1 class="hospital-name">RSUD dr. Saiful Anwar Provinsi Jawa Timur</h1>
@@ -48,46 +51,55 @@
</div>
</div>
<!-- Queue Display - Vertical Scrollable -->
<!-- Queue Display - CSS Animated Scroll -->
<div
:ref="el => setQueueDisplayRef(loketData.id, el)"
class="queue-display"
:class="{
'has-scroll': loketData.queues.length > 20,
'ticker-scroll': loketData.queues.length > 0
}"
class="queue-display"
:ref="el => setQueueDisplayRef(el, loketData.id)"
>
<!-- Ticket Cards List - Infinite Ticker -->
<!-- Ticket Cards List - CSS Animation -->
<div v-if="loketData.queues.length > 0" class="ticket-cards-wrapper">
<div
class="ticket-cards-list ticker-container"
:style="ticketGridStyle"
class="ticket-cards-scroll"
:class="{ 'animate-scroll': loketScrollStatus[loketData.id] }"
:ref="el => setQueueScrollRef(el, loketData.id)"
>
<!-- 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' }"
<div
class="ticket-cards-grid"
:style="ticketGridStyle"
>
<!-- 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
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>
<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' }"
<!-- Duplicated Content for Seamless Loop -->
<div
v-if="loketScrollStatus[loketData.id]"
class="ticket-cards-grid"
:style="ticketGridStyle"
aria-hidden="true"
>
<!-- 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
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 class="ticket-number">{{ queue.noAntrian?.split(" |")[0] || queue.no || '' }}</div>
</div>
</div>
</div>
@@ -161,6 +173,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()
@@ -209,12 +231,44 @@ let timeInterval = null
// false = tampilkan semua loket termasuk yang tanpa antrian
const hideEmptyLokets = ref(false)
// Refs for queue display containers (for auto-scroll)
const windowWidth = ref(process.client ? window.innerWidth : 1920)
// Status scroll per loket: { loketId: boolean }
const loketScrollStatus = ref({})
const queueDisplayRefs = ref({})
const queueScrollRefs = ref({})
const setQueueDisplayRef = (loketId, el) => {
if (el) {
queueDisplayRefs.value[loketId] = el
const setQueueDisplayRef = (el, loketId) => {
if (el) queueDisplayRefs.value[loketId] = el
}
const setQueueScrollRef = (el, loketId) => {
if (el) queueScrollRefs.value[loketId] = el
}
const checkOverflow = (loketId) => {
const container = queueDisplayRefs.value[loketId]
const content = queueScrollRefs.value[loketId]
if (container && content) {
// Check if content height is greater than container height
// We use a small buffer (5px) to prevent flickering
const hasOverflow = content.scrollHeight > container.clientHeight + 5
loketScrollStatus.value[loketId] = hasOverflow
}
}
const checkAllOverflows = () => {
nextTick(() => {
displayedLokets.value.forEach(loket => {
checkOverflow(loket.id)
})
})
}
const updateWidth = () => {
if (process.client) {
windowWidth.value = window.innerWidth
checkAllOverflows()
}
}
@@ -315,12 +369,9 @@ const displayedLokets = computed(() => {
})
// Calculate grid columns based on number of displayed lokets
// Maksimal 7 loket per row
// User wants all lokets in a single row (columns only)
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)
return displayedLokets.value.length || 1
})
const gridStyle = computed(() => {
@@ -330,16 +381,14 @@ const gridStyle = computed(() => {
})
// 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)
// If few lokets, show tickets in multiple columns
if (loketCols <= 2) return 5
if (loketCols === 3) return 4
if (loketCols === 4) return 3
if (loketCols === 5) return 2
// If many lokets, show tickets in 1 column (vertical)
return 1
})
@@ -414,99 +463,10 @@ const updateTime = () => {
})
}
// 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)
checkAllOverflows()
}
}, { immediate: true, deep: true })
@@ -525,12 +485,29 @@ watch(() => {
}
}, { immediate: true, deep: true })
// Watch displayedLokets untuk restart auto-scroll
watch(displayedLokets, () => {
startAutoScroll()
}, { deep: true })
onMounted(() => {
updateWidth()
if (process.client) {
window.addEventListener('resize', updateWidth)
// Check overflows initially
checkAllOverflows()
// Set up ResizeObserver to monitor container size changes
const resizeObserver = new ResizeObserver(() => {
checkAllOverflows()
})
// Observe the main container
const container = document.querySelector('.antrian-display-container')
if (container) resizeObserver.observe(container)
onUnmounted(() => {
window.removeEventListener('resize', updateWidth)
resizeObserver.disconnect()
})
}
// Wait for stores to be hydrated from persisted state
// Use nextTick to ensure stores are ready
nextTick(() => {
@@ -561,33 +538,27 @@ onMounted(() => {
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 = {}
if (process.client) {
window.removeEventListener('resize', updateWidth)
}
})
</script>
<style scoped lang="scss">
.antrian-display-container {
background: #FFFFFF;
width: 100vw;
height: 100vh;
width: 100%;
min-height: 100vh;
min-height: 100dvh; /* Dynamic viewport height for better mobile support */
max-width: 100vw;
max-height: 100vh;
max-height: 100dvh;
padding: 16px;
font-family: 'Roboto', sans-serif;
overflow: auto;
@@ -606,6 +577,8 @@ onUnmounted(() => {
overflow: hidden;
width: 100%;
height: 100%;
position: fixed;
overscroll-behavior: none;
}
:deep(#__nuxt) {
@@ -613,6 +586,8 @@ onUnmounted(() => {
padding: 0;
width: 100%;
height: 100%;
position: fixed;
overflow: hidden;
}
/* ========== HEADER ========== */
@@ -622,8 +597,8 @@ onUnmounted(() => {
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;
padding: clamp(12px, 1.2vw, 18px) clamp(16px, 2vw, 24px);
margin-bottom: 12px;
box-shadow: 0 8px 24px rgba(25, 118, 210, 0.3);
flex-shrink: 0;
box-sizing: border-box;
@@ -659,11 +634,11 @@ onUnmounted(() => {
}
.hospital-name {
font-size: 32px;
font-size: clamp(20px, 1.8vw, 26px);
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 1px;
letter-spacing: 0.5px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1.1;
}
@@ -702,10 +677,10 @@ onUnmounted(() => {
}
.time-large {
font-size: 64px;
font-size: clamp(32px, 3vw, 48px);
font-weight: 900;
color: var(--color-neutral-100);
letter-spacing: 2px;
letter-spacing: 1px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
@@ -730,7 +705,7 @@ onUnmounted(() => {
.lokets-grid {
display: grid;
gap: 12px;
gap: clamp(8px, 1vw, 16px);
width: 100%;
flex: 1;
min-height: 0;
@@ -747,7 +722,8 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-height: 200px;
min-height: 150px;
min-width: 0;
/* Tidak set max-height untuk allow card fleksibel */
}
@@ -755,7 +731,7 @@ onUnmounted(() => {
.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;
padding: clamp(8px, 1vw, 14px) clamp(10px, 1.2vw, 18px);
box-shadow: 0 2px 8px rgba(58, 97, 201, 0.2);
border-radius: 8px 8px 0 0;
flex-shrink: 0;
@@ -793,50 +769,57 @@ onUnmounted(() => {
margin-right: 6px;
}
/* Queue Display - Vertical Scrollable */
/* Queue Display - CSS Animated Scroll */
.queue-display {
flex: 1;
padding: 16px;
padding-bottom: 5vh;
padding: clamp(4px, 0.6vw, 10px);
padding-bottom: 1vh;
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%;
overflow: hidden;
}
.ticker-container {
will-change: transform;
transform: translateZ(0); /* Hardware acceleration */
display: grid;
gap: 10px;
/* Scroll Container with CSS Animation */
.ticket-cards-scroll {
width: 100%;
padding-bottom: 20px;
display: flex;
flex-direction: column;
}
.ticket-cards-list {
/* Animate scroll when there are many items */
.ticket-cards-scroll.animate-scroll {
animation: scroll-up 30s linear infinite;
}
/* Keyframes for smooth vertical scrolling */
@keyframes scroll-up {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50%);
}
}
/* Pause animation on hover for better UX */
.ticket-cards-scroll:hover {
animation-play-state: paused;
}
.ticket-cards-grid {
display: grid;
gap: 10px;
gap: clamp(4px, 0.5vw, 10px);
width: 100%;
padding-bottom: 20px; /* Additional bottom padding for last card */
padding: clamp(2px, 0.3vw, 5px);
/* grid-template-columns is set dynamically via :style binding */
}
@@ -844,15 +827,15 @@ onUnmounted(() => {
background: var(--color-neutral-400, #E1E5EA);
border: 1px solid var(--color-neutral-500, #CDD4DC);
border-radius: 8px;
padding: 20px 28px;
padding: 12px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 8px;
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;
min-height: 80px;
position: relative;
cursor: default;
}
@@ -876,10 +859,10 @@ onUnmounted(() => {
}
.ticket-number {
font-size: 40px;
font-size: clamp(20px, 2.5vw, 32px);
font-weight: 800;
color: var(--color-primary-600, #3A61C9);
letter-spacing: 1px;
letter-spacing: 0.5px;
line-height: 1.1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
@@ -984,7 +967,7 @@ onUnmounted(() => {
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
margin-top: auto;
margin-top: 24px;
min-height: 90px;
height: auto;
}
@@ -1208,12 +1191,6 @@ onUnmounted(() => {
@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 {
@@ -1222,104 +1199,12 @@ onUnmounted(() => {
}
}
@media (min-width: 961px) and (max-width: 1400px) {
@media (min-width: 961px) {
.lokets-grid {
gap: 12px;
/* grid-template-columns is set dynamically via :style binding */
gap: clamp(12px, 1vw, 20px);
}
}
@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 {
+3
View File
@@ -7,6 +7,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
+3
View File
@@ -9,6 +9,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
@@ -1,6 +1,7 @@
<!-- pages/Anjungan/AntrianKlinikRuang/[kodeKlinik].vue -->
<template>
<div class="antrian-display-container">
<div class="scaler-wrapper">
<div class="antrian-display-container">
<!-- Header -->
<div class="display-header">
<div class="header-left">
@@ -9,7 +10,10 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
>
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
<h1 class="hospital-name">ANTRIAN KLINIK RUANG</h1>
@@ -130,6 +134,7 @@
</div>
</div>
</div>
</div>
</template>
<script setup>
@@ -632,6 +637,20 @@ 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;
@@ -641,11 +660,11 @@ watch(anjunganClientId, (newClientId, oldClientId) => {
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
box-sizing: border-box;
flex-shrink: 0; /* Prevent flex from shrinking the fixed-size container */
/* Responsive scaling menggunakan CSS variables dan JavaScript */
transform-origin: top center;
transform-origin: center center;
--scale-factor: 1;
transform: scale(var(--scale-factor));
}
@@ -7,6 +7,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
+112 -124
View File
@@ -9,6 +9,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
@@ -71,7 +74,7 @@
</div>
<!-- Main Grid - Display Klinik -->
<div class="kliniks-grid">
<div class="kliniks-grid" :style="gridStyle">
<div
v-for="klinik in displayedClinics"
:key="klinik.name"
@@ -178,6 +181,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 loketStore = useLoketStore()
@@ -600,6 +613,19 @@ const displayedClinics = computed(() => {
return clinics.sort((a, b) => a.name.localeCompare(b.name))
})
// Dynamic grid logic: divide total by 2 to balance across rows, cap at 7
const gridColumns = computed(() => {
const total = displayedClinics.value.length
if (total <= 1) return 1
if (total <= 7) return total // Show in one row if 7 or less looks better?
// User asked for balanced rows, so for 8 clinics it will be 4 col, 2 rows.
return Math.min(7, Math.ceil(total / 2))
})
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${gridColumns.value}, 1fr)`
}))
// Helper untuk cek apakah tiket masih dalam TTS window (15 detik)
const isInTTSWindow = (queue) => {
if (!queue) return false
@@ -872,17 +898,41 @@ onUnmounted(() => {
<style scoped lang="scss">
.antrian-display-container {
background: var(--color-neutral-300);
min-height: 100vh;
max-width: 1920px;
max-height: 1080px;
width: 100vw;
height: 100vh;
padding: 24px;
width: 100%;
height: 100dvh;
padding: clamp(10px, 1.5vw, 20px);
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0 auto;
margin: 0;
box-sizing: border-box;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Ensure body/html don't cause layout issues */
:deep(body),
:deep(html) {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
position: fixed;
overscroll-behavior: none;
}
:deep(#__nuxt) {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
position: fixed;
overflow: hidden;
}
/* ========== HEADER ========== */
@@ -891,10 +941,11 @@ onUnmounted(() => {
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 16px;
padding: 24px 40px;
margin-bottom: 20px;
border-radius: 12px;
padding: clamp(8px, 1vh, 16px) clamp(16px, 2vw, 32px);
margin-bottom: clamp(10px, 1.2vh, 16px);
box-shadow: 0 8px 24px rgba(33, 150, 243, 0.3);
flex-shrink: 0;
}
.header-left {
@@ -923,13 +974,13 @@ onUnmounted(() => {
}
.hospital-name {
font-size: 48px;
font-size: clamp(22px, 2.5vw, 34px);
font-weight: 800;
color: var(--color-neutral-100);
margin: 0;
letter-spacing: 2px;
letter-spacing: 1px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
line-height: 1;
line-height: 1.1;
}
.display-subtitle {
@@ -953,10 +1004,10 @@ onUnmounted(() => {
}
.time-large {
font-size: 56px;
font-size: clamp(32px, 3.5vw, 48px);
font-weight: 900;
color: var(--color-neutral-100);
letter-spacing: 2px;
letter-spacing: 1.5px;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
@@ -973,15 +1024,16 @@ onUnmounted(() => {
.hero-main-section {
display: flex;
gap: 16px;
margin-bottom: 20px;
margin: clamp(10px, 1.5vh, 20px) 0;
align-items: flex-start;
flex-shrink: 0;
}
/* ========== HERO CALL SECTION (LARGE) ========== */
.hero-call-section {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
border-radius: 20px;
padding: 32px;
padding: clamp(15px, 2vh, 32px);
flex: 1;
text-align: center;
box-shadow: 0 12px 32px rgba(33, 150, 243, 0.35);
@@ -1038,9 +1090,10 @@ onUnmounted(() => {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
padding: clamp(10px, 1.2vh, 20px);
margin-bottom: clamp(10px, 1.5vh, 20px);
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
flex-shrink: 0;
}
.next-tickets-header {
@@ -1199,10 +1252,13 @@ onUnmounted(() => {
/* ========== KLINIKS GRID ========== */
.kliniks-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 20px;
/* grid-template-columns is now handled by :style="gridStyle" */
gap: clamp(10px, 1.2vw, 20px);
margin-bottom: clamp(10px, 1.2vh, 20px);
padding: 0 1vw;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.klinik-box {
@@ -1211,27 +1267,31 @@ onUnmounted(() => {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
min-height: 320px;
min-height: clamp(160px, 20vh, 260px);
display: flex;
flex-direction: column;
}
.klinik-header-bar {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
padding: 18px 22px;
padding: 10px 14px;
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
gap: 8px;
}
.klinik-title {
font-size: 24px;
font-size: clamp(14px, 1vw, 22px);
font-weight: 800;
color: var(--color-neutral-100);
letter-spacing: 0.5px;
text-transform: uppercase;
line-height: 1.2;
line-height: 1.1;
flex: 1;
min-width: 0;
margin-top: 2px;
word-break: break-word;
}
.klinik-count {
@@ -1243,6 +1303,7 @@ onUnmounted(() => {
align-items: center;
gap: 6px;
padding: 6px 12px !important;
flex-shrink: 0;
}
.count-text {
@@ -1254,9 +1315,10 @@ onUnmounted(() => {
flex: 1;
display: flex;
flex-direction: column;
padding: 22px;
padding: clamp(8px, 1.2vh, 16px);
background: var(--color-primary-50);
gap: 12px;
gap: clamp(4px, 0.8vh, 12px);
min-height: 0;
}
/* Current Serving Section */
@@ -1264,7 +1326,7 @@ onUnmounted(() => {
text-align: center;
background: var(--color-neutral-100);
border-radius: 12px;
padding: 18px;
padding: clamp(10px, 1.2vw, 18px);
margin-bottom: 12px;
}
@@ -1278,10 +1340,10 @@ onUnmounted(() => {
}
.current-number-large {
font-size: 72px;
font-size: clamp(32px, 3.5vw, 56px);
font-weight: 900;
color: var(--color-neutral-900);
letter-spacing: 3px;
letter-spacing: 1.5px;
line-height: 1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
@@ -1294,13 +1356,15 @@ onUnmounted(() => {
}
.current-waiting-text {
font-size: 48px;
font-size: clamp(14px, 1.1vw, 24px);
font-weight: 700;
color: var(--color-neutral-500);
letter-spacing: 2px;
line-height: 1;
letter-spacing: 0.5px;
line-height: 1.1;
margin-top: 10px;
opacity: 0.7;
word-break: break-word;
overflow-wrap: break-word;
}
/* Multiple Calls Row (Small chips) */
@@ -1459,7 +1523,12 @@ onUnmounted(() => {
.empty-state {
text-align: center;
padding: 32px 0;
padding: clamp(10px, 2vh, 32px) 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.empty-text {
@@ -1474,11 +1543,12 @@ onUnmounted(() => {
background: var(--color-neutral-100);
border: 2px solid var(--color-primary-200);
border-radius: 16px;
padding: 20px 40px;
padding: clamp(10px, 1.2vh, 18px) clamp(20px, 2.5vw, 40px);
display: flex;
align-items: center;
gap: 32px;
gap: clamp(16px, 2vw, 32px);
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.12);
flex-shrink: 0;
}
.stat-item {
@@ -1539,11 +1609,9 @@ onUnmounted(() => {
}
/* ========== RESPONSIVE ========== */
@media (min-width: 1920px) and (min-height: 1080px) {
@media (min-width: 1920px) {
.antrian-display-container {
width: 1920px;
height: 1080px;
padding: 24px;
padding: clamp(20px, 1.5vw, 32px);
}
}
@@ -1565,22 +1633,6 @@ onUnmounted(() => {
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;
}
@@ -1595,23 +1647,6 @@ onUnmounted(() => {
flex: 1;
}
.call-label {
font-size: 24px;
}
.call-number {
font-size: 100px;
}
.call-number {
font-size: 100px;
}
.call-loket {
font-size: 28px;
}
.kliniks-grid {
grid-template-columns: repeat(2, 1fr);
}
@@ -1629,18 +1664,10 @@ onUnmounted(() => {
padding: 14px 16px;
}
.loket-title {
font-size: 20px;
}
.loket-content {
padding: 16px;
}
.current-number {
font-size: 72px;
}
.footer-stats-bar {
padding: 16px 32px;
}
@@ -1654,21 +1681,9 @@ onUnmounted(() => {
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) {
@@ -1711,30 +1726,6 @@ onUnmounted(() => {
grid-template-columns: 1fr;
}
.hospital-name {
font-size: 28px;
}
.time-large {
font-size: 36px;
}
.call-number {
font-size: 80px;
}
.call-number-small {
font-size: 28px;
}
.current-number {
font-size: 60px;
}
.current-number-large {
font-size: 48px;
}
.kliniks-grid {
grid-template-columns: 1fr;
}
@@ -1742,12 +1733,9 @@ onUnmounted(() => {
.hero-call-card-small {
min-width: 100px;
}
.call-number-tiny {
font-size: 20px;
}
}
/* Prevent text selection */
* {
user-select: none;
+3
View File
@@ -7,6 +7,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="40"
height="40"
style="width: 40px; height: 40px; object-fit: contain;"
/>
</div>
<div class="header-content">
+3
View File
@@ -9,6 +9,9 @@
src="/Rumah_Sakit_Umum_Daerah_Dr._Saiful_Anwar.webp"
alt="RSUD Logo"
class="header-logo"
width="64"
height="64"
style="width: 64px; height: 64px; object-fit: contain;"
/>
</div>
<div class="header-text">
+419 -30
View File
@@ -28,11 +28,13 @@
Selamat Datang Kembali! 👋
</h1>
<p v-if="user" class="hero-subtitle-new">
<span class="greeting-text">Halo,</span>
<span class="user-name-new">{{ user.name || user.preferred_username }}</span>
<div v-if="user" class="hero-subtitle-new">
<div class="greeting-row">
<span class="greeting-text">Halo,</span>
<span class="user-name-new">{{ user.name || user.preferred_username }}</span>
</div>
<span class="subtitle-desc">Berikut adalah ringkasan aktivitas hari ini</span>
</p>
</div>
<!-- Quick Info Pills -->
<div class="quick-info-pills">
@@ -50,22 +52,45 @@
<!-- Right Section: Actions & Stats -->
<v-col cols="12" md="6" class="hero-right">
<div class="hero-actions-new">
<!-- Export Button -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<div class="hero-actions-new">
<!-- Date Filter Section -->
<div class="date-filter-row">
<div class="date-input-group">
<label class="date-label">Dari</label>
<input type="date" v-model="filterDateFrom" class="modern-datepicker" />
</div>
<div class="date-input-group">
<label class="date-label">Hingga</label>
<input type="date" v-model="filterDateTo" class="modern-datepicker" />
</div>
<v-btn
v-bind="props"
color="white"
variant="flat"
class="modern-btn export-btn-new"
size="large"
prepend-icon="mdi-download"
>
<span class="btn-text">Export Data</span>
<v-icon end size="18">mdi-chevron-down</v-icon>
</v-btn>
</template>
icon="mdi-magnify"
size="small"
class="filter-submit-btn"
@click="applyDateFilter"
:loading="isLoading"
></v-btn>
</div>
<div class="action-divider"></div>
<!-- Export Button -->
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="white"
variant="flat"
class="modern-btn export-btn-new"
size="large"
prepend-icon="mdi-download"
>
<span class="btn-text">Export Data</span>
<v-icon end size="18">mdi-chevron-down</v-icon>
</v-btn>
</template>
<v-list class="export-menu-new">
<v-list-item
v-for="(item, index) in exportOptions"
@@ -88,7 +113,7 @@
<v-icon size="20" color="white">mdi-account-multiple</v-icon>
</div>
<div class="mini-stat-content">
<div class="mini-stat-value">324</div>
<div class="mini-stat-value">{{ stats[0].value }}</div>
<div class="mini-stat-label">Pasien Hari Ini</div>
</div>
</div>
@@ -98,7 +123,7 @@
<v-icon size="20" color="white">mdi-clock-fast</v-icon>
</div>
<div class="mini-stat-content">
<div class="mini-stat-value">47</div>
<div class="mini-stat-value">{{ stats[1].value }}</div>
<div class="mini-stat-label">Antrean Aktif</div>
</div>
</div>
@@ -177,10 +202,38 @@
</div>
</div>
<div class="chart-controls">
<v-chip class="live-chip" size="small">
<v-icon class="pulse-icon" size="16">mdi-circle</v-icon>
2025
</v-chip>
<v-btn
icon="mdi-download"
variant="text"
size="small"
color="primary"
class="chart-download-btn-dark"
@click="handleChartExport('visitTrend', 'Tren Kunjungan Pasien')"
density="comfortable"
></v-btn>
<v-menu offset-y transition="scale-transition">
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
class="live-chip interactive-year-chip"
size="small"
>
<v-icon class="pulse-icon" size="14">mdi-circle</v-icon>
<span class="ml-1">{{ selectedYear }}</span>
<v-icon end size="14">mdi-chevron-down</v-icon>
</v-chip>
</template>
<v-list class="year-dropdown-list">
<v-list-item
v-for="year in availableYears"
:key="year"
@click="selectedYear = year"
:class="{ 'year-item-active': selectedYear === year }"
>
<v-list-item-title class="dropdown-year-text">{{ year }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<div class="chart-container">
@@ -215,6 +268,17 @@
<p class="chart-subtitle">Distribusi metode bayar</p>
</div>
</div>
<div class="chart-controls">
<v-btn
icon="mdi-download"
variant="text"
size="small"
color="success"
class="chart-download-btn-dark"
@click="handleChartExport('paymentStatus', 'Status Pembayaran')"
density="comfortable"
></v-btn>
</div>
</div>
<div class="chart-container">
<ClientOnly>
@@ -248,6 +312,17 @@
<p class="chart-subtitle">Rata-rata waktu tunggu (menit)</p>
</div>
</div>
<div class="chart-controls">
<v-btn
icon="mdi-download"
variant="text"
size="small"
color="secondary"
class="chart-download-btn-dark"
@click="handleChartExport('waitingTime', 'Waktu Tunggu per Poli')"
density="comfortable"
></v-btn>
</div>
</div>
<div class="chart-container">
<ClientOnly>
@@ -282,6 +357,15 @@
</div>
</div>
<div class="chart-controls">
<v-btn
icon="mdi-download"
variant="text"
size="small"
color="primary"
class="chart-download-btn-dark"
@click="handleChartExport('attendance', 'Tingkat Kehadiran')"
density="comfortable"
></v-btn>
<v-chip class="live-chip" size="small">
<v-icon class="pulse-icon" size="16">mdi-circle</v-icon>
Minggu Ini
@@ -349,6 +433,7 @@ ChartJS.register(
);
import { useAuth } from '~/composables/useAuth';
import { useVisitAPI } from '~/composables/useVisitAPI';
import dayjs from 'dayjs';
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
@@ -365,6 +450,7 @@ definePageMeta({
const user = ref(null);
const isLoading = ref(false);
const { checkAuth } = useAuth();
const { fetchStats } = useVisitAPI();
// Color palette from assets/scss/_colors.scss
const colors = {
@@ -407,6 +493,10 @@ const exportOptions = ref([
]);
const currentDate = ref('');
const filterDateFrom = ref(dayjs().format('YYYY-MM-DD'));
const filterDateTo = ref(dayjs().format('YYYY-MM-DD'));
const selectedYear = ref(2025);
const availableYears = ref([2025, 2026, 2027]);
// New Stats Data - Updated Metrics
const stats = ref([
@@ -829,6 +919,83 @@ const polarAreaOptions = ref({
}
});
const refreshDashboardStats = async (filterParams = {}) => {
isLoading.value = true;
try {
// Merge provided filters with UI dates if not explicitly provided
const params = {
from: filterDateFrom.value,
to: filterDateTo.value,
...filterParams
};
const data = await fetchStats(params);
console.log('📡 API Response Data:', data);
if (data) {
// 1. Update Stats Cards
stats.value[0].value = data.today_patients.toString();
const activeQueuesTotal = Object.values(data.today_active_queues_by_service).reduce((sum, val) => sum + val, 0);
stats.value[1].value = activeQueuesTotal.toString();
const waitMinutes = Math.floor(data.average_waiting_seconds / 60);
stats.value[2].value = `${waitMinutes} min`;
// 2. Update Payment Status Chart
if (data.total_by_payment_type) {
const labelMap = {
'JKN': 'JKN / BPJS',
'UMUM': 'Umum / Mandiri',
'UNKNOWN': 'Lainnya'
};
const labels = Object.keys(data.total_by_payment_type).map(key => labelMap[key] || key);
const values = Object.values(data.total_by_payment_type);
paymentStatusData.value = {
labels: labels,
datasets: [{
...paymentStatusData.value.datasets[0],
data: values,
backgroundColor: [
colors.primary[500],
colors.secondary[500],
colors.success[500],
colors.primary[300],
colors.secondary[300]
]
}]
};
}
// 3. Update Waiting Time by Service Chart
if (data.average_waiting_by_service_seconds && Object.keys(data.average_waiting_by_service_seconds).length > 0) {
waitingTimeData.value = {
labels: Object.keys(data.average_waiting_by_service_seconds).map(key => key.split('|')[0]),
datasets: [{
...waitingTimeData.value.datasets[0],
data: Object.values(data.average_waiting_by_service_seconds).map(sec => Math.round(sec / 60))
}]
};
}
// 4. Update Monthly Trend (Map API into datasets)
if (data.monthly_trend && data.monthly_trend.length > 0) {
// Logic to update trend charts can be expanded here
// For now, we'll keep the existing structure but show where real data fits
}
}
} catch (error) {
console.error('❌ Error refreshing stats:', error);
} finally {
isLoading.value = false;
}
};
const applyDateFilter = () => {
refreshDashboardStats();
};
onMounted(async () => {
console.log('📊 Dashboard mounted');
try {
@@ -836,6 +1003,9 @@ onMounted(async () => {
if (sessionUser) {
user.value = sessionUser;
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
// Load real-time stats
await refreshDashboardStats();
console.log('✅ Dashboard loaded successfully');
}
} catch (error) {
@@ -844,8 +1014,103 @@ onMounted(async () => {
});
const handleExport = (type) => {
console.log('Export:', type);
alert(`Export ${type} - Feature coming soon!`);
// 1. Prepare Export Data
const exportData = {
metadata: {
report_name: 'Dashboard Antrean Statistics',
generated_at: dayjs().format('YYYY-MM-DD HH:mm:ss'),
period_from: filterDateFrom.value,
period_to: filterDateTo.value,
user: user.value?.name || user.value?.preferred_username || 'System'
},
summary_stats: stats.value.map(s => ({
label: s.label,
value: s.value
})),
payment_distribution: paymentStatusData.value.labels.map((label, i) => ({
method: label,
total: paymentStatusData.value.datasets[0].data[i]
})),
waiting_time_by_service: waitingTimeData.value.labels.map((label, i) => ({
service: label,
average_minutes: waitingTimeData.value.datasets[0].data[i]
}))
};
// 2. Process Export based on type
if (type === 'json') {
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
downloadBlob(blob, `report_antrean_${dayjs().format('YYYYMMDD_HHmm')}.json`);
} else if (type === 'csv') {
// Generate CSV String
let csv = 'Dashboard Antrean Statistics\n';
csv += `Periode: ${filterDateFrom.value} - ${filterDateTo.value}\n\n`;
csv += 'RINGKASAN STATISTIK\n';
csv += 'Metrik,Nilai\n';
exportData.summary_stats.forEach(s => {
csv += `"${s.label}","${s.value}"\n`;
});
csv += '\nSTATUS PEMBAYARAN\n';
csv += 'Metode,Jumlah\n';
exportData.payment_distribution.forEach(p => {
csv += `"${p.method}",${p.total}\n`;
});
csv += '\nWAKTU TUNGGU PER POLI\n';
csv += 'Layanan,Rata-rata (Menit)\n';
exportData.waiting_time_by_service.forEach(w => {
csv += `"${w.service}",${w.average_minutes}\n`;
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, `report_antrean_${dayjs().format('YYYYMMDD_HHmm')}.csv`);
} else {
alert(`Export ${type} - Feature coming soon!`);
}
};
const handleChartExport = (chartId, title) => {
let csv = `${title}\n`;
csv += `Periode: ${filterDateFrom.value} - ${filterDateTo.value}\n\n`;
if (chartId === 'visitTrend') {
csv += 'Bulan,' + visitTrendData.value.datasets.map(d => d.label).join(',') + '\n';
visitTrendData.value.labels.forEach((label, i) => {
csv += `"${label}",` + visitTrendData.value.datasets.map(d => d.data[i]).join(',') + '\n';
});
} else if (chartId === 'paymentStatus') {
csv += 'Metode Pembayaran,Jumlah\n';
paymentStatusData.value.labels.forEach((label, i) => {
csv += `"${label}",${paymentStatusData.value.datasets[0].data[i]}\n`;
});
} else if (chartId === 'waitingTime') {
csv += 'Layanan,Rata-rata Waktu Tunggu (Menit)\n';
waitingTimeData.value.labels.forEach((label, i) => {
csv += `"${label}",${waitingTimeData.value.datasets[0].data[i]}\n`;
});
} else if (chartId === 'attendance') {
csv += 'Status Kehadiran,Jumlah\n';
attendanceData.value.labels.forEach((label, i) => {
csv += `"${label}",${attendanceData.value.datasets[0].data[i]}\n`;
});
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, `${chartId}_${dayjs().format('YYYYMMDD_HHmm')}.csv`);
};
// Helper function for file download
const downloadBlob = (blob, filename) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
</script>
@@ -1014,6 +1279,13 @@ const handleExport = (type) => {
flex-direction: column;
gap: 4px;
margin: 0 0 16px 0;
.greeting-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 2px;
}
.greeting-text {
font-size: 14px;
@@ -1022,7 +1294,7 @@ const handleExport = (type) => {
}
.user-name-new {
font-size: 20px;
font-size: 22px;
font-weight: 800;
color: white;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
@@ -1069,9 +1341,74 @@ const handleExport = (type) => {
.hero-actions-new {
display: flex;
flex-direction: column;
gap: 12px;
gap: 16px;
width: 100%;
max-width: 400px;
max-width: 450px;
}
.date-filter-row {
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
padding: 10px 16px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.date-input-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.date-label {
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modern-datepicker {
background: transparent;
border: none;
color: white;
font-size: 13px;
font-weight: 600;
outline: none;
width: 100%;
&::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
}
.filter-submit-btn {
background: white !important;
color: var(--color-primary-600) !important;
border-radius: 10px !important;
transition: all 0.3s ease !important;
&:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3) !important;
}
}
.action-divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 4px 0;
}
.modern-btn {
@@ -1197,7 +1534,8 @@ const handleExport = (type) => {
/* Compact Stats Cards */
.stats-row {
margin-top: -40px;
margin-top: -20px;
margin-bottom: 12px !important;
position: relative;
z-index: 3;
@@ -1211,7 +1549,7 @@ const handleExport = (type) => {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.7) 100%);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.8);
border: 1.5px solid var(--color-primary-200);
border-radius: 24px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
@@ -1523,6 +1861,57 @@ const handleExport = (type) => {
flex-direction: column;
}
.interactive-year-chip {
cursor: pointer;
transition: all 0.3s ease !important;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(51, 164, 132, 0.4) !important;
}
}
.year-dropdown-list {
background: rgba(255, 255, 255, 0.9) !important;
backdrop-filter: blur(10px);
border-radius: 12px !important;
padding: 4px !important;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1) !important;
}
.year-item-active {
background: var(--color-success-50) !important;
color: var(--color-success-600) !important;
.dropdown-year-text {
font-weight: 700;
}
}
.dropdown-year-text {
font-size: 13px;
font-weight: 500;
text-align: center;
}
.chart-download-btn-dark {
opacity: 0.7 !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
&:hover {
opacity: 1 !important;
transform: scale(1.15) !important;
background: rgba(0, 0, 0, 0.05) !important;
}
}
.chart-controls {
display: flex;
align-items: center;
gap: 8px;
}
.chart-header-modern {
display: flex;
justify-content: space-between;
+4 -2
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid fill-height class="login-background">
<v-container fluid class="login-background fill-height">
<div class="floating-medical-icon icon-1">
<v-icon size="144">mdi-heart-pulse</v-icon>
@@ -20,7 +20,7 @@
<v-icon size="114">mdi-bandage</v-icon>
</div>
<v-row class="fill-height align-center justify-center">
<v-row class="fill-height align-center justify-center ma-0">
<v-col cols="12" class="d-flex justify-center align-center">
<v-card class="main-card white-card rounded-xl pa-0" max-width="450" width="100%">
<v-row class="ma-0">
@@ -36,6 +36,8 @@
src="/love logo biru.png"
alt="Antrean Logo"
class="mt-3 hospital-logo"
height="180"
style="height: 180px; width: auto; object-fit: contain;"
/>
<!-- src="/x logo biru.png" -->
<!-- src="/logo spiral.png" -->