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
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
@@ -0,0 +1,548 @@
<!-- Sessions management for a class: list existing, bulk create when empty, manual add at bottom -->
<template>
<div class="class-sessions-section flex flex-column gap-4">
<!-- Always show sessions list below students -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div>
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
<p class="text-muted text-sm m-0 mt-1">جلسات برنامهریزیشده برای این کلاس</p>
</div>
<div class="flex align-items-center gap-2 flex-wrap">
<Tag :value="`${toPersianDigits(sessions.length)} جلسه`" severity="info" />
<template v-if="selectedSessions.length">
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخاب‌شده`" />
<Select
v-model="bulkStatus"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="تغییر وضعیت"
class="text-sm"
style="min-width: 9rem"
/>
<Button
label="اعمال"
icon="pi pi-check"
size="small"
:loading="isBulkUpdating"
:disabled="!bulkStatus"
@click="applyBulkStatus"
/>
<Button
label="حذف"
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="confirmBulkDelete"
/>
<Button label="لغو" text size="small" severity="secondary" @click="selectedSessions = []" />
</template>
</div>
</div>
<div v-if="loadingSessions" class="mb-2">
<TableSkeleton :rows="6" :columns="6" />
</div>
<DataTable
v-else
v-model:selection="selectedSessions"
:value="sessions"
dataKey="_id"
class="p-datatable-sm text-sm"
emptyMessage="جلسه‌ای ثبت نشده است"
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column header="#" style="width: 50px">
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
</Column>
<Column header="موضوع">
<template #body="{ data }">{{ data.topic || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
</Column>
<Column header="زمان">
<template #body="{ data }">
<span dir="ltr">{{ data.startTime || '—' }} {{ data.endTime || '—' }}</span>
</template>
</Column>
<Column header="وضعیت" style="width: 100px">
<template #body="{ data }">
<StatusTag :status="data.status || 'scheduled'" type="session" />
</template>
</Column>
<Column header="عملیات" style="width: 140px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<Button
icon="pi pi-check-square"
text
rounded
size="small"
severity="success"
v-tooltip.top="'حضور و غیاب'"
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
/>
<Button
icon="pi pi-pencil"
text
rounded
size="small"
severity="warning"
v-tooltip.top="'ویرایش'"
@click="$router.push(`/sessions/edit/${data._id || data.id}`)"
/>
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
v-tooltip.top="'حذف'"
@click="confirmDeleteOne(data)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
:loading="isDeleting"
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : 'حذف جلسه'"
:message="deleteMessage"
@confirm="handleDeleteConfirm"
/>
<!-- Bulk auto-create: only when no sessions yet -->
<div v-if="!hasSessions" class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">ایجاد جلسات کلاس</h2>
<p class="text-muted text-sm mb-3">جلسات را بهصورت خودکار بر اساس روزهای هفته بسازید، یا از بخش دستی پایین صفحه استفاده کنید.</p>
<div class="grid">
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تعداد جلسات</label>
<InputNumber v-model="autoForm.sessionCount" :min="1" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="autoForm.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت شروع</label>
<InputText v-model="autoForm.startTime" class="w-full text-sm" placeholder="19:00" dir="ltr" />
</div>
<div class="col-12 sm:col-6 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">ساعت پایان</label>
<InputText v-model="autoForm.endTime" class="w-full text-sm" placeholder="20:30" dir="ltr" />
</div>
<div class="col-12 flex flex-column gap-2 my-2">
<label class="font-semibold text-sm">روزهای هفته</label>
<SelectButton v-model="autoForm.selectedDays" :options="weekdays" optionLabel="label" optionValue="value" multiple class="text-sm" />
</div>
<div class="col-12">
<Button label="پیش‌نمایش و ساخت جلسات" icon="pi pi-cog" severity="help" @click="generateSessionsPreview" />
</div>
</div>
<div v-if="generatedSessions.length > 0" class="mt-4 pt-3 border-top-1 border-color">
<h3 class="text-md font-bold mb-3">پیشنمایش جلسات (قابل ویرایش)</h3>
<DataTable :value="generatedSessions" class="p-datatable-sm text-sm">
<Column field="sessionNumber" header="جلسه #" style="width: 70px">
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
</Column>
<Column field="date" header="تاریخ">
<template #body="{ data }">
<InputText v-model="data.date" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
<Column field="startTime" header="ساعت شروع">
<template #body="{ data }">
<InputText v-model="data.startTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="endTime" header="ساعت پایان">
<template #body="{ data }">
<InputText v-model="data.endTime" class="w-full p-inputtext-sm text-sm" dir="ltr" />
</template>
</Column>
<Column field="topic" header="موضوع جلسه">
<template #body="{ data }">
<InputText v-model="data.topic" class="w-full p-inputtext-sm text-sm" />
</template>
</Column>
</DataTable>
<div class="flex justify-content-end mt-3">
<Button label="ثبت نهایی جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitGeneratedSessions" />
</div>
</div>
</div>
<!-- Manual create: always at the bottom -->
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
<h2 class="text-lg font-bold text-color mb-1">
{{ hasSessions ? 'افزودن جلسه دستی' : 'تعریف دستی جلسات' }}
</h2>
<p class="text-muted text-sm mb-3">
{{ hasSessions ? 'در صورت نیاز می‌توانید یک یا چند جلسه اضافه کنید.' : 'جلسات را یکی‌یکی تعریف کنید.' }}
</p>
<div v-for="(session, index) in manualSessions" :key="index" class="grid align-items-center mb-2 p-2 border-round surface-ground">
<div class="col-12 sm:col-1 text-center font-bold">#{{ toPersianDigits(index + 1) }}</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.date" placeholder="تاریخ (1403/01/01)" class="w-full text-sm" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.startTime" placeholder="ساعت شروع" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-6 sm:col-2">
<InputText v-model="session.endTime" placeholder="ساعت پایان" class="w-full text-sm" dir="ltr" />
</div>
<div class="col-12 sm:col-3">
<InputText v-model="session.topic" placeholder="موضوع جلسه" class="w-full text-sm" />
</div>
<div class="col-12 sm:col-1 text-center">
<Button
icon="pi pi-times"
text
rounded
severity="danger"
size="small"
:disabled="manualSessions.length === 1"
@click="manualSessions.splice(index, 1)"
/>
</div>
</div>
<div class="flex align-items-center justify-content-between mt-3">
<Button
label="افزودن ردیف"
icon="pi pi-plus"
text
@click="manualSessions.push({ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' })"
/>
<Button label="ثبت جلسات" icon="pi pi-save" severity="success" :loading="isSaving" @click="submitManualSessions" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import moment from 'jalali-moment';
import { sessionApi } from '@/api/sessionApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import StatusTag from '@/components/common/StatusTag.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton';
import DatePicker from 'vue3-persian-datetime-picker';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
const props = defineProps({
classId: { type: [String, Object], required: true },
courseId: { type: [String, Object], required: true },
professorId: { type: [String, Object], default: null },
defaultSessionCount: { type: Number, default: 12 }
});
const { toPersianDigits, toLatinDigits, formatJalali } = usePersianDate();
const { showSuccess, showError } = useToast();
const todayJalali = () => moment().locale('fa').format('jYYYY/jMM/jDD');
const sessions = ref([]);
const selectedSessions = ref([]);
const loadingSessions = ref(false);
const isSaving = ref(false);
const isDeleting = ref(false);
const isBulkUpdating = ref(false);
const deleteDialogVisible = ref(false);
const bulkDeleteMode = ref(false);
const sessionToDelete = ref(null);
const bulkStatus = ref(null);
const generatedSessions = ref([]);
const manualSessions = ref([{ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }]);
const statusOptions = [
{ label: 'طبق برنامه', value: 'scheduled' },
{ label: 'برگزار شده', value: 'held' },
{ label: 'لغو شده', value: 'cancelled' }
];
const selectedIds = computed(() =>
selectedSessions.value.map((s) => s._id || s.id).filter(Boolean)
);
const deleteMessage = computed(() => {
if (bulkDeleteMode.value) {
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخاب‌شده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
}
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
});
const resolvedClassId = computed(() => {
const value = props.classId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedCourseId = computed(() => {
const value = props.courseId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const resolvedProfessorId = computed(() => {
const value = props.professorId;
if (!value) return null;
return typeof value === 'object' ? (value._id || value.id || null) : String(value);
});
const hasSessions = computed(() => sessions.value.length > 0);
const normalizeList = (response) => {
if (!response) return [];
if (Array.isArray(response)) return response;
if (Array.isArray(response.data)) return response.data;
if (Array.isArray(response.items)) return response.items;
if (Array.isArray(response.sessions)) return response.sessions;
if (response.data && typeof response.data === 'object') {
if (Array.isArray(response.data.data)) return response.data.data;
if (Array.isArray(response.data.items)) return response.data.items;
if (Array.isArray(response.data.sessions)) return response.data.sessions;
}
return [];
};
const weekdays = [
{ label: 'شنبه', value: 6 },
{ label: 'یکشنبه', value: 0 },
{ label: 'دوشنبه', value: 1 },
{ label: 'سه‌شنبه', value: 2 },
{ label: 'چهارشنبه', value: 3 },
{ label: 'پنجشنبه', value: 4 },
{ label: 'جمعه', value: 5 }
];
const autoForm = reactive({
sessionCount: props.defaultSessionCount || 12,
startDate: todayJalali(),
startTime: '19:00',
endTime: '20:30',
selectedDays: [6, 2]
});
const toSessionDayIso = (value) => {
if (!value) return null;
const latin = toLatinDigits(String(value));
const m = moment(latin, ['jYYYY/jMM/jDD', 'YYYY/MM/DD', moment.ISO_8601], true);
return m.isValid() ? m.toDate().toISOString() : null;
};
const fetchSessions = async () => {
const classId = resolvedClassId.value;
if (!classId) return;
loadingSessions.value = true;
try {
// Use schema field `class` only. Sending classId alongside it makes older
// API builds add `{ classId: ... }` to the Mongo filter and return 0 rows.
const res = await sessionApi.getAll({
limit: 100,
class: classId,
sortBy: 'day',
sortOrder: 'asc'
});
sessions.value = normalizeList(res);
selectedSessions.value = [];
} catch (err) {
showError(err);
sessions.value = [];
} finally {
loadingSessions.value = false;
}
};
const confirmDeleteOne = (session) => {
bulkDeleteMode.value = false;
sessionToDelete.value = session;
deleteDialogVisible.value = true;
};
const confirmBulkDelete = () => {
if (!selectedSessions.value.length) return;
bulkDeleteMode.value = true;
sessionToDelete.value = null;
deleteDialogVisible.value = true;
};
const handleDeleteConfirm = async () => {
isDeleting.value = true;
try {
if (bulkDeleteMode.value) {
const res = await sessionApi.bulkDelete(selectedIds.value);
const deletedCount = res?.data?.deletedCount ?? selectedIds.value.length;
showSuccess(`${toPersianDigits(deletedCount)} جلسه حذف شد`);
selectedSessions.value = [];
} else if (sessionToDelete.value) {
await sessionApi.delete(sessionToDelete.value._id || sessionToDelete.value.id);
showSuccess('جلسه با موفقیت حذف شد');
}
deleteDialogVisible.value = false;
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
const applyBulkStatus = async () => {
if (!selectedIds.value.length || !bulkStatus.value) return;
isBulkUpdating.value = true;
try {
const res = await sessionApi.bulkUpdateStatus(selectedIds.value, bulkStatus.value);
const updatedCount = res?.data?.updatedCount ?? selectedIds.value.length;
showSuccess(`وضعیت ${toPersianDigits(updatedCount)} جلسه به‌روز شد`);
bulkStatus.value = null;
selectedSessions.value = [];
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isBulkUpdating.value = false;
}
};
const resetCreationForms = () => {
generatedSessions.value = [];
manualSessions.value = [{ date: todayJalali(), startTime: '19:00', endTime: '20:30', topic: '' }];
};
const generateSessionsPreview = () => {
if (!autoForm.startDate) {
showError('لطفا تاریخ شروع را وارد کنید');
return;
}
if (!autoForm.selectedDays?.length) {
showError('لطفا حداقل یک روز از هفته را انتخاب کنید');
return;
}
const list = [];
const latinStartDate = toLatinDigits(autoForm.startDate);
let currentMoment = moment(latinStartDate, 'jYYYY/jMM/jDD');
let count = 0;
const targetCount = autoForm.sessionCount || 12;
while (count < targetCount) {
if (autoForm.selectedDays.includes(currentMoment.day())) {
count++;
list.push({
sessionNumber: count,
date: currentMoment.locale('fa').format('YYYY/MM/DD'),
startTime: autoForm.startTime,
endTime: autoForm.endTime,
topic: `جلسه ${count}`
});
}
currentMoment.add(1, 'day');
}
generatedSessions.value = list;
};
const ensureIds = () => {
if (!resolvedClassId.value) {
showError('ابتدا کلاس را ذخیره کنید');
return false;
}
if (!resolvedCourseId.value) {
showError('دوره مرتبط با کلاس مشخص نیست');
return false;
}
if (!resolvedProfessorId.value) {
showError('لطفا استاد کلاس را انتخاب و ذخیره کنید');
return false;
}
return true;
};
const submitGeneratedSessions = async () => {
if (!ensureIds()) return;
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: generatedSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات کلاس با موفقیت ایجاد شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
const submitManualSessions = async () => {
if (!ensureIds()) return;
const invalid = manualSessions.value.some((s) => !s.date || !s.startTime || !s.endTime);
if (invalid) {
showError('تاریخ و ساعت همه جلسات الزامی است');
return;
}
isSaving.value = true;
try {
await sessionApi.create({
courseId: resolvedCourseId.value,
classId: resolvedClassId.value,
professorId: resolvedProfessorId.value,
sessions: manualSessions.value.map((s) => ({
topic: s.topic,
startTime: s.startTime,
endTime: s.endTime,
day: toSessionDayIso(s.date)
}))
});
showSuccess('جلسات با موفقیت ثبت شدند');
resetCreationForms();
await fetchSessions();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
watch(
() => props.defaultSessionCount,
(val) => {
if (val && !hasSessions.value) autoForm.sessionCount = val;
}
);
watch(resolvedClassId, (id) => {
if (id) fetchSessions();
}, { immediate: true });
defineExpose({ fetchSessions });
</script>
@@ -0,0 +1,57 @@
<!-- /src/components/common/ConfirmDeleteDialog.vue -->
<template>
<Dialog
v-model:visible="visible"
modal
:header="title || 'تایید حذف'"
:style="{ width: '400px' }"
:closable="!loading"
>
<div class="flex align-items-center gap-3 py-2">
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
<span class="text-color text-sm">{{ message || 'آیا از حذف این مورد اطمینان دارید؟ این عملیات قابل بازگشت نیست.' }}</span>
</div>
<template #footer>
<div class="flex justify-content-end gap-2">
<Button label="انصراف" text class="p-button-secondary" :disabled="loading" @click="visible = false" />
<Button label="حذف نهایی" icon="pi pi-trash" severity="danger" :loading="loading" @click="onConfirm" />
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
message: {
type: String,
default: ''
},
loading: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'confirm']);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const onConfirm = () => {
emit('confirm');
};
</script>
+188
View File
@@ -0,0 +1,188 @@
<!-- /src/components/common/DataTableWrapper.vue -->
<template>
<div class="data-table-wrapper surface-card p-4 border-round border-1 border-color shadow-sm">
<!-- Toolbar / Search / Filter Header -->
<div class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mb-4">
<div class="p-input-icon-right w-full sm:w-20rem">
<IconField iconPosition="right">
<InputIcon class="pi pi-search" />
<InputText
v-model="searchValue"
:placeholder="$t('app.search')"
class="w-full text-sm"
@input="onSearchInput"
/>
</IconField>
</div>
<div class="flex align-items-center gap-2 w-full sm:w-auto justify-content-end">
<slot name="toolbar" />
</div>
</div>
<!-- Skeleton loading -->
<TableSkeleton v-if="loading" :rows="skeletonRowCount" :columns="skeletonColumnCount" />
<!-- Data Table -->
<DataTable
v-else
:value="items"
:lazy="true"
:sortField="sortBy"
:sortOrder="sortOrder === 'asc' ? 1 : -1"
:selection="selection"
dataKey="_id"
responsiveLayout="scroll"
class="p-datatable-sm text-sm"
@sort="onSort"
@update:selection="onSelectionChange"
>
<template #empty>
<EmptyState />
</template>
<Column v-if="selectable" selectionMode="multiple" headerStyle="width: 3rem" />
<slot />
</DataTable>
<!-- Custom Paginator -->
<div
class="flex flex-column sm:flex-row align-items-center justify-content-between gap-3 mt-4 pt-3 border-top-1 border-color"
v-if="totalCount > 0 || loading"
>
<span class="text-muted text-sm">
<template v-if="loading">در حال بارگذاری</template>
<template v-else>
نمایش {{ toPersianDigits(firstItemIndex) }} تا {{ toPersianDigits(lastItemIndex) }} از {{ toPersianDigits(totalCount) }} مورد
</template>
</span>
<Paginator
:rows="limit"
:totalRecords="Math.max(totalCount, 1)"
:first="(page - 1) * limit"
:rowsPerPageOptions="[10, 20, 50, 100]"
:disabled="loading"
@page="onPage"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Paginator from 'primevue/paginator';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import EmptyState from './EmptyState.vue';
import TableSkeleton from './TableSkeleton.vue';
const props = defineProps({
items: {
type: Array,
required: true
},
totalCount: {
type: Number,
default: 0
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
sortBy: {
type: String,
default: 'createdAt'
},
sortOrder: {
type: String,
default: 'desc'
},
loading: {
type: Boolean,
default: false
},
selectable: {
type: Boolean,
default: false
},
selection: {
type: Array,
default: () => []
},
skeletonColumns: {
type: Number,
default: 5
}
});
const emit = defineEmits(['page-change', 'sort-change', 'search-change', 'update:selection']);
const { toPersianDigits } = usePersianDate();
const searchValue = ref('');
const skeletonColumnCount = computed(() => Math.max(3, props.skeletonColumns || 5));
const skeletonRowCount = computed(() => Math.min(Math.max(props.limit || 20, 5), 12));
const onSelectionChange = (value) => {
emit('update:selection', value || []);
};
let searchTimer = null;
const onSearchInput = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
emit('search-change', searchValue.value);
}, 400);
};
const onSort = (event) => {
emit('sort-change', {
sortBy: event.sortField,
sortOrder: event.sortOrder === 1 ? 'asc' : 'desc'
});
};
const onPage = (event) => {
emit('page-change', {
page: event.page + 1,
limit: event.rows
});
};
const firstItemIndex = computed(() => {
if (props.totalCount === 0) return 0;
return (props.page - 1) * props.limit + 1;
});
const lastItemIndex = computed(() => {
return Math.min(props.page * props.limit, props.totalCount);
});
watch(
() => props.items,
() => {
if (props.selectable && props.selection?.length) {
const ids = new Set(props.items.map((item) => String(item._id || item.id)));
const next = props.selection.filter((item) => ids.has(String(item._id || item.id)));
if (next.length !== props.selection.length) {
emit('update:selection', next);
}
}
}
);
</script>
<style scoped lang="scss">
.data-table-wrapper {
background-color: var(--surface-card);
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<!-- /src/components/common/EmptyState.vue -->
<template>
<div class="empty-state flex flex-column align-items-center justify-content-center p-5 text-center my-4">
<div class="icon-wrapper mb-3 text-muted">
<i :class="[icon, 'text-6xl']"></i>
</div>
<h3 class="text-xl font-bold text-color mb-2">{{ title }}</h3>
<p class="text-muted text-sm mb-4 max-w-26rem">{{ description }}</p>
<slot />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: 'اطلاعاتی یافت نشد'
},
description: {
type: String,
default: 'موردی برای نمایش در این بخش وجود ندارد.'
},
icon: {
type: String,
default: 'pi pi-folder-open'
}
});
</script>
<style scoped lang="scss">
.empty-state {
background: var(--surface-card);
border: 1px dashed var(--border-color);
border-radius: 8px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<!-- /src/components/common/PageHeader.vue -->
<template>
<div class="page-header flex flex-column md:flex-row align-items-start md:align-items-center justify-content-between mb-4 gap-3">
<div>
<h1 class="text-2xl font-bold text-color m-0">{{ title }}</h1>
<p v-if="subtitle" class="text-muted text-sm mt-1 mb-0">{{ subtitle }}</p>
</div>
<div class="flex align-items-center gap-2">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
}
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<!-- /src/components/common/PermissionGate.vue -->
<template>
<slot v-if="hasAccess" />
</template>
<script setup>
import { computed } from 'vue';
import { usePermission } from '@/composables/usePermission';
const props = defineProps({
permission: {
type: String,
default: null
},
anyPermission: {
type: Array,
default: () => []
}
});
const { hasPermission, hasAnyPermission } = usePermission();
const hasAccess = computed(() => {
if (props.permission) {
return hasPermission(props.permission);
}
if (props.anyPermission && props.anyPermission.length > 0) {
return hasAnyPermission(props.anyPermission);
}
return true;
});
</script>
+101
View File
@@ -0,0 +1,101 @@
<!-- /src/components/common/StatusTag.vue -->
<template>
<Tag :severity="severity" :value="label" class="text-xs font-semibold px-2 py-1" />
</template>
<script setup>
import { computed } from 'vue';
import Tag from 'primevue/tag';
const props = defineProps({
status: {
type: [String, Boolean],
required: true
},
type: {
type: String,
default: 'general' // general | payment | session | notification | contact
}
});
const SESSION_LABELS = {
scheduled: 'طبق برنامه',
Scheduled: 'طبق برنامه',
held: 'برگزارشده',
cancelled: 'لغوشده',
canceled: 'لغوشده'
};
const CONTACT_LABELS = {
new: 'جدید',
seen: 'مشاهده‌شده',
call_later: 'تماس بعدی',
contacted: 'تماس گرفته شد',
closed: 'بسته'
};
const NOTIFICATION_LABELS = {
pending: 'در انتظار',
sent: 'ارسال‌شده',
delivered: 'تحویل‌شده',
failed: 'ناموفق',
retrying: 'تلاش مجدد'
};
const PAYMENT_LABELS = {
paid: 'تسویه‌شده',
partial: 'پرداخت جزئی',
pending: 'در انتظار پرداخت',
overdue: 'معوق'
};
const severity = computed(() => {
const val = String(props.status).toLowerCase();
if (props.type === 'payment') {
if (val === 'paid' || val === 'تسویه شده' || val === 'تسویه‌شده') return 'success';
if (val === 'partial' || val === 'پرداخت جزئی') return 'warning';
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
}
if (props.type === 'session') {
if (val === 'scheduled' || val.includes('برنامه')) return 'info';
if (val === 'held' || val.includes('برگزار')) return 'success';
if (val === 'cancelled' || val === 'canceled' || val.includes('لغو')) return 'danger';
}
if (props.type === 'contact') {
if (val === 'new') return 'info';
if (val === 'seen') return 'secondary';
if (val === 'call_later') return 'warning';
if (val === 'contacted') return 'success';
if (val === 'closed') return 'contrast';
}
if (props.type === 'notification') {
if (val === 'success' || val === 'delivered' || val === 'sent' || val === 'تحویل شده' || val === 'ارسال‌شده') return 'success';
if (val === 'pending' || val === 'retrying' || val === 'در حال تلاش مجدد' || val === 'در انتظار') return 'warning';
if (val === 'failed' || val === 'ناموفق') return 'danger';
}
if (val === 'true' || val === 'active' || val === 'فعال') return 'success';
if (val === 'false' || val === 'inactive' || val === 'غیرفعال') return 'danger';
return 'info';
});
const label = computed(() => {
const raw = String(props.status);
const val = raw.toLowerCase();
if (props.type === 'session') return SESSION_LABELS[val] || SESSION_LABELS[raw] || raw;
if (props.type === 'contact') return CONTACT_LABELS[val] || raw;
if (props.type === 'notification') return NOTIFICATION_LABELS[val] || raw;
if (props.type === 'payment') return PAYMENT_LABELS[val] || raw;
if (val === 'true' || val === 'active') return 'فعال';
if (val === 'false' || val === 'inactive') return 'غیرفعال';
return raw;
});
</script>
+32
View File
@@ -0,0 +1,32 @@
<!-- Reusable table skeleton placeholder -->
<template>
<div class="table-skeleton" aria-busy="true" aria-live="polite">
<div class="flex gap-2 mb-3 pb-2 border-bottom-1 border-color">
<Skeleton
v-for="col in columns"
:key="`h-${col}`"
height="0.85rem"
:width="widths[(col - 1) % widths.length]"
/>
</div>
<div v-for="row in rows" :key="`r-${row}`" class="flex align-items-center gap-2 mb-2">
<Skeleton
v-for="col in columns"
:key="`c-${row}-${col}`"
height="1.35rem"
:width="widths[(col - 1) % widths.length]"
/>
</div>
</div>
</template>
<script setup>
import Skeleton from 'primevue/skeleton';
defineProps({
rows: { type: Number, default: 6 },
columns: { type: Number, default: 5 }
});
const widths = ['18%', '22%', '16%', '20%', '14%', '12%', '10%', '15%'];
</script>
+41
View File
@@ -0,0 +1,41 @@
<!-- /src/components/layout/AppBreadcrumb.vue -->
<template>
<div v-if="items && items.length > 0" class="app-breadcrumb mb-3">
<Breadcrumb :home="home" :model="items">
<template #item="{ item }">
<router-link v-if="item.to" :to="item.to" class="text-color hover:text-primary font-medium text-sm">
{{ item.label }}
</router-link>
<span v-else class="text-muted font-normal text-sm">{{ item.label }}</span>
</template>
</Breadcrumb>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Breadcrumb from 'primevue/breadcrumb';
const route = useRoute();
const home = { icon: 'pi pi-home', to: '/' };
const items = computed(() => {
const matched = route.matched.filter(m => m.meta && m.meta.title && m.path !== '/');
return matched.map(m => ({
label: m.meta.title,
to: m.path === route.path ? null : m.path
}));
});
</script>
<style scoped lang="scss">
.app-breadcrumb {
:deep(.p-breadcrumb) {
background: transparent;
border: none;
padding: 0;
}
}
</style>
+8
View File
@@ -0,0 +1,8 @@
<!-- /src/components/layout/AppLayout.vue -->
<template>
<DashboardLayout />
</template>
<script setup>
import DashboardLayout from '@/layouts/DashboardLayout.vue';
</script>
+69
View File
@@ -0,0 +1,69 @@
<!-- /src/components/layout/AppSidebar.vue -->
<template>
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full">
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color">
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
<span class="font-bold text-xl text-color">گام نو</span>
</div>
<div class="menu-list flex-grow-1 overflow-y-auto">
<router-link
v-for="item in menuItems"
:key="item.to"
:to="item.to"
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
:class="{ 'active-item': isMenuActive(item.to) }"
>
<i :class="item.icon" class="text-lg"></i>
<span class="font-medium text-sm">{{ item.label }}</span>
</router-link>
</div>
</div>
</template>
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
const menuItems = [
{ label: 'داشبورد اصلی', icon: 'pi pi-home', to: '/' },
{ label: 'مدیریت کاربران', icon: 'pi pi-users', to: '/users' },
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
{ label: 'دوره‌های آموزشی', icon: 'pi pi-book', to: '/courses' },
{ label: 'کلاس‌ها', icon: 'pi pi-desktop', to: '/classes' },
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' },
{ label: 'امور مالی و پرداخت‌ها', icon: 'pi pi-wallet', to: '/payments' },
{ label: 'اطلاعیه‌ها', icon: 'pi pi-bell', to: '/notifications' },
{ label: 'درخواست‌های تماس', icon: 'pi pi-comments', to: '/contact-inquiries' },
{ label: 'گزارش فعالیت‌ها', icon: 'pi pi-history', to: '/logs' },
{ label: 'نقش‌ها و دسترسی‌ها', icon: 'pi pi-shield', to: '/roles' }
];
function isMenuActive(to) {
if (to === '/') {
return route.path === '/';
}
return route.path === to || route.path.startsWith(`${to}/`);
}
</script>
<style lang="scss" scoped>
.app-sidebar {
width: 240px;
min-height: 100vh;
}
.menu-item {
color: var(--text-secondary);
text-decoration: none;
&:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
&.active-item {
background-color: var(--primary-color-light);
color: var(--primary-color);
font-weight: 700;
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<!-- /src/components/layout/AppTopbar.vue -->
<template>
<header class="app-topbar flex align-items-center justify-content-between px-4 py-2 surface-card border-bottom-1 border-color">
<div class="flex align-items-center gap-3">
<Button
icon="pi pi-bars"
text
rounded
class="lg:hidden"
@click="uiStore.toggleMobileSidebar"
/>
<Button
icon="pi pi-bars"
text
rounded
class="hidden lg:flex"
@click="uiStore.toggleSidebar"
/>
<div class="flex align-items-center gap-2 font-bold text-xl text-primary">
<i class="pi pi-building text-2xl"></i>
<span>{{ $t('app.title') }}</span>
</div>
</div>
<div class="flex align-items-center gap-3">
<!-- Dark/Light Theme Toggle -->
<Button
:icon="themeStore.isDarkMode ? 'pi pi-sun' : 'pi pi-moon'"
text
rounded
:aria-label="themeStore.isDarkMode ? $t('app.lightMode') : $t('app.darkMode')"
v-tooltip.bottom="themeStore.isDarkMode ? $t('app.lightMode') : $t('app.darkMode')"
@click="themeStore.toggleTheme"
/>
<!-- Notifications Icon -->
<button
type="button"
class="notification-trigger"
:aria-label="$t('navigation.notifications')"
v-tooltip.bottom="$t('navigation.notifications')"
@click="$router.push('/notifications')"
>
<i class="pi pi-bell"></i>
<span v-if="notificationCount > 0" class="notification-badge">{{ notificationCount }}</span>
</button>
<!-- User Menu -->
<div class="flex align-items-center gap-2">
<Avatar
icon="pi pi-user"
shape="circle"
class="bg-primary text-white cursor-pointer"
@click="toggleUserMenu"
/>
<span class="font-medium hidden sm:inline text-color cursor-pointer" @click="toggleUserMenu">
{{ authStore.user?.name || authStore.user?.username || 'کاربر سیستم' }}
</span>
<Menu ref="userMenu" :model="menuItems" :popup="true" />
</div>
</div>
</header>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/authStore';
import { useThemeStore } from '@/stores/themeStore';
import { useUiStore } from '@/stores/uiStore';
import { useI18n } from 'vue-i18n';
import Button from 'primevue/button';
import Avatar from 'primevue/avatar';
import Menu from 'primevue/menu';
const authStore = useAuthStore();
const themeStore = useThemeStore();
const uiStore = useUiStore();
const router = useRouter();
const { t } = useI18n();
const userMenu = ref(null);
const notificationCount = ref(3);
const toggleUserMenu = (event) => {
userMenu.value.toggle(event);
};
const menuItems = ref([
{
label: authStore.user?.name || authStore.user?.username || 'کاربر',
items: [
{
label: t('app.profile'),
icon: 'pi pi-user',
command: () => {
const id = authStore.user?._id || authStore.user?.id;
if (id) {
router.push(`/users/edit/${id}`);
}
}
},
{
label: t('app.logout'),
icon: 'pi pi-sign-out',
command: async () => {
await authStore.logout();
router.push('/login');
}
}
]
}
]);
</script>
<style scoped lang="scss">
.app-topbar {
height: 60px;
position: sticky;
top: 0;
z-index: 999;
}
.notification-trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--primary-color);
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--primary-color-light);
}
.pi-bell {
font-size: 1.15rem;
line-height: 1;
}
}
.notification-badge {
position: absolute;
top: 0.2rem;
inset-inline-end: 0.15rem;
min-width: 1.05rem;
height: 1.05rem;
padding: 0 0.25rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1.05rem;
text-align: center;
box-shadow: 0 0 0 2px var(--surface-card, #1e293b);
pointer-events: none;
}
</style>
@@ -0,0 +1,37 @@
<!-- /src/components/uploader/CertificateUploader.vue -->
<template>
<div class="certificate-uploader">
<ImageManager
v-model="certificates"
upload-endpoint="/certificates/user/upload"
delete-endpoint="/files/admin/delete"
:max-files="1"
:allow-multiple="false"
gallery-id="certificate-gallery"
@upload-success="onCertificateUploaded"
/>
</div>
</template>
<script setup>
import { computed } from 'vue';
import ImageManager from './ImageManager.vue';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['update:modelValue', 'uploaded']);
const certificates = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const onCertificateUploaded = (fileData) => {
emit('uploaded', fileData);
};
</script>
+198
View File
@@ -0,0 +1,198 @@
<!-- /src/components/uploader/ImageManager.vue -->
<template>
<div class="image-manager">
<!-- FilePond Upload Input -->
<file-pond
ref="pond"
name="file"
:label-idle="labelIdle"
:allow-multiple="allowMultiple"
:max-files="maxFiles"
accepted-file-types="image/jpeg, image/png, image/webp, application/pdf"
:server="serverOptions"
@processfile="handleProcessFile"
@removefile="handleRemoveFile"
/>
<!-- Gallery Lightbox Preview Grid -->
<div v-if="imagesList && imagesList.length > 0" class="gallery-grid grid mt-3" :id="galleryId">
<div
v-for="(img, index) in imagesList"
:key="img.url || index"
class="col-12 sm:col-6 md:col-4 lg:col-3"
>
<div class="gallery-card surface-card border-1 border-color border-round overflow-hidden relative group">
<a
:href="img.url"
:data-pswp-width="img.width || 1200"
:data-pswp-height="img.height || 800"
target="_blank"
class="block overflow-hidden"
>
<img :src="img.url" :alt="img.name || 'تصویر'" class="w-full h-10rem object-cover block transition-transform transition-duration-200 hover:scale-105" />
</a>
<div class="p-2 flex align-items-center justify-content-between border-top-1 border-color">
<span class="text-xs text-muted truncate max-w-8rem">{{ img.name || `فایل ${index + 1}` }}</span>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
@click="removeGalleryImage(index)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import vueFilePond from 'vue-filepond';
import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import 'photoswipe/style.css';
import { useAuthStore } from '@/stores/authStore';
import Button from 'primevue/button';
const FilePond = vueFilePond(FilePondPluginFileValidateType, FilePondPluginImagePreview);
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
uploadEndpoint: {
type: String,
default: '/files/admin/upload-temp'
},
deleteEndpoint: {
type: String,
default: '/files/admin/delete'
},
maxFiles: {
type: Number,
default: 5
},
allowMultiple: {
type: Boolean,
default: true
},
galleryId: {
type: String,
default: 'pswp-gallery'
}
});
const emit = defineEmits(['update:modelValue', 'upload-success']);
const authStore = useAuthStore();
const pond = ref(null);
let lightbox = null;
const labelIdle = 'کشیدن و رها کردن فایل یا <span class="filepond--label-action">مرور سیستم</span>';
const imagesList = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api';
const serverOptions = computed(() => ({
url: API_BASE_URL,
process: {
url: props.uploadEndpoint,
headers: {
Authorization: `Bearer ${authStore.accessToken}`
},
onload: (response) => {
try {
const res = typeof response === 'string' ? JSON.parse(response) : response;
const fileData = res.data || res;
return fileData.tempFileName || fileData.filename || fileData.url || JSON.stringify(fileData);
} catch (e) {
return response;
}
}
},
revert: {
url: props.deleteEndpoint,
headers: {
Authorization: `Bearer ${authStore.accessToken}`
}
}
}));
const handleProcessFile = (error, file) => {
if (!error) {
let serverRes = file.serverId;
let url = '';
let tempFileName = '';
try {
const parsed = JSON.parse(serverRes);
url = parsed.url || parsed.publicUrl || '';
tempFileName = parsed.tempFileName || parsed.filename || '';
} catch (e) {
tempFileName = serverRes;
url = `${API_BASE_URL}/files/admin/signed-url/${serverRes}`;
}
const newItem = {
name: file.filename,
tempFileName: tempFileName || file.filename,
url: url || `${API_BASE_URL}/files/admin/signed-url/${tempFileName}`,
width: 1200,
height: 800
};
const updated = [...imagesList.value, newItem];
emit('update:modelValue', updated);
emit('upload-success', newItem);
}
};
const handleRemoveFile = (error, file) => {
// handled via gallery card actions or filepond
};
const removeGalleryImage = (index) => {
const updated = [...imagesList.value];
updated.splice(index, 1);
emit('update:modelValue', updated);
};
onMounted(() => {
lightbox = new PhotoSwipeLightbox({
gallery: `#${props.galleryId}`,
children: 'a',
pswpModule: () => import('photoswipe')
});
lightbox.init();
});
onUnmounted(() => {
if (lightbox) {
lightbox.destroy();
lightbox = null;
}
});
</script>
<style lang="scss">
.image-manager {
.filepond--panel-root {
background-color: var(--surface-card);
border: 1px dashed var(--border-color);
}
.filepond--drop-label {
color: var(--text-secondary);
}
}
</style>