Improve dashboard and navigation access to attendance, group the right-side menu by category, and let each account save default theme and font size.
527 lines
20 KiB
Vue
527 lines
20 KiB
Vue
<!-- /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>
|