+
+
+
-
Dashboard
-
- Selamat Datang, {{ user.name || user.preferred_username }}!
+
+ Antrean Dashboard
+
+
+ Selamat Datang,
+
+ {{ user.name || user.preferred_username }}
+ ! 🍊
-
-
+
+
+
+
+
+ Export Data ({{ exportType }})
+ mdi-menu-down
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
{{ currentDate }}
-
+
-
+
-
mdi-account-group
+
mdi-tablet-dashboard
-
2635
-
Total Visitors
+
150
+
Total Antrean Online
+
-
+
-
mdi-account-multiple-plus
+
mdi-account-hard-hat
-
759
-
Offline Registrants
+
100
+
Total Antrean MJKN
+
-
+
-
mdi-account-multiple-plus-outline
+
mdi-account-group
-
1876
-
Online Registrants
+
500
+
Total Kunjungan
+
-
+
-
mdi-ticket
+
mdi-timer-sand
-
248
-
Total Tickets Printed
+
7.5s
+
Avg. Check-in Time
-
+
+
-
-
- Grafik Jumlah Antrean
-
- Hari
- Minggu
- Bulan
- Tahun
+
+
+ Registration Trend
+
+ Day
-
+
-
-
- Monthly Visitor Data
+
+
+ Total Registrasi Perbulan
2024
2025
-
+
@@ -117,35 +156,41 @@
-
- Realtime Ticket Queue
-
+
+
+ Realtime Check-in Velocity
+
+ mdi-circle
+ Live
+
+
+
-
- Registrant Breakdown
-
+
+ Registrant Type Breakdown
+
-
-
- Loading dashboard...
+
+
+ Loading dashboard...
@@ -154,12 +199,13 @@
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from 'dayjs';
-
-// Tambahkan plugin Dayjs
import weekday from 'dayjs/plugin/weekday';
import weekOfYear from 'dayjs/plugin/weekOfYear';
+import 'dayjs/locale/id';
+
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
+dayjs.locale('id');
import {
Chart as ChartJS,
@@ -178,128 +224,120 @@ definePageMeta({
middleware:['auth']
})
-// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
-// Use the auth composable
-const { user, isLoading, checkAuth, logout } = useAuth()
-const isLoggingOut = ref(false)
+// --- KEYCLOAK/AUTH INTEGRATION ---
+// const { user, isLoading, checkAuth, logout } = useAuth()
+// const navigateTo = (path) => { console.log('Navigating to', path); };
+// Mock user for demo purposes - replace with your actual auth
+const user = ref({ name: 'Admin User', preferred_username: 'admin' });
+const isLoading = ref(false);
+const checkAuth = async () => user.value;
+const navigateTo = (path) => console.log('Navigate to:', path);
+// ---------------------------------
-// Dashboard data
+// --- EXPORT STATE ---
+const exportType = ref('JSON');
+const exportOptions = ref([
+ { title: 'JSON (.json)', type: 'json', icon: 'mdi-code-json' },
+ { title: 'CSV (.csv)', type: 'csv', icon: 'mdi-file-delimited' },
+ { title: 'Excel (.xlsx) - Server Only', type: 'excel', icon: 'mdi-file-excel' },
+ { title: 'PDF (.pdf) - Placeholder', type: 'pdf', icon: 'mdi-file-pdf' },
+]);
+// --------------------
+
+// Dashboard state
const currentDate = ref('');
const activeYear = ref('2025');
-const queueTimePeriod = ref('month');
+const queueTimePeriod = ref('day');
-// --- MOCK DATA DINAMIS ANTREEAN (Tetap) ---
+// --- MOCK DATA FOR CHARTS ---
const mockQueueData = ref([
- // ... (data tetap sama)
- { date: '2025-09-22', count: 120 },
- { date: '2025-09-23', count: 150 },
- { date: '2025-09-24', count: 135 },
- { date: '2025-09-25', count: 160 },
- { date: '2025-09-26', count: 145 },
- { date: '2025-09-27', count: 170 },
- { date: '2025-09-28', count: 180 },
- { date: '2025-08-10', count: 300 },
- { date: '2025-08-20', count: 350 },
- { date: '2025-09-01', count: 400 },
- { date: '2025-09-15', count: 450 },
- { date: '2024-01-01', count: 1200 },
- { date: '2024-06-01', count: 1800 },
- { date: '2025-01-01', count: 2000 },
- { date: '2025-06-01', count: 2500 },
+ { date: '2025-10-13', registrants: 300, attendees: 250 },
+ { date: '2025-10-14', registrants: 450, attendees: 400 },
+ { date: '2025-10-15', registrants: 500, attendees: 420 },
+ { date: '2025-10-16', registrants: 400, attendees: 350 },
+ { date: '2025-10-17', registrants: 550, attendees: 500 },
+ { date: '2025-10-18', registrants: 435, attendees: 380 },
]);
-// --- AKHIR MOCK DATA ANTREEAN ---
-// --- REFACTORED REALTIME LOGIC ---
-const realtimeChart = ref(null); // Ref untuk mengakses komponen Line
+// --- REALTIME CHART LOGIC - FIXED ---
+const realtimeChart = ref(null);
const maxDataPoints = 10;
let realtimeInterval = null;
-// Initial data structure (non-reactive for update function)
-const initialLineData = {
- labels: Array.from({ length: maxDataPoints }, (_, i) =>
- dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
- ),
- datasets: [
- {
- label: 'Tickets Processed',
- backgroundColor: '#FF5722',
- borderColor: '#FF5722',
- data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 50) + 100),
- fill: false,
- tension: 0.1,
- },
- ],
-};
-
-const lineOptions = ref({
- responsive: true,
- maintainAspectRatio: false,
- animation: {
- duration: 0 // Crucial: Disable animation for smooth realtime scrolling
+const realtimeLineData = ref({
+ labels: Array.from({ length: maxDataPoints }, (_, i) =>
+ dayjs().subtract((maxDataPoints - 1 - i) * 5, 'second').format('HH:mm:ss')
+ ),
+ datasets: [
+ {
+ label: 'Check-ins/min',
+ backgroundColor: 'rgba(74, 95, 122, 0.15)',
+ borderColor: '#5FB7FF',
+ data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
+ fill: true,
+ tension: 0.3,
+ borderWidth: 3,
+ pointRadius: 4,
+ pointHoverRadius: 6,
+ pointBackgroundColor: '#4A5F7A',
},
- plugins: {
- legend: { display: true },
- title: { display: false }
+ ],
+});
+
+const lineOptions = ref({
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: { duration: 300 },
+ plugins: {
+ legend: { display: true },
+ title: { display: false }
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ suggestedMax: 80,
+ title: { display: true, text: 'Check-ins per Minute' },
+ grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
- scales: {
- y: {
- beginAtZero: true,
- suggestedMax: 200,
- title: { display: true, text: 'Count' },
- grid: { display: true }
- },
- x: {
- title: { display: true, text: 'Time (HH:MM:SS)' },
- grid: { display: false }
- }
+ x: {
+ title: { display: true, text: 'Time (HH:MM:SS)' },
+ grid: { display: false }
}
+ }
});
-// Function to simulate realtime data update using chart.update()
const updateRealtimeData = () => {
- const chart = realtimeChart.value?.chart;
- if (!chart) return;
-
- // 1. Get the current data arrays
- const dataArray = chart.data.datasets[0].data;
- const labelArray = chart.data.labels;
-
- // 2. Shift (remove) the oldest data point and label
- dataArray.shift();
- labelArray.shift();
-
- // 3. Generate new data point and time label
- const newDataPoint = Math.floor(Math.random() * 50) + 100;
- const newTimeLabel = dayjs().format('HH:mm:ss');
-
- // 4. Push the new data and label
- dataArray.push(newDataPoint);
- labelArray.push(newTimeLabel);
-
- // 5. CRUCIAL: Tell Chart.js to redraw itself without destroying the instance
- chart.update();
+ const data = realtimeLineData.value.datasets[0].data;
+ const labels = realtimeLineData.value.labels;
+
+ // Remove first data point
+ data.shift();
+ labels.shift();
+
+ // Add new data point
+ const newDataPoint = Math.floor(Math.random() * 20) + 40;
+ const newTimeLabel = dayjs().format('HH:mm:ss');
+
+ data.push(newDataPoint);
+ labels.push(newTimeLabel);
+
+ console.log('Realtime chart updated:', newTimeLabel, newDataPoint);
};
-// --- END REFACTORED REALTIME LOGIC ---
+// --- END REALTIME CHART LOGIC ---
-
-// Example data for both years (Tetap)
-const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
-const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
-
-// Check authentication and setup on page load
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
- // Set current date
- const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
- currentDate.value = new Date().toLocaleDateString('id-ID', options);
-
- // Start realtime data update interval
- // Pastikan chart instance sudah siap sebelum memulai interval
- realtimeInterval = setInterval(updateRealtimeData, 5000);
+ // Set date with Indonesian locale
+ currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
+ console.log('Current date set:', currentDate.value);
+
+ // Start realtime updates every 5 seconds
+ realtimeInterval = setInterval(updateRealtimeData, 5000);
+ console.log('Realtime interval started');
} else {
await navigateTo('/LoginPage');
}
@@ -309,34 +347,87 @@ onMounted(async () => {
}
});
-// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
+ console.log('Realtime interval cleared');
}
});
-// Updated logout handler (Tetap)
-const handleLogout = async () => {
- if (isLoggingOut.value) return
-
- try {
- isLoggingOut.value = true
- console.log('🚪 Dashboard logout initiated...')
- await logout()
- } catch (error) {
- console.error('❌ Dashboard logout error:', error)
- } finally {
- isLoggingOut.value = false
- }
-};
-
-// Function to change the active year (Tetap)
const changeYear = (year) => {
activeYear.value = year;
};
-// --- LOGIKA UTAMA UNTUK GRAFIK ANTREEAN (Tetap) ---
+// --- DATA UTILITY FUNCTIONS ---
+const getKpiData = () => {
+ return [
+ { name: 'Antrean Online', value: 150 },
+ { name: 'Antrean MJKN', value: 100 },
+ { name: 'Total Kunjungan', value: 500 },
+ { name: 'Avg. Check-in Time', value: '7.5s' },
+ ];
+};
+
+const convertJsonToCsv = (jsonData, fields) => {
+ if (!jsonData.length) return '';
+
+ const header = fields.join(',');
+
+ const csv = jsonData.map(row =>
+ fields.map(fieldName => {
+ let value = row[fieldName];
+ if (typeof value === 'string') {
+ value = `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+ }).join(',')
+ ).join('\n');
+
+ return header + '\n' + csv;
+};
+// --- END DATA UTILITY FUNCTIONS ---
+
+// --- EXPORT FUNCTION HANDLER ---
+const downloadFile = (data, filename, mimeType) => {
+ const dataStr = `data:${mimeType};charset=utf-8,` + encodeURIComponent(data);
+ const downloadAnchorNode = document.createElement('a');
+ downloadAnchorNode.setAttribute("href", dataStr);
+ downloadAnchorNode.setAttribute("download", filename);
+ document.body.appendChild(downloadAnchorNode);
+ downloadAnchorNode.click();
+ downloadAnchorNode.remove();
+}
+
+const handleExport = (type) => {
+ exportType.value = type.toUpperCase();
+ const filenamePrefix = "antrean_data_" + dayjs().format('YYYYMMDD');
+
+ const dataToExport = mockQueueData.value;
+
+ if (type === 'json') {
+ downloadFile(JSON.stringify(dataToExport, null, 2), `${filenamePrefix}.json`, 'application/json');
+ alert("Data Antrean berhasil diexport sebagai JSON! ✅");
+
+ } else if (type === 'csv') {
+ const fields = ['date', 'registrants', 'attendees'];
+ const csvContent = convertJsonToCsv(dataToExport, fields);
+
+ const kpiData = getKpiData().map(kpi => `# ${kpi.name}: ${kpi.value}`).join('\n');
+ const finalCsvContent = kpiData + '\n' + csvContent;
+
+ downloadFile(finalCsvContent, `${filenamePrefix}.csv`, 'text/csv');
+ alert("Data Antrean berhasil diexport sebagai CSV! 📊");
+
+ } else if (type === 'excel') {
+ alert("Excel (.xlsx) export requires a dedicated server endpoint. Simulating download... ⚠️");
+
+ } else if (type === 'pdf') {
+ alert("PDF (.pdf) export requires client-side libraries (like jsPDF) or a server service. Simulating download... 📝");
+ }
+};
+// --- END EXPORT FUNCTION HANDLER ---
+
+// --- CHART DATA LOGIC ---
const processQueueData = (data, period) => {
const grouped = {};
const sortedDates = data.map(item => ({
@@ -347,37 +438,49 @@ const processQueueData = (data, period) => {
sortedDates.forEach(item => {
let key;
let label;
-
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
- label = item.date.format('DD/MM');
+ label = item.date.format('ddd, DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
- label = `Wk ${item.date.week()} ${item.date.year()}`;
+ label = `Wk ${item.date.week()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
- } else if (period === 'year') {
- key = item.date.format('YYYY');
- label = item.date.format('YYYY');
}
-
if (!grouped[key]) {
- grouped[key] = { label: label, count: 0 };
+ grouped[key] = { label: label, registrants: 0, attendees: 0 };
}
- grouped[key].count += item.count;
+ grouped[key].registrants += item.registrants;
+ grouped[key].attendees += item.attendees;
});
const finalLabels = Object.values(grouped).map(g => g.label);
- const finalCounts = Object.values(grouped).map(g => g.count);
+ const finalRegistrants = Object.values(grouped).map(g => g.registrants);
+ const finalAttendees = Object.values(grouped).map(g => g.attendees);
return {
labels: finalLabels,
datasets: [
{
- label: `Total Antrean per ${period}`,
- backgroundColor: '#FFB300',
- data: finalCounts,
+ label: `Registrants`,
+ backgroundColor: '#FFB95F',
+ data: finalRegistrants,
+ yAxisID: 'y1',
+ type: 'bar',
+ borderRadius: 6,
+ },
+ {
+ label: `Attendees`,
+ backgroundColor: 'transparent',
+ borderColor: '#5FB7FF',
+ data: finalAttendees,
+ yAxisID: 'y1',
+ type: 'line',
+ pointRadius: 5,
+ pointBackgroundColor: '#4A5F7A',
+ tension: 0.4,
+ borderWidth: 3,
},
],
};
@@ -392,66 +495,94 @@ const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
- legend: { display: true },
+ legend: { display: true, position: 'top' },
},
scales: {
- y: {
+ y1: {
beginAtZero: true,
- grid: { display: true }
+ title: { display: true, text: 'Count' },
+ grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
grid: { display: false }
}
}
});
-// --- AKHIR LOGIKA GRAFIK ANTREEAN ---
+const visitorData2024 = [150, 200, 350, 400, 380, 500, 550, 600, 520, 480, 650, 700];
+const visitorData2025 = [200, 250, 400, 450, 420, 550, 600, 650, 570, 520, 700, 750];
+const conversionRate2025 = [2.5, 3.1, 4.0, 4.5, 4.2, 5.5, 6.0, 5.8, 5.7, 5.2, 6.5, 7.5];
-// Computed property Monthly Visitor (Tetap)
const barData = computed(() => {
const dataForYear = activeYear.value === '2024' ? visitorData2024 : visitorData2025;
+ const conversionData = activeYear.value === '2024' ? [2.0, 2.5, 3.5, 3.8, 3.6, 4.5, 5.0, 5.2, 4.8, 4.5, 5.5, 6.0] : conversionRate2025;
+
return {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
- label: `Total Visitors ${activeYear.value}`,
- backgroundColor: '#2196F3',
+ label: `Total Registrants`,
+ backgroundColor: '#FFB95F',
data: dataForYear,
+ yAxisID: 'y1',
+ type: 'bar',
+ borderRadius: 6,
},
+ {
+ label: `Conv. Rate (%)`,
+ borderColor: '#5FB7FF',
+ backgroundColor: 'transparent',
+ data: conversionData,
+ yAxisID: 'y2',
+ type: 'line',
+ pointRadius: 5,
+ pointBackgroundColor: '#4A5F7A',
+ tension: 0.4,
+ borderWidth: 3,
+ }
],
};
});
-// Bar chart options (Tetap)
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
- legend: { display: false },
+ legend: { display: true, position: 'top' },
},
scales: {
- y: {
+ y1: {
+ position: 'left',
beginAtZero: true,
+ title: { display: true, text: 'Registrants' },
grid: { display: false }
},
+ y2: {
+ position: 'right',
+ beginAtZero: true,
+ suggestedMax: 10,
+ title: { display: true, text: 'Conv. Rate (%)' },
+ grid: { drawOnChartArea: false }
+ },
x: {
grid: { display: false }
}
}
});
-// Pie chart data (Tetap)
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
- backgroundColor: ['#2196F3', '#4CAF50'],
+ backgroundColor: ['#5FB7FF', '#FFB95F'],
+ hoverBackgroundColor: ['#1B98FF ', '#FF9B1B'],
data: [759, 1876],
+ borderWidth: 3,
+ borderColor: '#ffffff',
},
],
});
-// Pie chart options (Tetap)
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
@@ -461,7 +592,9 @@ const pieOptions = ref({
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
- return `${label}: ${value}`;
+ const total = context.dataset.data.reduce((a, b) => a + b, 0);
+ const percentage = ((value / total) * 100).toFixed(1);
+ return `${label}: ${value} (${percentage}%)`;
}
}
}
@@ -470,9 +603,89 @@ const pieOptions = ref({
\ No newline at end of file
diff --git a/pages/LoginPage.vue b/pages/LoginPage.vue
index ef7c1eb..c426491 100644
--- a/pages/LoginPage.vue
+++ b/pages/LoginPage.vue
@@ -1,78 +1,6 @@
-
-
-
-
-
- mdi-hospital-building
- ANTREAN RSSA
-
-
-
-
-
-
-
-
- Tentang
- mdi-chevron-down
-
-
-
-
-
- mdi-hospital-building
- Profil Rumah Sakit
-
-
-
-
-
-
-
- Kontak
- mdi-chevron-down
-
-
-
-
-
- mdi-phone
- Hubungi Kami
-
-
- mdi-map-marker
- Alamat & Lokasi
-
-
- mdi-email
- Email
-
-
- mdi-help-circle
- Bantuan
-
-
-
-
-
- mdi-menu
-
-
-
-
mdi-heart-pulse
@@ -92,53 +20,26 @@
mdi-bandage
-