update layout all
This commit is contained in:
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<v-app>
|
||||
<v-app>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</v-app>
|
||||
</NuxtLayout>
|
||||
</NuxtLayout>
|
||||
</v-app>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<v-main>
|
||||
<slot />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -107,6 +107,12 @@ export default defineNuxtConfig({
|
||||
};
|
||||
})(),
|
||||
|
||||
routeRules: {
|
||||
'/stats-api/**': {
|
||||
proxy: 'http://10.10.150.100:8084/api/v1/**'
|
||||
},
|
||||
},
|
||||
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
|
||||
Generated
+11
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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" -->
|
||||
|
||||
Reference in New Issue
Block a user