Files
gameno-dashboard/src/views/dashboard/DashboardHomeView.vue
T
kavehhn ba509734ae Add attendance hub, categorized sidebar, and UI preferences page.
Improve dashboard and navigation access to attendance, group the right-side menu by category, and let each account save default theme and font size.
2026-08-16 07:28:58 +03:30

527 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /src/views/dashboard/DashboardHomeView.vue -->
<template>
<div class="dashboard-home-view">
<PageHeader title="داشبورد مدیریتی" subtitle="خلاصه آمار، عملکرد و دسترسی‌های سریع سیستم">
<Button icon="pi pi-refresh" text rounded v-tooltip.top="'بروزرسانی'" :loading="isLoading" @click="loadStats" />
</PageHeader>
<!-- ── Stats Cards ──────────────────────────────────────────────────────── -->
<div class="grid mb-4">
<div class="col-12 sm:col-6 lg:col-3" v-for="stat in statCards" :key="stat.title">
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3">
<div class="flex-grow-1">
<span class="text-muted text-xs block mb-1">{{ stat.title }}</span>
<div class="flex align-items-baseline gap-2">
<span class="text-3xl font-bold text-color">
<span v-if="isLoading"></span>
<span v-else>{{ toPersianDigits(stat.value) }}</span>
</span>
<span v-if="stat.sub !== undefined && !isLoading" class="text-xs text-muted">
({{ toPersianDigits(stat.sub) }} فعال)
</span>
</div>
</div>
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="stat.bgClass">
<i :class="stat.icon" class="text-xl"></i>
</div>
</div>
</div>
</div>
<!-- ── Charts Row ───────────────────────────────────────────────────────── -->
<div class="grid mb-4">
<!-- Daily Enrollment Chart -->
<div class="col-12 lg:col-8">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<div class="flex align-items-center justify-content-between mb-4">
<h3 class="text-base font-bold text-color m-0">روند ثبتنام کاربران (۱۴ روز اخیر)</h3>
<Tag value="روزانه" severity="info" />
</div>
<div class="chart-container" style="height: 200px;">
<div v-if="isLoading" class="flex align-items-center justify-content-center h-full">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<div v-else-if="!dailyLabels.length" class="flex align-items-center justify-content-center h-full text-muted">
<span>دادهای برای نمایش وجود ندارد</span>
</div>
<div v-else class="bar-chart-wrapper h-full flex align-items-end gap-1">
<div
v-for="(item, idx) in dailyEnrollmentData"
:key="idx"
class="bar-group flex flex-column align-items-center gap-1 flex-grow-1"
>
<span class="text-xs font-bold text-primary">{{ toPersianDigits(item.count) }}</span>
<div
class="bar-fill border-round-top"
:style="{ height: getBarHeight(item.count, maxEnrollment) + 'px', minHeight: '4px' }"
></div>
<span class="bar-label text-muted text-center">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Summary Doughnuts -->
<div class="col-12 lg:col-4">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت فعال / کل</h3>
<div class="flex flex-column gap-3">
<div v-for="ring in ringStats" :key="ring.label" class="flex align-items-center gap-3">
<div class="ring-chart flex-shrink-0" :style="getRingStyle(ring)">
<span class="ring-pct text-xs font-bold">{{ ring.pct }}٪</span>
</div>
<div class="flex-grow-1">
<div class="text-sm font-semibold text-color">{{ ring.label }}</div>
<div class="text-xs text-muted mt-1">
<span class="font-bold" :class="ring.activeClass">{{ toPersianDigits(ring.active) }}</span>
<span class="mx-1">/</span>
<span>{{ toPersianDigits(ring.total) }} کل</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Quick Access ─────────────────────────────────────────────────────── -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
<h3 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">دسترسی سریع</h3>
<div class="grid">
<div
v-for="link in quickLinks"
:key="link.to"
class="col-6 sm:col-4 lg:col-2"
>
<div
class="quick-link-card border-round-xl p-3 flex flex-column align-items-center gap-2 cursor-pointer text-center"
:class="link.cardClass"
@click="$router.push(link.to)"
>
<div class="quick-link-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center" :class="link.iconBg">
<i :class="[link.icon, 'text-xl', link.iconColor]"></i>
</div>
<span class="text-sm font-semibold text-color">{{ link.label }}</span>
<Tag v-if="link.badge !== undefined" :value="toPersianDigits(link.badge)" :severity="link.badgeSeverity || 'secondary'" class="text-xs" />
</div>
</div>
</div>
</div>
<!-- ── Attendance ───────────────────────────────────────────────────────── -->
<PermissionGate :permission="PERMISSIONS.SESSIONS_ATTENDANCE">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
<div class="flex align-items-center justify-content-between mb-3">
<div>
<h3 class="text-base font-bold text-color m-0">حضور و غیاب</h3>
<p class="text-xs text-muted m-0 mt-1">جلسات اخیر که نیاز به ثبت حضور دارند</p>
</div>
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/attendance')" />
</div>
<DataTable :value="attendanceSessions" class="p-datatable-sm text-sm" :loading="isAttendanceLoading">
<template #empty>
<div class="text-center text-muted p-4">جلسهای برای ثبت حضور وجود ندارد</div>
</template>
<Column field="topic" header="موضوع">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
</template>
</Column>
<Column field="course" header="دوره">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '—' }}
</template>
</Column>
<Column field="day" header="تاریخ">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="attendanceStatus" header="وضعیت">
<template #body="{ data }">
<Tag
:value="hasAttendance(data) ? 'ثبت شده' : 'در انتظار'"
:severity="hasAttendance(data) ? 'success' : 'warn'"
/>
</template>
</Column>
<Column header="عملیات" style="width: 100px">
<template #body="{ data }">
<Button
icon="pi pi-check-square"
:label="hasAttendance(data) ? 'ویرایش' : 'ثبت'"
size="small"
text
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
</template>
</Column>
</DataTable>
</div>
</PermissionGate>
<!-- ── Recent Sessions ─────────────────────────────────────────────────── -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">آخرین جلسات</h3>
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/sessions')" />
</div>
<DataTable :value="recentSessions" class="p-datatable-sm text-sm" :loading="isLoading">
<template #empty>
<div class="text-center text-muted p-4">هنوز جلسهای ثبت نشده است</div>
</template>
<Column field="topic" header="موضوع">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
</template>
</Column>
<Column field="course" header="دوره">
<template #body="{ data }">
{{ data.course?.title || data.courseTitle || '—' }}
</template>
</Column>
<Column field="class" header="کلاس">
<template #body="{ data }">
{{ data.class?.name || '—' }}
</template>
</Column>
<Column field="professor" header="استاد">
<template #body="{ data }">
{{
data.professor
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
: '—'
}}
</template>
</Column>
<Column field="day" header="تاریخ">
<template #body="{ data }">
{{ formatJalali(data.day || data.date) }}
</template>
</Column>
<Column field="time" header="ساعت">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column field="status" header="وضعیت">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import { dashboardApi } from '@/api/dashboardApi';
import { sessionApi } from '@/api/sessionApi';
import { usePermissionStore } from '@/stores/permissionStore';
import { PERMISSIONS } from '@/constants/permissions';
import PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import ProgressSpinner from 'primevue/progressspinner';
const { toPersianDigits, formatJalali } = usePersianDate();
const permissionStore = usePermissionStore();
// ── State ─────────────────────────────────────────────────────────────────────
const isLoading = ref(false);
const isAttendanceLoading = ref(false);
const attendanceSessions = ref([]);
const stats = ref({
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
recentSessions: [],
charts: { dailyUsers: [], dailySessions: [] }
});
// ── Stat Cards ────────────────────────────────────────────────────────────────
const statCards = computed(() => [
{
title: 'کل کاربران',
value: stats.value.totals.users,
sub: stats.value.totals.activeUsers,
icon: 'pi pi-users text-blue-500',
bgClass: 'bg-blue-100'
},
{
title: 'اساتید موسسه',
value: stats.value.totals.professors,
sub: stats.value.totals.activeProfessors,
icon: 'pi pi-id-card text-green-500',
bgClass: 'bg-green-100'
},
{
title: 'دوره‌های آموزشی',
value: stats.value.totals.courses,
icon: 'pi pi-book text-purple-500',
bgClass: 'bg-purple-100'
},
{
title: 'کل جلسات',
value: stats.value.totals.sessions,
icon: 'pi pi-calendar text-orange-500',
bgClass: 'bg-orange-100'
}
]);
// ── Ring Stats ────────────────────────────────────────────────────────────────
const ringStats = computed(() => {
const t = stats.value.totals;
return [
{
label: 'کاربران فعال',
total: t.users,
active: t.activeUsers,
pct: t.users ? Math.round((t.activeUsers / t.users) * 100) : 0,
color: '#3B82F6',
activeClass: 'text-blue-500'
},
{
label: 'اساتید فعال',
total: t.professors,
active: t.activeProfessors,
pct: t.professors ? Math.round((t.activeProfessors / t.professors) * 100) : 0,
color: '#10B981',
activeClass: 'text-green-500'
}
];
});
// ── Chart Helpers ─────────────────────────────────────────────────────────────
const dailyEnrollmentData = computed(() => {
const raw = stats.value.charts?.dailyUsers || [];
return raw.map((item) => ({
count: item.count || 0,
label: formatJalali(item.date || buildDateFromParts(item._id), 'jD jMMMM')
}));
});
const buildDateFromParts = (parts) => {
if (!parts?.year || !parts?.month || !parts?.day) return null;
return new Date(parts.year, parts.month - 1, parts.day);
};
const dailyLabels = computed(() => dailyEnrollmentData.value.map((i) => i.label));
const maxEnrollment = computed(() => {
const counts = dailyEnrollmentData.value.map((i) => i.count);
return Math.max(...counts, 1);
});
const MAX_BAR_HEIGHT = 140;
const getBarHeight = (val, max) => Math.max(4, (val / max) * MAX_BAR_HEIGHT);
const getRingStyle = (ring) => {
const deg = (ring.pct / 100) * 360;
return {
background: `conic-gradient(${ring.color} ${deg}deg, var(--surface-border) ${deg}deg)`,
borderRadius: '50%',
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative'
};
};
// ── Recent Sessions ───────────────────────────────────────────────────────────
const recentSessions = computed(() => stats.value.recentSessions || []);
// ── Quick Links ───────────────────────────────────────────────────────────────
const quickLinks = computed(() => [
{
to: '/users/create',
label: 'افزودن کاربر',
icon: 'pi pi-user-plus',
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
cardClass: 'quick-link-blue',
badge: stats.value.totals.users,
badgeSeverity: 'info'
},
{
to: '/professors/create',
label: 'افزودن استاد',
icon: 'pi pi-plus-circle',
iconBg: 'bg-green-100',
iconColor: 'text-green-600',
cardClass: 'quick-link-green',
badge: stats.value.totals.professors,
badgeSeverity: 'success'
},
{
to: '/courses/create',
label: 'دوره جدید',
icon: 'pi pi-book',
iconBg: 'bg-purple-100',
iconColor: 'text-purple-600',
cardClass: 'quick-link-purple',
badge: stats.value.totals.courses,
badgeSeverity: 'secondary'
},
{
to: '/sessions/create',
label: 'جلسه جدید',
icon: 'pi pi-calendar-plus',
iconBg: 'bg-orange-100',
iconColor: 'text-orange-600',
cardClass: 'quick-link-orange',
badge: stats.value.totals.sessions,
badgeSeverity: 'warning'
},
...(permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)
? [{
to: '/attendance',
label: 'حضور و غیاب',
icon: 'pi pi-check-square',
iconBg: 'bg-amber-100',
iconColor: 'text-amber-600',
cardClass: 'quick-link-amber'
}]
: []),
{
to: '/payments',
label: 'امور مالی',
icon: 'pi pi-wallet',
iconBg: 'bg-teal-100',
iconColor: 'text-teal-600',
cardClass: 'quick-link-teal'
},
{
to: '/roles',
label: 'نقش‌ها',
icon: 'pi pi-shield',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
cardClass: 'quick-link-red'
}
]);
const hasAttendance = (session) => Array.isArray(session.attendanceList) && session.attendanceList.length > 0;
// ── Load Data ─────────────────────────────────────────────────────────────────
const loadAttendanceSessions = async () => {
if (!permissionStore.hasPermission(PERMISSIONS.SESSIONS_ATTENDANCE)) return;
isAttendanceLoading.value = true;
try {
const res = await sessionApi.getAll({ page: 1, limit: 8, sortBy: 'day', sortOrder: 'desc' });
const data = res.data || res;
const sessions = Array.isArray(data) ? data : (data.data || []);
attendanceSessions.value = sessions
.filter((s) => !hasAttendance(s))
.slice(0, 5);
if (attendanceSessions.value.length < 5) {
const recorded = sessions.filter((s) => hasAttendance(s)).slice(0, 5 - attendanceSessions.value.length);
attendanceSessions.value = [...attendanceSessions.value, ...recorded];
}
} catch (err) {
console.warn('Attendance sessions not available:', err.message);
attendanceSessions.value = [];
} finally {
isAttendanceLoading.value = false;
}
};
const loadStats = async () => {
isLoading.value = true;
try {
const res = await dashboardApi.getStats();
const data = res.data || res;
// Support both { data: { totals... } } and { totals... } structures
stats.value = data.data || data;
} catch (err) {
console.warn('Dashboard stats not available, showing zeros:', err.message);
// Graceful fallback — zeros, no crash
} finally {
isLoading.value = false;
}
};
onMounted(() => {
loadStats();
loadAttendanceSessions();
});
</script>
<style scoped lang="scss">
.stat-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1) !important;
}
}
.stat-icon {
transition: transform 0.2s ease;
.stat-card:hover & {
transform: scale(1.05);
}
}
.bar-chart-wrapper {
padding-bottom: 4px;
}
.bar-fill {
width: 100%;
background: linear-gradient(to top, var(--primary-color), var(--primary-color-light, #818cf8));
min-width: 12px;
transition: height 0.4s ease;
}
.bar-label {
font-size: 9px;
line-height: 1.2;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ring-chart {
position: relative;
.ring-pct {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--surface-card);
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
}
}
.quick-link-card {
background: var(--surface-ground);
transition: all 0.2s ease;
border: 1px solid transparent;
&:hover {
transform: translateY(-3px);
border-color: var(--primary-color);
background: var(--primary-50, rgba(99, 102, 241, 0.04));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
}
</style>