Initial commit: admin dashboard for GameNo.

Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
2026-08-09 04:18:09 +02:00
commit 1a34c3b133
94 changed files with 12016 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
<!-- /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>
<!-- 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 PageHeader from '@/components/common/PageHeader.vue';
import StatusTag from '@/components/common/StatusTag.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();
// ── State ─────────────────────────────────────────────────────────────────────
const isLoading = ref(false);
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'
},
{
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'
}
]);
// ── Load Data ─────────────────────────────────────────────────────────────────
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();
});
</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>