Initial commit: admin dashboard for GameNo.
Vue 3 + Vite dashboard with PrimeVue, i18n, and API integration for institution management.
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user