+
+
+
-
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 +158,35 @@
-
- Realtime Ticket Queue
-
+
+ Realtime Check-in Velocity
+
-
- Registrant Breakdown
-
+
+ Registrant Type Breakdown
+
-
-
- Loading dashboard...
+
+
+ Loading dashboard...
@@ -174,6 +215,7 @@ import {
LineElement,
} from 'chart.js';
+// Ensure this middleware setup is correct for your framework (e.g., Nuxt)
definePageMeta({
middleware:['auth']
})
@@ -181,54 +223,59 @@ definePageMeta({
// Register necessary Chart.js elements
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
-// Use the auth composable
+// --- KEYCLOAK/AUTH INTEGRATION ---
const { user, isLoading, checkAuth, logout } = useAuth()
+const navigateTo = (path) => { console.log('Navigating to', path); };
+// ---------------------------------
+
+// --- 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' },
+]);
+// --------------------
+
const isLoggingOut = ref(false)
-// Dashboard data
+// 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 ---
+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')
+ dayjs().subtract((maxDataPoints - 1 - i) * 10, '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,
+ 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',
},
],
};
@@ -236,9 +283,7 @@ const initialLineData = {
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
- animation: {
- duration: 0 // Crucial: Disable animation for smooth realtime scrolling
- },
+ animation: { duration: 0 },
plugins: {
legend: { display: true },
title: { display: false }
@@ -246,9 +291,9 @@ const lineOptions = ref({
scales: {
y: {
beginAtZero: true,
- suggestedMax: 200,
- title: { display: true, text: 'Count' },
- grid: { display: true }
+ suggestedMax: 80,
+ title: { display: true, text: 'Check-ins per Minute' },
+ grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
@@ -257,49 +302,28 @@ const lineOptions = ref({
}
});
-// 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 newDataPoint = Math.floor(Math.random() * 20) + 40;
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();
};
-// --- 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);
+ realtimeInterval = setInterval(updateRealtimeData, 10000);
} else {
await navigateTo('/LoginPage');
}
@@ -309,34 +333,86 @@ onMounted(async () => {
}
});
-// Clear the interval when the component is unmounted
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
}
});
-// 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 +423,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 +480,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 +577,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 +588,89 @@ const pieOptions = ref({
\ No newline at end of file
diff --git a/pages/LoginPage.vue b/pages/LoginPage.vue
index ef7c1eb..2a7d129 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,18 +20,16 @@
mdi-bandage
-