Files
web-antrean/pages/Dashboard.vue
T

2275 lines
55 KiB
Vue

<template>
<div class="dashboard-wrapper">
<!-- Modern Hero Header Section -->
<div class="dashboard-hero">
<!-- Geometric Background Pattern -->
<div class="hero-pattern">
<div class="pattern-circle circle-1"></div>
<div class="pattern-circle circle-2"></div>
<div class="pattern-circle circle-3"></div>
<div class="pattern-lines">
<div class="line"></div>
<div class="line"></div>
<div class="line"></div>
</div>
</div>
<v-container fluid class="pa-4 hero-container">
<v-row align="center" class="hero-row">
<!-- Left Section: Welcome & Title -->
<v-col cols="12" md="6" class="hero-left">
<div class="welcome-section">
<div class="welcome-badge-new">
<div class="badge-dot"></div>
<span class="badge-text-new">DASHBOARD ANTREAN</span>
</div>
<h1 class="hero-title-new">
Selamat Datang Kembali! 👋
</h1>
<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>
</div>
<!-- Quick Info Pills -->
<div class="quick-info-pills">
<div class="info-pill">
<v-icon size="16" color="white">mdi-clock-outline</v-icon>
<span>Real-time Data</span>
</div>
<div class="info-pill">
<v-icon size="16" color="white">mdi-calendar-today</v-icon>
<span>{{ currentDate }}</span>
</div>
</div>
</div>
</v-col>
<!-- Right Section: Actions & Stats -->
<v-col cols="12" md="6" class="hero-right">
<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
color="white"
variant="flat"
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 v-if="false" 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"
:key="index"
@click="handleExport(item.type)"
class="export-item-new"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="20"></v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- Mini Stats Cards in Header -->
<div class="header-mini-stats">
<div class="mini-stat-card">
<div class="mini-stat-icon">
<v-icon size="20" color="white">mdi-account-multiple</v-icon>
</div>
<div class="mini-stat-content">
<div class="mini-stat-value">{{ stats[0].value }}</div>
<div class="mini-stat-label">Pasien Hari Ini</div>
</div>
</div>
<div class="mini-stat-card">
<div class="mini-stat-icon secondary">
<v-icon size="20" color="white">mdi-clock-fast</v-icon>
</div>
<div class="mini-stat-content">
<div class="mini-stat-value">{{ stats[1].value }}</div>
<div class="mini-stat-label">Antrean Aktif</div>
</div>
</div>
</div>
</div>
</v-col>
</v-row>
</v-container>
</div>
<!-- Main Content -->
<v-container fluid class="pa-3 dashboard-bg">
<!-- Compact Stats Cards Section -->
<v-row class="mb-2 stats-row">
<v-col cols="12" sm="6" lg="3" v-for="(stat, index) in stats" :key="index">
<v-card
class="stat-card innovative-card"
:class="`stat-card-${stat.color}`"
elevation="0"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<!-- Animated Background Pattern -->
<div class="card-pattern">
<div class="pattern-dot"></div>
<div class="pattern-dot"></div>
<div class="pattern-dot"></div>
</div>
<!-- Card Content -->
<div class="stat-card-content">
<div class="stat-header">
<div class="stat-icon-wrapper modern-icon" :class="`icon-${stat.color}`">
<v-icon :size="32" :color="stat.iconColor">{{ stat.icon }}</v-icon>
<div class="icon-glow"></div>
</div>
<div v-if="stat.change" class="stat-badge" :class="stat.changeType">
<v-icon size="12">{{ stat.changeIcon }}</v-icon>
<span>{{ stat.change }}</span>
</div>
</div>
<div class="stat-body">
<div class="stat-value-wrapper">
<div class="stat-value modern-value">{{ stat.value }}</div>
<div class="value-sparkline"></div>
</div>
<div class="stat-label modern-label">{{ stat.label }}</div>
</div>
</div>
<!-- Hover Effect Gradient -->
<div class="hover-gradient"></div>
<!-- Progress Bar -->
<div class="stat-progress">
<div class="progress-bar" :class="`progress-${stat.color}`"></div>
</div>
</v-card>
</v-col>
</v-row>
<!-- Charts Section -->
<v-row class="mb-2">
<!-- Chart 1: Tren Kunjungan Pasien (Area Chart) -->
<v-col cols="12" lg="8" md="12">
<v-card class="modern-card chart-card">
<div class="chart-header-modern">
<div class="chart-header-left">
<div class="chart-icon-wrapper chart-icon-primary">
<v-icon size="small">mdi-chart-timeline-variant</v-icon>
</div>
<div>
<h3 class="chart-title">Tren Kunjungan Pasien</h3>
<p class="chart-subtitle">Perbandingan kunjungan bulanan</p>
</div>
</div>
<div class="chart-controls">
<v-btn
v-if="false"
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">
<ClientOnly>
<Line
v-if="visitTrendData.labels && visitTrendData.labels.length > 0"
:data="visitTrendData"
:options="areaChartOptions"
class="chart-wrapper"
/>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
<p class="loading-text">Loading chart...</p>
</div>
</template>
</ClientOnly>
</div>
</v-card>
</v-col>
<!-- Chart 2: Status Pembayaran (Doughnut Chart) -->
<v-col cols="12" lg="4" md="6">
<v-card class="modern-card chart-card">
<div class="chart-header-modern">
<div class="chart-header-left">
<div class="chart-icon-wrapper chart-icon-success">
<v-icon size="small">mdi-cash-multiple</v-icon>
</div>
<div>
<h3 class="chart-title">Status Pembayaran</h3>
<p class="chart-subtitle">Distribusi metode bayar</p>
</div>
</div>
<div class="chart-controls">
<v-btn
v-if="false"
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>
<Doughnut
v-if="paymentStatusData.labels && paymentStatusData.labels.length > 0"
:data="paymentStatusData"
:options="doughnutOptions"
class="chart-wrapper pie-chart"
/>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="success" size="50"></v-progress-circular>
<p class="loading-text">Loading chart...</p>
</div>
</template>
</ClientOnly>
</div>
</v-card>
</v-col>
<!-- Chart 3: Waktu Tunggu per Poli (Bar Chart) -->
<v-col cols="12" lg="6" md="6">
<v-card class="modern-card chart-card">
<div class="chart-header-modern">
<div class="chart-header-left">
<div class="chart-icon-wrapper chart-icon-secondary">
<v-icon size="small">mdi-clock-outline</v-icon>
</div>
<div>
<h3 class="chart-title">Waktu Tunggu per Poli</h3>
<p class="chart-subtitle">Rata-rata waktu tunggu (menit)</p>
</div>
</div>
<div class="chart-controls">
<v-btn
v-if="false"
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>
<Bar
v-if="waitingTimeData.labels && waitingTimeData.labels.length > 0"
:data="waitingTimeData"
:options="horizontalBarOptions"
class="chart-wrapper"
/>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="secondary" size="50"></v-progress-circular>
<p class="loading-text">Loading chart...</p>
</div>
</template>
</ClientOnly>
</div>
</v-card>
</v-col>
<!-- Chart 4: Tingkat Kehadiran (Polar Area) -->
<v-col cols="12" lg="6" md="6">
<v-card class="modern-card chart-card">
<div class="chart-header-modern">
<div class="chart-header-left">
<div class="chart-icon-wrapper chart-icon-primary">
<v-icon size="small">mdi-account-check</v-icon>
</div>
<div>
<h3 class="chart-title">Tingkat Kehadiran</h3>
<p class="chart-subtitle">Perbandingan hadir vs tidak hadir</p>
</div>
</div>
<div class="chart-controls">
<v-btn
v-if="false"
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
</v-chip>
</div>
</div>
<div class="chart-container">
<ClientOnly>
<PolarArea
v-if="attendanceData.labels && attendanceData.labels.length > 0"
:data="attendanceData"
:options="polarAreaOptions"
class="chart-wrapper pie-chart"
/>
<template #fallback>
<div class="chart-loading">
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
<p class="loading-text">Loading chart...</p>
</div>
</template>
</ClientOnly>
</div>
</v-card>
</v-col>
</v-row>
<v-overlay v-model="isLoading" contained class="align-center justify-center">
<v-progress-circular indeterminate size="64" color="secondary-600"></v-progress-circular>
<p class="loading-text">Loading dashboard...</p>
</v-overlay>
</v-container>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { Bar, Doughnut, Line, PolarArea } from 'vue-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
LineElement,
PointElement,
Filler,
RadialLinearScale
} from 'chart.js';
ChartJS.register(
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
ArcElement,
LineElement,
PointElement,
Filler,
RadialLinearScale
);
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';
import 'dayjs/locale/id';
dayjs.extend(weekday);
dayjs.extend(weekOfYear);
dayjs.locale('id');
definePageMeta({
middleware:['auth']
})
const user = ref(null);
const isLoading = ref(false);
const { checkAuth } = useAuth();
const { fetchStats } = useVisitAPI();
// Color palette from assets/scss/_colors.scss
const colors = {
primary: {
700: '#3556AE',
600: '#3A61C9',
500: '#567EE7',
400: '#7898EC',
300: '#9AB2F1',
200: '#BBCBF5',
},
secondary: {
700: '#E65A0D',
600: '#F16F29',
500: '#FF8441',
400: '#FF9D67',
300: '#FFB58D',
},
success: {
700: '#008D65',
600: '#33A484',
500: '#59B59B',
400: '#80C6B2',
},
neutral: {
100: '#FFFFFF',
300: '#F5F7FA',
500: '#CDD4DC',
700: '#717171',
900: '#212121',
},
};
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 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([
{
label: 'Pasien Hari Ini',
value: '324',
icon: 'mdi-account-heart',
iconSize: 24,
iconColor: 'white',
color: 'primary',
change: '+18.2%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
},
{
label: 'Antrean Aktif',
value: '47',
icon: 'mdi-clock-fast',
iconSize: 24,
iconColor: 'white',
color: 'secondary',
change: '+5',
changeType: 'positive',
changeIcon: 'mdi-arrow-up'
},
{
label: 'Rata-rata Tunggu',
value: '12 min',
icon: 'mdi-timer-outline',
iconSize: 24,
iconColor: 'white',
color: 'success',
change: '-3 min',
changeType: 'positive',
changeIcon: 'mdi-trending-down'
},
{
label: 'Tingkat Kehadiran',
value: '89.5%',
icon: 'mdi-check-circle',
iconSize: 24,
iconColor: 'white',
color: 'primary',
change: '+2.3%',
changeType: 'positive',
changeIcon: 'mdi-trending-up'
}
]);
// New Data: Monthly Visit Trend (Area Chart)
const visitTrendData = ref({
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Ags', 'Sep', 'Okt', 'Nov', 'Des'],
datasets: [
{
label: 'Pasien Umum',
data: [850, 920, 880, 1050, 1120, 1080, 1200, 1150, 1080, 1190, 1250, 1300],
backgroundColor: 'rgba(86, 126, 231, 0.2)',
borderColor: colors.primary[500],
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: colors.primary[600],
pointBorderColor: '#fff',
pointBorderWidth: 2,
},
{
label: 'Pasien BPJS',
data: [1200, 1350, 1280, 1480, 1550, 1620, 1700, 1650, 1590, 1720, 1800, 1850],
backgroundColor: 'rgba(255, 132, 65, 0.2)',
borderColor: colors.secondary[500],
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: colors.secondary[600],
pointBorderColor: '#fff',
pointBorderWidth: 2,
}
]
});
// Payment Status Data (Doughnut Chart)
const paymentStatusData = ref({
labels: ['BPJS Kesehatan', 'Umum/Tunai', 'Asuransi Swasta', 'Corporate'],
datasets: [
{
data: [1850, 680, 320, 280],
backgroundColor: [
colors.primary[500],
colors.secondary[500],
colors.success[500],
colors.primary[300],
],
borderColor: '#fff',
borderWidth: 3,
hoverOffset: 15,
}
]
});
// Waiting Time per Poli Data (Horizontal Bar Chart)
const waitingTimeData = ref({
labels: ['Poli Umum', 'Poli Anak', 'Poli Gigi', 'Poli Mata', 'Poli THT', 'Poli Jantung'],
datasets: [
{
label: 'Waktu Tunggu (menit)',
data: [15, 12, 8, 18, 10, 22],
backgroundColor: [
colors.primary[400],
colors.secondary[400],
colors.success[400],
colors.primary[500],
colors.secondary[500],
colors.success[500],
],
borderColor: [
colors.primary[600],
colors.secondary[600],
colors.success[600],
colors.primary[700],
colors.secondary[700],
colors.success[700],
],
borderWidth: 2,
borderRadius: 8,
}
]
});
// Attendance Data (Polar Area Chart)
const attendanceData = ref({
labels: ['Hadir Tepat Waktu', 'Hadir Terlambat', 'Tidak Hadir', 'Batal', 'Reschedule'],
datasets: [
{
data: [1850, 420, 280, 150, 230],
backgroundColor: [
colors.success[400],
colors.primary[400],
colors.secondary[400],
colors.neutral[500],
colors.primary[300],
],
borderColor: '#fff',
borderWidth: 2,
}
]
});
// Area Chart Options (Visit Trend)
const areaChartOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
plugins: {
legend: {
display: true,
position: 'top',
align: 'end',
labels: {
usePointStyle: true,
padding: 12,
font: {
size: 11,
weight: '600',
family: "'Inter', sans-serif"
},
color: colors.neutral[700],
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: colors.neutral[900],
bodyColor: colors.neutral[700],
borderColor: colors.neutral[400],
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y.toLocaleString('id-ID')} pasien`;
}
}
},
},
scales: {
x: {
grid: {
display: false,
},
ticks: {
color: colors.neutral[700],
font: {
size: 10,
weight: '500'
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
drawBorder: false,
},
ticks: {
color: colors.neutral[700],
font: {
size: 10,
weight: '600'
},
callback: function(value) {
return value.toLocaleString('id-ID');
}
}
}
},
interaction: {
intersect: false,
mode: 'index'
}
});
// Doughnut Options (Payment Status)
const doughnutOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
animateRotate: true,
animateScale: true,
duration: 1200,
easing: 'easeInOutQuart'
},
cutout: '65%',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
padding: 12,
font: {
size: 10,
weight: '600',
family: "'Inter', sans-serif"
},
color: colors.neutral[700],
generateLabels: function(chart) {
const data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map((label, i) => {
const value = data.datasets[0].data[i];
const total = data.datasets[0].data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return {
text: `${label}: ${percentage}%`,
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i
};
});
}
return [];
}
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: colors.neutral[900],
bodyColor: colors.neutral[700],
borderColor: colors.neutral[400],
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value.toLocaleString('id-ID')} (${percentage}%)`;
}
}
}
}
});
// Horizontal Bar Options (Waiting Time)
const horizontalBarOptions = ref({
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
plugins: {
legend: {
display: false
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: colors.neutral[900],
bodyColor: colors.neutral[700],
borderColor: colors.neutral[400],
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
return `Waktu tunggu: ${context.parsed.x} menit`;
}
}
},
},
scales: {
x: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
drawBorder: false,
},
ticks: {
color: colors.neutral[700],
font: {
size: 10,
weight: '600'
},
callback: function(value) {
return value + ' min';
}
}
},
y: {
grid: {
display: false,
},
ticks: {
color: colors.neutral[700],
font: {
size: 10,
weight: '500'
}
}
}
}
});
// Polar Area Options (Attendance)
const polarAreaOptions = ref({
responsive: true,
maintainAspectRatio: false,
animation: {
animateRotate: true,
animateScale: true,
duration: 1200,
easing: 'easeInOutQuart'
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
padding: 10,
font: {
size: 10,
weight: '600',
family: "'Inter', sans-serif"
},
color: colors.neutral[700],
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
titleColor: colors.neutral[900],
bodyColor: colors.neutral[700],
borderColor: colors.neutral[400],
borderWidth: 1,
padding: 12,
boxPadding: 6,
usePointStyle: true,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed.r;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value.toLocaleString('id-ID')} (${percentage}%)`;
}
}
}
},
scales: {
r: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
ticks: {
color: colors.neutral[700],
font: {
size: 9,
weight: '500'
},
backdropColor: 'transparent'
}
}
}
});
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 {
const sessionUser = await checkAuth();
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) {
console.error('❌ Auth check error:', error);
}
});
const handleExport = (type) => {
// 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>
<style lang="scss" scoped>
@import '@/assets/scss/_colors.scss';
@import '@/assets/scss/_variables.scss';
.dashboard-wrapper {
min-height: 100vh;
background: var(--color-neutral-300);
}
/* Modern Hero Header Section */
.dashboard-hero {
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-primary-700) 100%);
position: relative;
overflow: hidden;
min-height: 180px;
box-shadow: 0 4px 24px rgba(58, 97, 201, 0.2);
/* Geometric Pattern Background */
.hero-pattern {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0.1;
pointer-events: none;
}
.pattern-circle {
position: absolute;
border-radius: 50%;
border: 2px solid white;
&.circle-1 {
width: 400px;
height: 400px;
top: -200px;
right: -100px;
animation: rotate-slow 30s linear infinite;
}
&.circle-2 {
width: 250px;
height: 250px;
bottom: -100px;
left: -50px;
animation: rotate-slow 25s linear infinite reverse;
}
&.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 40%;
animation: pulse-circle 4s ease-in-out infinite;
}
}
.pattern-lines {
position: absolute;
inset: 0;
.line {
position: absolute;
height: 1px;
background: linear-gradient(90deg, transparent, white, transparent);
width: 100%;
&:nth-child(1) {
top: 20%;
animation: slide-line 8s linear infinite;
}
&:nth-child(2) {
top: 50%;
animation: slide-line 10s linear infinite;
}
&:nth-child(3) {
top: 80%;
animation: slide-line 12s linear infinite;
}
}
}
}
@keyframes rotate-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse-circle {
0%, 100% { transform: scale(1); opacity: 0.3; }
50% { transform: scale(1.2); opacity: 0.6; }
}
@keyframes slide-line {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.hero-container {
position: relative;
z-index: 2;
}
.hero-row {
min-height: 180px;
}
/* Left Section */
.hero-left {
display: flex;
flex-direction: column;
justify-content: center;
}
.welcome-section {
.welcome-badge-new {
display: inline-flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
padding: 6px 16px;
border-radius: 50px;
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
.badge-dot {
width: 8px;
height: 8px;
background: var(--color-secondary-500);
border-radius: 50%;
animation: pulse-dot 2s ease-in-out infinite;
}
.badge-text-new {
font-size: 11px;
font-weight: 700;
color: white;
letter-spacing: 1.2px;
text-transform: uppercase;
}
}
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: 0.7; }
}
.hero-title-new {
font-size: 36px;
font-weight: 900;
color: white;
margin: 0 0 12px 0;
line-height: 1.2;
letter-spacing: -0.5px;
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.15);
}
.hero-subtitle-new {
display: flex;
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;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
}
.user-name-new {
font-size: 22px;
font-weight: 800;
color: white;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.subtitle-desc {
font-size: 13px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
}
}
.quick-info-pills {
display: flex;
gap: 10px;
flex-wrap: wrap;
.info-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(8px);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
span {
font-size: 12px;
font-weight: 600;
color: white;
}
}
}
/* Right Section */
.hero-right {
display: flex;
align-items: center;
justify-content: flex-end;
}
.hero-actions-new {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
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 {
text-transform: none;
font-weight: 700;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
}
.export-btn-new {
padding: 12px 20px !important;
height: 48px !important;
.btn-text {
font-size: 14px;
letter-spacing: 0.3px;
}
}
.export-menu-new {
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
.export-item-new {
padding: 12px 16px;
transition: all 0.2s ease;
&:hover {
background: var(--color-primary-50);
}
}
}
.header-mini-stats {
display: flex;
gap: 10px;
}
.mini-stat-card {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 12px 14px;
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.18);
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
}
.mini-stat-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary-500);
border-radius: 10px;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(86, 126, 231, 0.3);
&.secondary {
background: var(--color-secondary-500);
box-shadow: 0 4px 12px rgba(255, 132, 65, 0.3);
}
}
.mini-stat-content {
display: flex;
flex-direction: column;
gap: 2px;
.mini-stat-value {
font-size: 20px;
font-weight: 900;
color: white;
line-height: 1;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.mini-stat-label {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
line-height: 1.2;
}
}
/* Compact Background */
.dashboard-bg {
background: var(--color-neutral-300);
padding-top: 12px !important;
padding-bottom: 12px !important;
min-height: auto;
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 50%, rgba(58, 97, 201, 0.02) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(241, 111, 41, 0.02) 0%, transparent 50%);
pointer-events: none;
}
}
/* Compact Stats Cards */
.stats-row {
margin-top: -20px;
margin-bottom: 12px !important;
position: relative;
z-index: 3;
:deep(.v-col) {
padding: 6px !important;
display: flex;
}
}
.innovative-card {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 255, 255, 0.7) 100%);
backdrop-filter: blur(20px);
border: 1.5px solid var(--color-primary-200);
border-radius: 24px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
overflow: hidden;
position: relative;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.6s ease-out;
height: 100%;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px) scale(1.01);
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 1);
border-color: rgba(255, 255, 255, 1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-pattern {
position: absolute;
inset: 0;
opacity: 0.03;
pointer-events: none;
.pattern-dot {
position: absolute;
width: 6px;
height: 6px;
background: var(--color-primary-600);
border-radius: 50%;
animation: pulse-dot 3s ease-in-out infinite;
&:nth-child(1) {
top: 20%;
left: 15%;
animation-delay: 0s;
}
&:nth-child(2) {
top: 60%;
right: 20%;
animation-delay: 1s;
}
&:nth-child(3) {
bottom: 25%;
left: 50%;
animation-delay: 2s;
}
}
}
@keyframes pulse-dot {
0%, 100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(1.5);
opacity: 0.6;
}
}
/* Compact Stat Card Layout */
.stat-card-content {
padding: 16px;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 130px;
height: 100%;
flex: 1;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.modern-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.icon-glow {
position: absolute;
inset: -6px;
background: currentColor;
border-radius: 16px;
opacity: 0;
filter: blur(16px);
transition: all 0.4s ease;
}
:deep(.v-icon) {
font-size: 24px !important;
}
}
.innovative-card:hover .modern-icon {
transform: scale(1.1) rotate(-5deg);
.icon-glow {
opacity: 0.4;
}
}
.stat-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 4px 8px;
border-radius: 50px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.3px;
&.positive {
background: var(--color-success-100);
color: var(--color-success-700);
box-shadow: 0 2px 8px rgba(51, 164, 132, 0.2);
}
}
.stat-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.stat-value-wrapper {
display: flex;
align-items: baseline;
gap: 8px;
}
.modern-value {
font-size: 28px;
font-weight: 900;
color: var(--color-neutral-900);
line-height: 1;
font-family: 'Inter', sans-serif;
letter-spacing: -0.5px;
}
.modern-label {
font-size: 11px;
font-weight: 600;
color: var(--color-neutral-700);
text-transform: uppercase;
letter-spacing: 0.8px;
line-height: 1.3;
}
.icon-primary {
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-primary-600) 100%);
color: white;
box-shadow: 0 4px 16px rgba(58, 97, 201, 0.3);
}
.icon-secondary {
background: linear-gradient(135deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
color: white;
box-shadow: 0 4px 16px rgba(241, 111, 41, 0.3);
}
.icon-success {
background: linear-gradient(135deg, var(--color-success-500) 0%, var(--color-success-600) 100%);
color: white;
box-shadow: 0 4px 16px rgba(51, 164, 132, 0.3);
}
.hover-gradient {
position: absolute;
inset: 0;
background: linear-gradient(135deg, transparent 0%, rgba(86, 126, 231, 0.05) 100%);
opacity: 0;
transition: opacity 0.4s ease;
pointer-events: none;
}
.innovative-card:hover .hover-gradient {
opacity: 1;
}
.stat-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0;
transition: width 1.5s cubic-bezier(0.4, 0, 0.2, 1);
&.progress-primary {
background: linear-gradient(90deg, var(--color-primary-500) 0%, var(--color-primary-600) 100%);
}
&.progress-secondary {
background: linear-gradient(90deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
}
&.progress-success {
background: linear-gradient(90deg, var(--color-success-500) 0%, var(--color-success-600) 100%);
}
}
.innovative-card:hover .progress-bar {
width: 100%;
}
/* Compact Chart Cards */
.v-row:not(.stats-row) {
:deep(.v-col) {
padding: 6px !important;
display: flex;
}
}
.modern-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.85) 100%);
backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 10px 40px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--color-primary-500) 0%, var(--color-primary-600) 50%, var(--color-secondary-500) 100%);
opacity: 0;
transition: opacity 0.4s ease;
}
&::after {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(58, 97, 201, 0.05) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.4s ease;
}
&:hover {
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.8) inset;
border-color: rgba(255, 255, 255, 0.5);
transform: translateY(-4px);
&::before {
opacity: 1;
}
&::after {
opacity: 1;
}
}
}
.chart-card {
height: 320px;
display: flex;
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;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.5) 0%, rgba(245, 247, 250, 0.3) 100%);
position: relative;
z-index: 1;
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 18px;
right: 18px;
height: 2px;
background: linear-gradient(90deg, var(--color-primary-500) 0%, var(--color-primary-600) 50%, var(--color-secondary-500) 100%);
opacity: 0.5;
border-radius: 2px;
}
}
.chart-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.chart-icon-wrapper {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
&::before {
content: '';
position: absolute;
inset: -3px;
border-radius: 14px;
background: inherit;
opacity: 0;
filter: blur(10px);
transition: opacity 0.4s ease;
}
:deep(.v-icon) {
font-size: 18px !important;
}
}
.chart-header-modern:hover .chart-icon-wrapper {
transform: scale(1.08) rotate(5deg);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
&::before {
opacity: 0.5;
}
}
.chart-icon-primary {
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-primary-600) 100%);
color: white;
}
.chart-icon-secondary {
background: linear-gradient(135deg, var(--color-secondary-500) 0%, var(--color-secondary-600) 100%);
color: white;
}
.chart-icon-success {
background: linear-gradient(135deg, var(--color-success-500) 0%, var(--color-success-600) 100%);
color: white;
}
.chart-title {
font-size: 15px;
font-weight: 700;
color: var(--color-neutral-900);
margin: 0;
line-height: 1.3;
letter-spacing: -0.3px;
}
.chart-subtitle {
font-size: 11px;
font-weight: 500;
color: var(--color-neutral-600);
margin: 3px 0 0 0;
opacity: 0.8;
letter-spacing: 0.2px;
}
.chart-container {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, rgba(245, 247, 250, 0.1) 100%);
border-radius: 0 0 24px 24px;
overflow: hidden;
}
.chart-wrapper {
width: 100% !important;
height: 100% !important;
max-height: 100%;
canvas {
max-height: 100% !important;
}
}
.pie-chart {
max-width: 100%;
margin: 0 auto;
}
:deep(canvas) {
border-radius: 12px;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.05));
transition: all 0.3s ease;
&:hover {
transform: scale(1.02);
}
}
.chart-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 200px;
.loading-text {
font-size: 13px;
font-weight: 600;
color: var(--color-neutral-700);
margin: 0;
}
}
.live-chip {
font-weight: 700;
font-size: 11px;
color: white;
display: flex;
align-items: center;
gap: 4px;
background: linear-gradient(135deg, var(--color-success-500) 0%, var(--color-success-600) 100%);
box-shadow: 0 2px 10px rgba(51, 164, 132, 0.3);
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 5px 12px !important;
border-radius: 50px !important;
}
.pulse-icon {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
}
}
/* Responsive */
@media (max-width: 960px) {
.dashboard-hero {
min-height: 200px;
}
.hero-row {
min-height: auto;
}
.hero-left,
.hero-right {
text-align: left;
}
.hero-right {
justify-content: flex-start;
margin-top: 16px;
}
.hero-title-new {
font-size: 28px;
}
.hero-subtitle-new {
.user-name-new {
font-size: 18px;
}
}
.hero-actions-new {
max-width: 100%;
}
.header-mini-stats {
flex-direction: column;
}
.mini-stat-card {
width: 100%;
}
.stats-row {
margin-top: -30px;
:deep(.v-col) {
display: flex;
}
}
.innovative-card {
height: 100%;
}
.stat-card-content {
padding: 14px;
min-height: 120px;
}
.modern-value {
font-size: 24px;
}
.modern-label {
font-size: 10px;
}
.modern-icon {
width: 42px;
height: 42px;
}
.chart-header-modern {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
}
.chart-title {
font-size: 14px;
}
.chart-subtitle {
font-size: 10px;
}
.chart-card {
height: 280px;
}
}
@media (max-width: 600px) {
.dashboard-hero {
min-height: 240px;
}
.hero-title-new {
font-size: 24px;
}
.hero-subtitle-new {
.user-name-new {
font-size: 16px;
}
.subtitle-desc {
font-size: 12px;
}
}
.quick-info-pills {
.info-pill {
font-size: 11px;
padding: 5px 10px;
}
}
.export-btn-new {
height: 44px !important;
.btn-text {
font-size: 13px;
}
}
.mini-stat-card {
padding: 10px 12px;
}
.mini-stat-icon {
width: 32px;
height: 32px;
}
.mini-stat-content {
.mini-stat-value {
font-size: 18px;
}
.mini-stat-label {
font-size: 10px;
}
}
.dashboard-bg {
padding: 8px !important;
}
.stats-row,
.v-row:not(.stats-row) {
:deep(.v-col) {
display: flex;
}
}
.innovative-card,
.modern-card {
height: 100%;
}
.stat-card-content {
padding: 12px;
min-height: 110px;
}
.modern-icon {
width: 40px;
height: 40px;
}
.chart-card {
height: 260px;
}
}
</style>