Files
Web-Antrean/pages/Dashboard.vue

691 lines
20 KiB
Vue

<template>
<v-container fluid class="pa-8 dashboard-bg">
<div class="d-flex justify-space-between align-center mb-10">
<div>
<h1 class="text-h3 font-weight-black primary-text">
Antrean Dashboard
</h1>
<p v-if="user" class="text-subtitle-1 secondary-text mt-2">
Selamat Datang,
<span class="font-weight-bold primary-accent">
{{ user.name || user.preferred_username }}
</span>! 🍊
</p>
</div>
<div class="d-flex align-center">
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
color="#FF9B1B"
variant="flat"
class="text-caption font-weight-bold rounded-lg elevation-2 mr-4 export-btn"
>
<v-icon start icon="mdi-download-box-outline"></v-icon>
Export Data ({{ exportType }})
<v-icon end>mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
v-for="(item, index) in exportOptions"
:key="index"
@click="handleExport(item.type)"
>
<v-list-item-title>
<v-icon start :icon="item.icon"></v-icon>
{{ item.title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-chip color="white" variant="flat" class="pa-4 font-weight-bold text-caption secondary-text rounded-lg date-chip">
<v-icon start icon="mdi-calendar-check-outline" class="primary-accent"></v-icon>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 primary-accent">mdi-tablet-dashboard</v-icon>
<div>
<div class="text-h4 font-weight-black primary-accent">150</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean Online</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 secondary-accent">mdi-account-hard-hat</v-icon>
<div>
<div class="text-h4 font-weight-black secondary-accent">100</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Antrean MJKN</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #D17A4A;">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 primary-accent">mdi-account-group</v-icon>
<div>
<div class="text-h4 font-weight-black primary-accent">500</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Total Kunjungan</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="pa-6 rounded-xl elevation-3 hover-effect card-style" style="border-left: 4px solid #4A5F7A;">
<div class="d-flex align-center">
<v-icon size="48" class="mr-4 secondary-accent">mdi-timer-sand</v-icon>
<div>
<div class="text-h4 font-weight-black secondary-accent">7.5s</div>
<div class="text-caption text-uppercase font-weight-medium secondary-light mt-1">Avg. Check-in Time</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="#D17A4A" class="text-caption rounded-lg">
<v-btn size="small" value="day">Day</v-btn>
</v-btn-toggle>
</v-card-title>
<v-card-text class="pa-0">
<Bar
:data="queueChartData"
:options="queueChartOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="d-flex justify-space-between align-center px-0 pt-0 mb-4">
<span class="text-h6 font-weight-bold primary-text">Total Registrasi Perbulan</span>
<div>
<v-chip
:color="activeYear === '2024' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2024' ? 'white' : '#FF9B1B'"
variant="flat"
class="mr-2 text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? '#D17A4A' : '#F5F5F5'"
:text-color="activeYear === '2025' ? 'white' : '#FF9B1B'"
variant="flat"
class="text-caption font-weight-bold cursor-pointer transition-chip rounded-pill"
@click="changeYear('2025')"
>
2025
</v-chip>
</div>
</v-card-title>
<v-card-text class="pa-0">
<Bar
:data="barData"
:options="barOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">
Realtime Check-in Velocity
<v-chip size="small" color="success" class="ml-2">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
</v-card-title>
<v-card-text class="pa-0">
<Line
ref="realtimeChart"
:data="realtimeLineData"
:options="lineOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-6 rounded-xl elevation-3 card-style chart-card">
<v-card-title class="text-h6 font-weight-bold primary-text px-0 pt-0 mb-4">Registrant Type Breakdown</v-card-title>
<v-card-text class="d-flex justify-center align-center pa-0" style="height: 350px;">
<Pie
:data="pieData"
:options="pieOptions"
style="max-height: 100%; max-width: 100%;"
/>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center dashboard-bg">
<v-progress-circular indeterminate size="64" color="#D17A4A"></v-progress-circular>
<p class="mt-4 primary-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue';
import { Bar, Pie, Line } from 'vue-chartjs';
import dayjs from '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,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
PointElement,
LineElement,
} from 'chart.js';
definePageMeta({
middleware:['auth']
})
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement, PointElement, LineElement);
// --- 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);
// ---------------------------------
// --- 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('day');
// --- MOCK DATA FOR CHARTS ---
const mockQueueData = ref([
{ 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 },
]);
// --- REALTIME CHART LOGIC - FIXED ---
const realtimeChart = ref(null);
const maxDataPoints = 10;
let realtimeInterval = null;
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',
},
],
});
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)' }
},
x: {
title: { display: true, text: 'Time (HH:MM:SS)' },
grid: { display: false }
}
}
});
const updateRealtimeData = () => {
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 REALTIME CHART LOGIC ---
onMounted(async () => {
try {
const sessionUser = await checkAuth()
if (sessionUser) {
// 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');
}
} catch (error) {
console.error('Auth check error:', error);
await navigateTo('/LoginPage');
}
});
onUnmounted(() => {
if (realtimeInterval) {
clearInterval(realtimeInterval);
console.log('Realtime interval cleared');
}
});
const changeYear = (year) => {
activeYear.value = year;
};
// --- 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 => ({
...item,
date: dayjs(item.date)
})).sort((a, b) => a.date.valueOf() - b.date.valueOf());
sortedDates.forEach(item => {
let key;
let label;
if (period === 'day') {
key = item.date.format('YYYY-MM-DD');
label = item.date.format('ddd, DD/MM');
} else if (period === 'week') {
key = item.date.format('YYYY-WW');
label = `Wk ${item.date.week()}`;
} else if (period === 'month') {
key = item.date.format('YYYY-MM');
label = item.date.format('MMM YYYY');
}
if (!grouped[key]) {
grouped[key] = { label: label, registrants: 0, attendees: 0 };
}
grouped[key].registrants += item.registrants;
grouped[key].attendees += item.attendees;
});
const finalLabels = Object.values(grouped).map(g => g.label);
const finalRegistrants = Object.values(grouped).map(g => g.registrants);
const finalAttendees = Object.values(grouped).map(g => g.attendees);
return {
labels: finalLabels,
datasets: [
{
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,
},
],
};
};
const queueChartData = computed(() => {
if (!mockQueueData.value.length) return { labels: [], datasets: [] };
return processQueueData(mockQueueData.value, queueTimePeriod.value);
});
const queueChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' },
},
scales: {
y1: {
beginAtZero: true,
title: { display: true, text: 'Count' },
grid: { display: true, color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
grid: { display: false }
}
}
});
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];
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 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,
}
],
};
});
const barOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'top' },
},
scales: {
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 }
}
}
});
const pieData = ref({
labels: ['Offline Registrants', 'Online Registrants'],
datasets: [
{
backgroundColor: ['#5FB7FF', '#FFB95F'],
hoverBackgroundColor: ['#1B98FF ', '#FF9B1B'],
data: [759, 1876],
borderWidth: 3,
borderColor: '#ffffff',
},
],
});
const pieOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
});
</script>
<style scoped>
/* Color Palette */
.dashboard-bg {
background-color: #FAFAFA;
}
.card-style {
background-color: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Text Colors */
.primary-text {
color: #2C3E50 !important;
}
.secondary-text {
color: #7B8794 !important;
}
.secondary-light {
color: #7B8794 !important;
}
.primary-accent {
color: #FF9B1B !important;
}
.secondary-accent {
color: #1B98FF !important;
}
/* Buttons & Interactive Elements */
.export-btn {
transition: all 0.3s ease;
text-transform: none;
}
.export-btn:hover {
background-color: #E0916E !important;
transform: translateY(-1px);
}
.cursor-pointer {
cursor: pointer;
}
/* Card Hover Effect - Subtle and Clean */
.hover-effect {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.hover-effect:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08) !important;
}
/* Chip Transition */
.transition-chip {
transition: all 0.25s ease;
}
.transition-chip:hover {
transform: scale(1.05);
}
/* Date Chip Styling */
.date-chip {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid rgba(0, 0, 0, 0.04);
}
/* Chart Card Fixed Height */
.chart-card {
min-height: 480px;
display: flex;
flex-direction: column;
}
.chart-card .v-card-text {
flex: 1;
display: flex;
flex-direction: column;
}
</style>