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,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
<!-- /src/components/layout/AppLayout.vue -->
|
||||
<template>
|
||||
<DashboardLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue';
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user