Files
web-antrean/pages/Dashboard.vue
T
2025-12-16 10:42:45 +07:00

744 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="dashboard-title">
Antrean Dashboard
</h1>
<p v-if="user" class="dashboard-subtitle">
Selamat Datang,
<span class="user-name">
{{ 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="primary-600"
variant="flat"
class="export-btn mr-4"
>
<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="date-chip">
<v-icon start icon="mdi-calendar-check-outline" color="primary-600"></v-icon>
{{ currentDate }}
</v-chip>
</div>
</div>
<v-row class="mb-10">
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" color="primary-600" class="mr-4">mdi-tablet-dashboard</v-icon>
<div>
<div class="stat-value">150</div>
<div class="stat-label">Total Antrean Online</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" color="secondary-600" class="mr-4">mdi-account-hard-hat</v-icon>
<div>
<div class="stat-value">100</div>
<div class="stat-label">Total Antrean MJKN</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-primary">
<div class="d-flex align-center">
<v-icon size="48" color="primary-600" class="mr-4">mdi-account-group</v-icon>
<div>
<div class="stat-value">500</div>
<div class="stat-label">Total Kunjungan</div>
</div>
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card class="stat-card stat-card-secondary">
<div class="d-flex align-center">
<v-icon size="48" color="secondary-600" class="mr-4">mdi-timer-sand</v-icon>
<div>
<div class="stat-value">7.5s</div>
<div class="stat-label">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="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registration Trend</span>
<v-btn-toggle v-model="queueTimePeriod" mandatory variant="outlined" color="primary-600" class="period-toggle">
<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="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Total Registrasi Perbulan</span>
<div>
<v-chip
:color="activeYear === '2024' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2024' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip mr-2"
@click="changeYear('2024')"
>
2024
</v-chip>
<v-chip
:color="activeYear === '2025' ? 'primary-600' : 'neutral-300'"
:text-color="activeYear === '2025' ? 'white' : 'neutral-700'"
variant="flat"
class="year-chip"
@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="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Realtime Check-in Velocity</span>
<v-chip size="small" color="success-600" class="live-chip">
<v-icon start size="small">mdi-circle</v-icon>
Live
</v-chip>
</v-card-title>
<v-card-text class="pa-0">
<Line
:data="realtimeLineData"
:options="lineOptions"
style="height: 350px"
/>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="chart-card">
<v-card-title class="chart-header">
<span class="chart-title">Registrant Type Breakdown</span>
</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">
<v-progress-circular indeterminate size="64" color="primary-600"></v-progress-circular>
<p class="loading-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 { useAuth } from '~/composables/useAuth';
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);
const user = ref(null);
const isLoading = ref(false);
const { checkAuth } = useAuth();
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 currentDate = ref('');
const activeYear = ref('2025');
const queueTimePeriod = ref('day');
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 },
]);
// --- SIMPLIFIED REALTIME CHART (NO AUTO-UPDATE) ---
const maxDataPoints = 10;
const realtimeLineData = ref({
labels: Array.from({ length: maxDataPoints }, (_, i) =>
dayjs().subtract((maxDataPoints - 1 - i) * 30, 'second').format('HH:mm:ss')
),
datasets: [
{
label: 'Check-ins/min',
backgroundColor: 'rgba(6, 99, 199, 0.1)',
borderColor: '#0671E0',
data: Array.from({ length: maxDataPoints }, () => Math.floor(Math.random() * 20) + 40),
fill: true,
tension: 0.3,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#0663C7',
},
],
});
const lineOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
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 }
}
}
});
// --- END SIMPLIFIED REALTIME CHART ---
// Single onMounted
onMounted(async () => {
console.log('📊 Dashboard mounted');
try {
const sessionUser = await checkAuth();
if (sessionUser) {
user.value = sessionUser;
currentDate.value = dayjs().format('dddd, DD MMMM YYYY');
console.log('✅ Dashboard loaded successfully');
}
} catch (error) {
console.error('❌ Auth check error:', error);
}
});
// Clean onUnmounted
onUnmounted(() => {
console.log('🧹 Dashboard unmounting');
});
const changeYear = (year) => {
activeYear.value = year;
};
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;
};
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... 📝");
}
};
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: '#0671E0',
data: finalAttendees,
yAxisID: 'y1',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
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: '#0671E0',
backgroundColor: 'transparent',
data: conversionData,
yAxisID: 'y2',
type: 'line',
pointRadius: 5,
pointBackgroundColor: '#0663C7',
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: ['#0671E0', '#FFB95F'],
hoverBackgroundColor: ['#0053AD', '#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 lang="scss">
/* Background */
.dashboard-bg {
background: var(--color-neutral-300);
}
/* Typography */
.dashboard-title {
font-size: 32px;
font-weight: 800;
color: var(--color-neutral-900);
margin: 0;
line-height: 1.2;
}
.dashboard-subtitle {
font-size: 16px;
font-weight: 500;
color: var(--color-neutral-700);
margin: 8px 0 0 0;
}
.user-name {
font-weight: 700;
color: var(--color-primary-600);
}
/* Export Button */
.export-btn {
text-transform: none;
font-weight: 600;
font-size: 14px;
letter-spacing: 0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(255, 155, 27, 0.2);
}
/* Date Chip */
.date-chip {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid var(--color-neutral-500);
}
/* Stat Cards */
.stat-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 2px solid transparent;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
}
.stat-card-primary {
border-left-color: var(--color-primary-600);
}
.stat-card-secondary {
border-left-color: var(--color-secondary-600);
}
.stat-value {
font-size: 36px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: var(--color-neutral-700);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.4;
}
/* Chart Cards */
.chart-card {
background: var(--color-neutral-100);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--color-neutral-500);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
min-height: 480px;
display: flex;
flex-direction: column;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 0 16px 0;
margin-bottom: 16px;
}
.chart-title {
font-size: 18px;
font-weight: 700;
color: var(--color-neutral-900);
}
.period-toggle {
font-size: 12px;
font-weight: 600;
border-radius: 8px;
}
.year-chip {
cursor: pointer;
font-size: 13px;
font-weight: 700;
border-radius: 20px;
transition: all 0.2s ease;
}
.year-chip:hover {
transform: scale(1.05);
}
.live-chip {
font-weight: 600;
color: var(--color-neutral-100);
}
.chart-card .v-card-text {
flex: 1;
display: flex;
flex-direction: column;
}
/* Loading */
.loading-text {
margin-top: 16px;
color: var(--color-neutral-900);
font-weight: 600;
font-size: 16px;
}
/* Responsive */
@media (max-width: 960px) {
.dashboard-title {
font-size: 24px;
}
.dashboard-subtitle {
font-size: 14px;
}
.d-flex.justify-space-between {
flex-direction: column;
gap: 16px;
align-items: flex-start !important;
}
.d-flex.align-center .mr-4 {
margin-right: 0 !important;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
}
.stat-label {
font-size: 11px;
}
}
</style>