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,158 @@
|
||||
<!-- /src/views/attendances/AttendanceEntryView.vue -->
|
||||
<template>
|
||||
<div class="attendance-entry-view" v-if="session">
|
||||
<PageHeader
|
||||
:title="$t('attendances.title')"
|
||||
:subtitle="`دوره: ${session.course?.title || '-'} | کلاس: ${session.class?.name || '-'} | تاریخ: ${formatJalali(session.day)} (${session.startTime || ''} تا ${session.endTime || ''})`"
|
||||
>
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/sessions')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-3" v-if="session.note">
|
||||
<span class="text-muted text-xs block mb-1">یادداشت جلسه</span>
|
||||
<p class="m-0 white-space-pre-wrap">{{ session.note }}</p>
|
||||
</div>
|
||||
|
||||
<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-4 pb-3 border-bottom-1 border-color">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-color m-0">لیست دانشجویان کلاس</h2>
|
||||
<span class="text-muted text-xs">وضعیت حضور هر دانشجو را مشخص کنید</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Button label="علامتگذاری همه به عنوان حاضر" icon="pi pi-check-circle" size="small" text severity="success" @click="markAllPresent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="studentList" class="p-datatable-sm text-sm" emptyMessage="دانشجویی در این کلاس ثبتنام نشده است">
|
||||
<Column header="#" style="width: 50px">
|
||||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||||
</Column>
|
||||
|
||||
<Column field="name" header="نام و نام خانوادگی دانشجو">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
|
||||
<span class="text-xs text-muted block">کد ملی: {{ toPersianDigits(data.nationalIdCode || '—') }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="وضعیت حضور">
|
||||
<template #body="{ data }">
|
||||
<SelectButton
|
||||
v-model="data.status"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="text-xs"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="note" header="یادداشت / توضیحات">
|
||||
<template #body="{ data }">
|
||||
<InputText v-model="data.note" class="w-full text-xs" placeholder="دلیل غیبت یا تاخیر..." />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<div class="flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
|
||||
<Button
|
||||
:label="$t('attendances.submitAttendance')"
|
||||
icon="pi pi-save"
|
||||
severity="success"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!studentList.length"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import { attendanceApi } from '@/api/attendanceApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import InputText from 'primevue/inputtext';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const sessionId = route.params.id;
|
||||
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const session = ref(null);
|
||||
const studentList = ref([]);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'حاضر', value: 'present' },
|
||||
{ label: 'غایب', value: 'absent' },
|
||||
{ label: 'تاخیر', value: 'late' },
|
||||
{ label: 'موجه', value: 'excused' }
|
||||
];
|
||||
|
||||
const fetchSessionAttendance = async () => {
|
||||
try {
|
||||
const res = await sessionApi.getOne(sessionId);
|
||||
const data = res.data || res;
|
||||
session.value = data;
|
||||
|
||||
const roster = data.class?.students || [];
|
||||
const attendanceByUser = new Map(
|
||||
(data.attendanceList || []).map((row) => [
|
||||
String(row.user?._id || row.user),
|
||||
row
|
||||
])
|
||||
);
|
||||
|
||||
studentList.value = roster.map((user) => {
|
||||
const existing = attendanceByUser.get(String(user._id));
|
||||
return {
|
||||
userId: user._id,
|
||||
name: user.name || '',
|
||||
surname: user.surname || '',
|
||||
nationalIdCode: user.nationalIdCode || '',
|
||||
status: existing?.status || 'present',
|
||||
note: existing?.note || ''
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllPresent = () => {
|
||||
studentList.value.forEach((s) => { s.status = 'present'; });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payloadList = studentList.value.map((s) => ({
|
||||
user: s.userId,
|
||||
status: s.status,
|
||||
note: s.note
|
||||
}));
|
||||
await attendanceApi.recordSessionAttendance(sessionId, payloadList);
|
||||
showSuccess('حضور و غیاب جلسه با موفقیت ثبت شد');
|
||||
router.push('/sessions');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchSessionAttendance);
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<!-- /src/views/auth/LoginView.vue -->
|
||||
<template>
|
||||
<div class="login-card-container">
|
||||
<div class="login-view surface-card p-4 sm:p-5 border-round-2xl border-1 border-color shadow-lg">
|
||||
<div class="text-center mb-4">
|
||||
<div class="inline-flex align-items-center justify-content-center w-4rem h-4rem border-circle bg-primary-light text-primary mb-3">
|
||||
<i class="pi pi-building text-3xl"></i>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-color m-0">{{ $t('auth.loginTitle') }}</h1>
|
||||
<p class="text-muted text-sm mt-2 mb-0">{{ $t('auth.welcome') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Credentials Hint Box -->
|
||||
<div class="surface-ground p-3 border-round border-1 border-color mb-4 text-xs">
|
||||
<div class="font-bold text-primary mb-1 flex align-items-center gap-1">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>اطلاعات ورود مدیر سیستم:</span>
|
||||
</div>
|
||||
<div class="flex justify-content-between align-items-center mt-1">
|
||||
<span>نام کاربری: <code class="font-bold text-color">superadmin</code></span>
|
||||
<span>رمز عبور: <code class="font-bold text-color">SuperAdminSecret123!</code></span>
|
||||
</div>
|
||||
<Button label="جاگذاری اطلاعات حساب مدیر" size="small" text class="w-full mt-2 text-xs p-1" @click="fillDemo" />
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="flex flex-column gap-3">
|
||||
<div class="flex flex-column gap-2">
|
||||
<label for="username" class="font-semibold text-sm text-color">{{ $t('auth.username') }}</label>
|
||||
<IconField iconPosition="right">
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText
|
||||
id="username"
|
||||
v-model.trim="form.username"
|
||||
class="w-full text-sm"
|
||||
:class="{ 'p-invalid': errors.username }"
|
||||
placeholder="superadmin"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</IconField>
|
||||
<small v-if="errors.username" class="text-red-500 text-xs">{{ errors.username }}</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2">
|
||||
<label for="password" class="font-semibold text-sm text-color">{{ $t('auth.password') }}</label>
|
||||
<IconField iconPosition="right">
|
||||
<InputIcon class="pi pi-lock" />
|
||||
<Password
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
class="w-full text-sm"
|
||||
inputClass="w-full text-sm"
|
||||
:class="{ 'p-invalid': errors.password }"
|
||||
placeholder="SuperAdminSecret123!"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</IconField>
|
||||
<small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('auth.loginButton')"
|
||||
icon="pi pi-sign-in"
|
||||
class="w-full mt-2 font-bold"
|
||||
:loading="loading"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Password from 'primevue/password';
|
||||
import Button from 'primevue/button';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const form = reactive({
|
||||
username: 'superadmin',
|
||||
password: 'SuperAdminSecret123!'
|
||||
});
|
||||
|
||||
const errors = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const fillDemo = () => {
|
||||
form.username = 'superadmin';
|
||||
form.password = 'SuperAdminSecret123!';
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
let valid = true;
|
||||
errors.username = '';
|
||||
errors.password = '';
|
||||
|
||||
if (!form.username) {
|
||||
errors.username = 'ورود نام کاربری الزامی است';
|
||||
valid = false;
|
||||
}
|
||||
if (!form.password) {
|
||||
errors.password = 'ورود رمز عبور الزامی است';
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
await authStore.login(form);
|
||||
showSuccess(t('auth.loginSuccess'));
|
||||
const redirectPath = route.query.redirect || '/';
|
||||
router.push(redirectPath);
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
showError(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-card-container {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 28rem !important;
|
||||
margin: auto !important;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
width: 100% !important;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<!-- /src/views/classes/ClassDetailView.vue -->
|
||||
<template>
|
||||
<div class="class-detail-view" v-if="classData">
|
||||
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
|
||||
<Button label="ویرایش" icon="pi pi-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h2 class="text-lg font-bold text-color mb-3">مشخصات کلاس</h2>
|
||||
<div class="grid">
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">استاد مدرس</span>
|
||||
<span class="font-bold text-color text-sm">
|
||||
{{ classData.professor ? `${classData.professor.name || ''} ${classData.professor.surname || ''}`.trim() : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">ظرفیت کل</span>
|
||||
<span class="font-bold text-color text-sm">{{ toPersianDigits(classData.capacity || 0) }} نفر</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">تعداد ثبتنامیها</span>
|
||||
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">شهریه</span>
|
||||
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.tuitionFee || 0).toLocaleString('en-US')) }} تومان</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">بازه زمانی</span>
|
||||
<span class="font-bold text-color text-sm">
|
||||
{{ formatJalali(classData.startDate) }} — {{ formatJalali(classData.endDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-4">
|
||||
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
||||
<StatusTag :status="classData.isActive !== false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h2 class="text-lg font-bold mb-3">دانشجویان</h2>
|
||||
<DataTable :value="classData.students || []" class="p-datatable-sm text-sm" emptyMessage="دانشجویی ثبت نشده">
|
||||
<Column header="#">
|
||||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||||
</Column>
|
||||
<Column header="نام">
|
||||
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
|
||||
</Column>
|
||||
<Column header="موبایل">
|
||||
<template #body="{ data }"><span dir="ltr">{{ data.phoneNumber || '—' }}</span></template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h2 class="text-lg font-bold m-0">جلسات کلاس</h2>
|
||||
<Button
|
||||
label="مدیریت جلسات"
|
||||
icon="pi pi-calendar"
|
||||
text
|
||||
size="small"
|
||||
@click="$router.push(`/classes/edit/${classId}`)"
|
||||
/>
|
||||
</div>
|
||||
<TableSkeleton v-if="loadingSessions" :rows="5" :columns="5" />
|
||||
<DataTable
|
||||
v-else
|
||||
:value="sessions"
|
||||
class="p-datatable-sm text-sm"
|
||||
emptyMessage="جلسهای برای این کلاس ثبت نشده است"
|
||||
>
|
||||
<Column header="#">
|
||||
<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="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status || 'scheduled'" type="session" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
const route = useRoute();
|
||||
const classId = route.params.id;
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showError } = useToast();
|
||||
|
||||
const classData = ref(null);
|
||||
const sessions = ref([]);
|
||||
const loadingSessions = ref(false);
|
||||
|
||||
const fetchClass = async () => {
|
||||
try {
|
||||
const res = await classApi.getOne(classId);
|
||||
classData.value = res.data || res;
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
loadingSessions.value = true;
|
||||
try {
|
||||
const res = await sessionApi.getAll({
|
||||
limit: 100,
|
||||
class: classId,
|
||||
sortBy: 'day',
|
||||
sortOrder: 'asc'
|
||||
});
|
||||
if (Array.isArray(res)) {
|
||||
sessions.value = res;
|
||||
} else if (Array.isArray(res?.data)) {
|
||||
sessions.value = res.data;
|
||||
} else if (Array.isArray(res?.items)) {
|
||||
sessions.value = res.items;
|
||||
} else {
|
||||
sessions.value = [];
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
sessions.value = [];
|
||||
} finally {
|
||||
loadingSessions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchClass();
|
||||
fetchSessions();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,316 @@
|
||||
<!-- /src/views/classes/ClassFormView.vue -->
|
||||
<template>
|
||||
<div class="class-form-view w-full max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
|
||||
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
|
||||
>
|
||||
<Button
|
||||
label="انصراف"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="goBack"
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<form @submit.prevent="handleSubmit" class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">نام کلاس *</label>
|
||||
<InputText v-model.trim="form.name" class="w-full text-sm" placeholder="مثلا: گروه الف - تابستان" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">دوره آموزشی مرتبط *</label>
|
||||
<Dropdown v-model="form.course" :options="courses" optionLabel="title" optionValue="_id" placeholder="دوره را انتخاب کنید" class="w-full text-sm" filter />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">استاد مدرس</label>
|
||||
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">ظرفیت کلاس</label>
|
||||
<InputNumber v-model="form.capacity" :min="1" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">شهریه کلاس</label>
|
||||
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" suffix=" تومان" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">تاریخ شروع</label>
|
||||
<DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">تاریخ پایان</label>
|
||||
<DatePicker v-model="form.endDate" class="w-full text-sm" placeholder="1403/03/01" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4 flex align-items-center gap-2 mt-4">
|
||||
<InputSwitch v-model="form.isActive" />
|
||||
<label class="font-semibold text-sm">کلاس فعال است</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||
<Button label="انصراف" text severity="secondary" @click="goBack" />
|
||||
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template v-if="isEditMode">
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold m-0">دانشجویان کلاس</h2>
|
||||
<p class="text-muted text-sm m-0 mt-1">دانشجویان ثبتنامشده در این کلاس</p>
|
||||
</div>
|
||||
<Tag :value="`${toPersianDigits(students.length)} نفر`" severity="info" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column md:flex-row gap-2 mb-3">
|
||||
<MultiSelect
|
||||
v-model="selectedUserIds"
|
||||
:options="availableUsers"
|
||||
optionLabel="label"
|
||||
optionValue="_id"
|
||||
placeholder="افزودن دانشجو…"
|
||||
filter
|
||||
display="chip"
|
||||
class="flex-grow-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
label="ثبتنام در کلاس"
|
||||
icon="pi pi-user-plus"
|
||||
:loading="registering"
|
||||
:disabled="!selectedUserIds.length"
|
||||
@click="registerSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable :value="students" class="p-datatable-sm text-sm" emptyMessage="هنوز دانشجویی ثبتنام نشده است">
|
||||
<Column header="#" style="width: 50px">
|
||||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||||
</Column>
|
||||
<Column header="نام">
|
||||
<template #body="{ data }">{{ data.name }} {{ data.surname }}</template>
|
||||
</Column>
|
||||
<Column header="موبایل">
|
||||
<template #body="{ data }">
|
||||
<span dir="ltr">{{ data.phoneNumber || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<ClassSessionsSection
|
||||
v-if="classId"
|
||||
:class-id="String(classId)"
|
||||
:course-id="form.course ? String(form.course) : ''"
|
||||
:professor-id="form.professor ? String(form.professor) : null"
|
||||
:default-session-count="defaultSessionCount"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import moment from 'jalali-moment';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { professorApi } from '@/api/professorApi';
|
||||
import { userApi } from '@/api/userApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dropdown from 'primevue/select';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import InputSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import DatePicker from 'vue3-persian-datetime-picker';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { toPersianDigits, toLatinDigits } = usePersianDate();
|
||||
|
||||
const classId = route.params.id;
|
||||
const queryCourseId = route.query.courseId ? String(route.query.courseId) : null;
|
||||
const isEditMode = computed(() => !!classId);
|
||||
const isSubmitting = ref(false);
|
||||
const registering = ref(false);
|
||||
const defaultSessionCount = ref(12);
|
||||
|
||||
const courses = ref([]);
|
||||
const professors = ref([]);
|
||||
const students = ref([]);
|
||||
const allUsers = ref([]);
|
||||
const selectedUserIds = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
course: queryCourseId,
|
||||
professor: null,
|
||||
capacity: 20,
|
||||
tuitionFee: 0,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const availableUsers = computed(() => {
|
||||
const enrolled = new Set(students.value.map((s) => String(s._id)));
|
||||
return allUsers.value
|
||||
.filter((u) => !enrolled.has(String(u._id)))
|
||||
.map((u) => ({
|
||||
...u,
|
||||
label: `${u.name || ''} ${u.surname || ''} — ${u.phoneNumber || ''}`.trim()
|
||||
}));
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
if (form.course) {
|
||||
router.push(`/courses/edit/${form.course}`);
|
||||
return;
|
||||
}
|
||||
router.push('/classes');
|
||||
};
|
||||
|
||||
const toGregorianIso = (jalaliValue) => {
|
||||
if (!jalaliValue) return undefined;
|
||||
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
|
||||
const latin = toLatinDigits(String(jalaliValue));
|
||||
const m = moment(latin, 'jYYYY/jMM/jDD');
|
||||
return m.isValid() ? m.toDate().toISOString() : undefined;
|
||||
};
|
||||
|
||||
const toJalaliDisplay = (value) => {
|
||||
if (!value) return '';
|
||||
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
|
||||
};
|
||||
|
||||
const applyCourseDefaults = (course) => {
|
||||
if (!course) return;
|
||||
if (!form.professor && (course.professor?._id || course.professor)) {
|
||||
form.professor = course.professor?._id || course.professor;
|
||||
}
|
||||
if (course.capacity) form.capacity = course.capacity;
|
||||
if (course.price != null) form.tuitionFee = course.price;
|
||||
if (course.sectionCount) defaultSessionCount.value = course.sectionCount;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cRes, pRes, uRes] = await Promise.all([
|
||||
courseApi.getAll({ limit: 100 }),
|
||||
professorApi.getAll({ limit: 100 }),
|
||||
userApi.getAll({ limit: 200 })
|
||||
]);
|
||||
|
||||
const cData = cRes.data || cRes;
|
||||
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
|
||||
|
||||
const pData = pRes.data || pRes;
|
||||
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
|
||||
...p,
|
||||
name: `${p.name || ''} ${p.surname || ''}`.trim()
|
||||
}));
|
||||
|
||||
const uData = uRes.data || uRes;
|
||||
allUsers.value = Array.isArray(uData) ? uData : (uData.items || uData.users || uData.data || []);
|
||||
|
||||
if (classId) {
|
||||
const res = await classApi.getOne(classId);
|
||||
const data = res.data || res;
|
||||
Object.assign(form, {
|
||||
name: data.name || '',
|
||||
course: data.course?._id || data.course || null,
|
||||
professor: data.professor?._id || data.professor || null,
|
||||
capacity: data.capacity || 20,
|
||||
tuitionFee: data.tuitionFee || 0,
|
||||
startDate: toJalaliDisplay(data.startDate),
|
||||
endDate: toJalaliDisplay(data.endDate),
|
||||
isActive: data.isActive !== false
|
||||
});
|
||||
students.value = data.students || [];
|
||||
if (data.course?.sectionCount) defaultSessionCount.value = data.course.sectionCount;
|
||||
} else if (queryCourseId) {
|
||||
const linked = courses.value.find((c) => String(c._id) === String(queryCourseId));
|
||||
if (linked) {
|
||||
applyCourseDefaults(linked);
|
||||
} else {
|
||||
try {
|
||||
const one = await courseApi.getOne(queryCourseId);
|
||||
applyCourseDefaults(one.data || one);
|
||||
} catch (e) {
|
||||
console.warn('Course prefetch error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) { showError('نام کلاس الزامی است'); return; }
|
||||
if (!form.course) { showError('دوره مرتبط الزامی است'); return; }
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
course: form.course,
|
||||
professor: form.professor || undefined,
|
||||
capacity: form.capacity,
|
||||
tuitionFee: form.tuitionFee,
|
||||
startDate: toGregorianIso(form.startDate),
|
||||
endDate: toGregorianIso(form.endDate),
|
||||
isActive: form.isActive
|
||||
};
|
||||
if (isEditMode.value) {
|
||||
await classApi.update(classId, payload);
|
||||
showSuccess('کلاس با موفقیت ویرایش شد');
|
||||
await fetchData();
|
||||
} else {
|
||||
const res = await classApi.create(payload);
|
||||
const created = res.data || res;
|
||||
showSuccess('کلاس جدید با موفقیت ایجاد شد');
|
||||
router.push(`/classes/edit/${created._id || created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const registerSelected = async () => {
|
||||
if (!selectedUserIds.value.length) return;
|
||||
registering.value = true;
|
||||
try {
|
||||
const res = await classApi.registerUsers(classId, selectedUserIds.value);
|
||||
const data = res.data || res;
|
||||
students.value = data.students || [];
|
||||
selectedUserIds.value = [];
|
||||
showSuccess('دانشجویان با موفقیت به کلاس اضافه شدند');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
registering.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!-- /src/views/classes/ClassListView.vue -->
|
||||
<template>
|
||||
<div class="class-list-view">
|
||||
<PageHeader title="مدیریت کلاسها" subtitle="لیست گروهها و کلاسهای در حال برگزاری">
|
||||
<PermissionGate permission="classes:create">
|
||||
<Button label="تعریف کلاس جدید" icon="pi pi-plus" @click="$router.push('/classes/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="name" header="نام کلاس" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.name }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="course.title" header="دوره مرتبط">
|
||||
<template #body="{ data }">
|
||||
{{ data.course?.title || data.courseTitle || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="capacity" header="ظرفیت">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.capacity || 0) }} نفر
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="info"
|
||||
@click="$router.push(`/classes/view/${data._id || data.id}`)"
|
||||
/>
|
||||
<PermissionGate permission="classes:update">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="warning"
|
||||
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
|
||||
/>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(classApi.getAll);
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<!-- /src/views/contactInquiries/ContactInquiryDetailView.vue -->
|
||||
<template>
|
||||
<div class="contact-inquiry-detail" v-if="inquiry">
|
||||
<PageHeader
|
||||
:title="`${inquiry.name} ${inquiry.surname}`"
|
||||
subtitle="جزئیات و پیگیری درخواست تماس"
|
||||
>
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/contact-inquiries')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-12 lg:col-7">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-3">
|
||||
<h2 class="text-lg font-bold mb-3">اطلاعات تماس</h2>
|
||||
<div class="grid">
|
||||
<div class="col-12 sm:col-6">
|
||||
<span class="text-muted text-xs block">کد ملی</span>
|
||||
<span class="font-semibold" dir="ltr">{{ inquiry.nationalIdCode ? toPersianDigits(inquiry.nationalIdCode) : '—' }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6">
|
||||
<span class="text-muted text-xs block">موبایل</span>
|
||||
<span class="font-semibold" dir="ltr">{{ inquiry.phoneNumber || '—' }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6">
|
||||
<span class="text-muted text-xs block">ایمیل</span>
|
||||
<span class="font-semibold" dir="ltr">{{ inquiry.email || '—' }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6">
|
||||
<span class="text-muted text-xs block">زمان ثبت</span>
|
||||
<span class="font-semibold">{{ formatJalali(inquiry.createdAt, 'jYYYY/jMM/jDD HH:mm') }}</span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<span class="text-muted text-xs block mb-1">روشهای ترجیحی تماس</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="method in (inquiry.preferredContactMethods || [])"
|
||||
:key="method"
|
||||
:value="methodLabel(method)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<span class="text-muted text-xs block mb-1">دورههای مورد علاقه</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="course in (inquiry.courses || [])"
|
||||
:key="course._id || course"
|
||||
:value="course.title || course"
|
||||
severity="info"
|
||||
/>
|
||||
<span v-if="!(inquiry.courses || []).length" class="text-muted">انتخاب نشده</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<span class="text-muted text-xs block mb-1">پیام کاربر</span>
|
||||
<p class="m-0 white-space-pre-wrap">{{ inquiry.message || '—' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 lg:col-5">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
|
||||
<h2 class="text-lg font-bold mb-3">پیگیری</h2>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">وضعیت</label>
|
||||
<Dropdown
|
||||
v-model="form.status"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">یادداشت داخلی</label>
|
||||
<Textarea
|
||||
v-model="form.notes"
|
||||
rows="8"
|
||||
class="w-full text-sm"
|
||||
placeholder="نتیجه تماس، زمان مناسب تماس بعدی و …"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label="ذخیره پیگیری"
|
||||
icon="pi pi-save"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { contactInquiryApi } from '@/api/contactInquiryApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Dropdown from 'primevue/select';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const route = useRoute();
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const inquiry = ref(null);
|
||||
const saving = ref(false);
|
||||
const form = reactive({ status: 'new', notes: '' });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'جدید', value: 'new' },
|
||||
{ label: 'مشاهدهشده', value: 'seen' },
|
||||
{ label: 'تماس بعدی', value: 'call_later' },
|
||||
{ label: 'تماس گرفته شد', value: 'contacted' },
|
||||
{ label: 'بسته', value: 'closed' }
|
||||
];
|
||||
|
||||
const METHOD_LABELS = {
|
||||
WhatsApp: 'واتساپ',
|
||||
Telegram: 'تلگرام',
|
||||
Soroush: 'سروش',
|
||||
Bale: 'بله',
|
||||
Eitaa: 'ایتا',
|
||||
SMS: 'پیامک',
|
||||
Call: 'تماس تلفنی'
|
||||
};
|
||||
|
||||
const methodLabel = (value) => METHOD_LABELS[value] || value;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await contactInquiryApi.getOne(route.params.id);
|
||||
const data = res.data || res;
|
||||
inquiry.value = data;
|
||||
form.status = data.status || 'new';
|
||||
form.notes = data.notes || '';
|
||||
|
||||
if (data.status === 'new') {
|
||||
await contactInquiryApi.update(route.params.id, { status: 'seen' });
|
||||
form.status = 'seen';
|
||||
inquiry.value.status = 'seen';
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
const res = await contactInquiryApi.update(route.params.id, {
|
||||
status: form.status,
|
||||
notes: form.notes
|
||||
});
|
||||
inquiry.value = res.data || res;
|
||||
showSuccess('پیگیری ذخیره شد');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<!-- /src/views/contactInquiries/ContactInquiryListView.vue -->
|
||||
<template>
|
||||
<div class="contact-inquiry-list-view">
|
||||
<PageHeader title="درخواستهای تماس" subtitle="پیگیری علاقهمندان و درخواستهای مشاوره از وبسایت">
|
||||
<div class="flex flex-wrap gap-2 align-items-center">
|
||||
<Dropdown
|
||||
v-model="selectedStatus"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="وضعیت"
|
||||
showClear
|
||||
class="w-12rem text-sm"
|
||||
@change="onStatusFilter"
|
||||
/>
|
||||
<Dropdown
|
||||
v-model="selectedCourse"
|
||||
:options="courses"
|
||||
optionLabel="title"
|
||||
optionValue="_id"
|
||||
placeholder="فیلتر دوره"
|
||||
showClear
|
||||
filter
|
||||
class="w-16rem text-sm"
|
||||
@change="onCourseFilter"
|
||||
/>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="name" header="نام">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
|
||||
<span v-if="data.nationalIdCode" class="text-xs text-muted block" dir="ltr">
|
||||
کد ملی: {{ toPersianDigits(data.nationalIdCode) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="phoneNumber" header="تماس">
|
||||
<template #body="{ data }">
|
||||
<span class="block" dir="ltr">{{ data.phoneNumber || '—' }}</span>
|
||||
<span v-if="data.email" class="text-xs text-muted block" dir="ltr">{{ data.email }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="courses" header="دورههای انتخابی">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="course in (data.courses || [])"
|
||||
:key="course._id || course"
|
||||
:value="course.title || course"
|
||||
severity="info"
|
||||
class="text-xs"
|
||||
/>
|
||||
<span v-if="!(data.courses || []).length" class="text-muted text-xs">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="preferredContactMethods" header="روش تماس">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm">{{ (data.preferredContactMethods || []).map(methodLabel).join('، ') || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="وضعیت" sortable>
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status" type="contact" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="createdAt" header="زمان ثبت" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.createdAt, 'jYYYY/jMM/jDD HH:mm') }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 90px">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'مشاهده و پیگیری'"
|
||||
@click="$router.push(`/contact-inquiries/view/${data._id}`)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { contactInquiryApi } from '@/api/contactInquiryApi';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Dropdown from 'primevue/select';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch,
|
||||
setFilter
|
||||
} = useDataTable(contactInquiryApi.getAll, { sortBy: 'createdAt', sortOrder: 'desc' });
|
||||
|
||||
const courses = ref([]);
|
||||
const selectedStatus = ref(null);
|
||||
const selectedCourse = ref(null);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'جدید', value: 'new' },
|
||||
{ label: 'مشاهدهشده', value: 'seen' },
|
||||
{ label: 'تماس بعدی', value: 'call_later' },
|
||||
{ label: 'تماس گرفته شد', value: 'contacted' },
|
||||
{ label: 'بسته', value: 'closed' }
|
||||
];
|
||||
|
||||
const METHOD_LABELS = {
|
||||
WhatsApp: 'واتساپ',
|
||||
Telegram: 'تلگرام',
|
||||
Soroush: 'سروش',
|
||||
Bale: 'بله',
|
||||
Eitaa: 'ایتا',
|
||||
SMS: 'پیامک',
|
||||
Call: 'تماس تلفنی'
|
||||
};
|
||||
|
||||
const methodLabel = (value) => METHOD_LABELS[value] || value;
|
||||
|
||||
const onStatusFilter = () => setFilter('status', selectedStatus.value || '');
|
||||
const onCourseFilter = () => setFilter('course', selectedCourse.value || '');
|
||||
|
||||
onMounted(async () => {
|
||||
loadData();
|
||||
try {
|
||||
const res = await courseApi.getAll({ limit: 100 });
|
||||
const data = res.data || res;
|
||||
courses.value = Array.isArray(data) ? data : (data.items || data.courses || data.data || []);
|
||||
} catch {
|
||||
courses.value = [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- /src/views/courses/CourseDetailView.vue -->
|
||||
<template>
|
||||
<div class="course-detail-view" v-if="course">
|
||||
<PageHeader :title="course.title" :subtitle="`استاد: ${course.professor?.name || course.professor || '-'}`">
|
||||
<PermissionGate permission="courses:update">
|
||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
|
||||
</PermissionGate>
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/courses')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid mb-4">
|
||||
<div class="col-12 md:col-8">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h3 class="text-lg font-bold mb-3">توضیحات دوره</h3>
|
||||
<p class="text-color text-sm line-height-3">{{ course.description || 'توضیحاتی برای این دوره ثبت نشده است.' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm">
|
||||
<h3 class="text-lg font-bold mb-3">جلسات دوره</h3>
|
||||
<DataTable :value="courseSessions" class="p-datatable-sm text-sm">
|
||||
<Column field="sessionNumber" header="#" style="width: 60px">
|
||||
<template #body="{ data }">{{ toPersianDigits(data.sessionNumber) }}</template>
|
||||
</Column>
|
||||
<Column field="topic" header="موضوع جلسه" />
|
||||
<Column field="date" header="تاریخ برگزاری">
|
||||
<template #body="{ data }">{{ formatJalali(data.date) }}</template>
|
||||
</Column>
|
||||
<Column field="time" header="زمان">
|
||||
<template #body="{ data }">{{ data.startTime }} - {{ data.endTime }}</template>
|
||||
</Column>
|
||||
<Column field="status" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status || 'Scheduled'" type="session" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-4">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h3 class="text-lg font-bold mb-3">اطلاعات تکمیلی</h3>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">نوع دوره</span>
|
||||
<Tag :value="course.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="course.type === 'Private' ? 'warning' : 'info'" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">شهریه</span>
|
||||
<span class="font-bold text-color">{{ toPersianDigits(course.price?.toLocaleString()) }} تومان</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">ظرفیت</span>
|
||||
<span class="font-bold text-color">{{ toPersianDigits(course.capacity) }} نفر</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">مدرک رسمی</span>
|
||||
<Tag :value="course.isOfficial ? 'دارای مدرک رسمی' : 'بدون مدرک رسمی'" :severity="course.isOfficial ? 'success' : 'secondary'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
const route = useRoute();
|
||||
const courseId = route.params.id;
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
|
||||
const course = ref(null);
|
||||
const courseSessions = ref([]);
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await courseApi.getOne(courseId);
|
||||
course.value = res.data || res;
|
||||
|
||||
const sessionRes = await sessionApi.getAll({
|
||||
courseId,
|
||||
limit: 100,
|
||||
sortBy: 'day',
|
||||
sortOrder: 'asc'
|
||||
});
|
||||
const sessionData = sessionRes.data || sessionRes;
|
||||
const list = Array.isArray(sessionData)
|
||||
? sessionData
|
||||
: (sessionData.items || sessionData.sessions || sessionData.data || []);
|
||||
courseSessions.value = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
console.warn('Fetch course detail error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDetail();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,303 @@
|
||||
<!-- /src/views/courses/CourseFormView.vue -->
|
||||
<template>
|
||||
<div class="course-form-view w-full max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? $t('courses.editCourse') : $t('courses.addCourse')"
|
||||
:subtitle="isEditMode ? 'ویرایش مشخصات دوره و مدیریت کلاسها' : 'تعریف دوره آموزشی جدید'"
|
||||
>
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/courses')" />
|
||||
</PageHeader>
|
||||
|
||||
<!-- Course Details -->
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h2 class="text-lg font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات اصلی دوره</h2>
|
||||
<form @submit.prevent="handleSubmitCourse" class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.courseTitle') }} *</label>
|
||||
<InputText v-model.trim="form.title" class="w-full text-sm" :class="{ 'p-invalid': errors.title }" />
|
||||
<small v-if="errors.title" class="text-red-500 text-xs">{{ errors.title }}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.type') }}</label>
|
||||
<Dropdown v-model="form.type" :options="typeOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
|
||||
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" suffix=" تومان" />
|
||||
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.professor') }}</label>
|
||||
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="انتخاب استاد" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.capacity') }}</label>
|
||||
<InputNumber v-model="form.capacity" class="w-full text-sm" :min="1" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">تعداد جلسات</label>
|
||||
<InputNumber v-model="form.sectionCount" class="w-full text-sm" :min="1" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">ساعت هر جلسه</label>
|
||||
<InputNumber v-model="form.hoursPerSection" class="w-full text-sm" :min="0" :maxFractionDigits="1" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
|
||||
<InputSwitch v-model="form.isOfficial" />
|
||||
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
|
||||
<InputSwitch v-model="form.showOnFrontend" />
|
||||
<label class="font-semibold text-sm">نمایش در وبسایت</label>
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.description') }}</label>
|
||||
<Textarea v-model="form.description" rows="3" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">نکات برجسته (هر خط یک مورد)</label>
|
||||
<Textarea
|
||||
v-model="highlightsText"
|
||||
rows="4"
|
||||
class="w-full text-sm"
|
||||
placeholder="مثال: مناسب مبتدیان همراه با گواهینامه تمرین عملی"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex justify-content-end mt-3">
|
||||
<Button type="submit" label="ذخیره مشخصات دوره" icon="pi pi-check" :loading="isSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Classes of this course (edit mode only) -->
|
||||
<div v-if="isEditMode" 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>
|
||||
<Button
|
||||
label="تعریف کلاس جدید"
|
||||
icon="pi pi-plus"
|
||||
@click="$router.push(`/classes/create?courseId=${courseId}`)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableSkeleton v-if="loadingClasses" :rows="5" :columns="6" />
|
||||
<DataTable
|
||||
v-else
|
||||
:value="courseClasses"
|
||||
class="p-datatable-sm text-sm"
|
||||
emptyMessage="هنوز کلاسی برای این دوره تعریف نشده است"
|
||||
>
|
||||
<Column header="#" style="width: 50px">
|
||||
<template #body="{ index }">{{ toPersianDigits(index + 1) }}</template>
|
||||
</Column>
|
||||
<Column header="نام کلاس">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold">{{ data.name }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="استاد">
|
||||
<template #body="{ data }">
|
||||
{{ data.professor ? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() : '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="ظرفیت" style="width: 90px">
|
||||
<template #body="{ data }">{{ toPersianDigits(data.capacity || 0) }}</template>
|
||||
</Column>
|
||||
<Column header="دانشجو" style="width: 90px">
|
||||
<template #body="{ data }">{{ toPersianDigits((data.students || []).length) }}</template>
|
||||
</Column>
|
||||
<Column header="وضعیت" style="width: 90px">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.isActive !== false" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="عملیات" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="info"
|
||||
v-tooltip.top="'مشاهده'"
|
||||
@click="$router.push(`/classes/view/${data._id || data.id}`)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="warning"
|
||||
v-tooltip.top="'ویرایش / جلسات'"
|
||||
@click="$router.push(`/classes/edit/${data._id || data.id}`)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div v-else class="surface-card p-4 border-round border-1 border-color shadow-sm">
|
||||
<p class="text-muted text-sm m-0">پس از ذخیره دوره، میتوانید کلاسهای آن را در همین صفحه تعریف کنید. جلسات از داخل هر کلاس ساخته میشوند.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { professorApi } from '@/api/professorApi';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dropdown from 'primevue/select';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import InputSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const courseId = route.params.id;
|
||||
const isEditMode = computed(() => !!courseId);
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const loadingClasses = ref(false);
|
||||
const professors = ref([]);
|
||||
const courseClasses = ref([]);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'عمومی', value: 'General' },
|
||||
{ label: 'خصوصی', value: 'Private' }
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'General',
|
||||
price: 1500000,
|
||||
professor: null,
|
||||
capacity: 20,
|
||||
sectionCount: 12,
|
||||
hoursPerSection: 1.5,
|
||||
isOfficial: true,
|
||||
showOnFrontend: true
|
||||
});
|
||||
|
||||
const highlightsText = ref('');
|
||||
|
||||
const errors = reactive({
|
||||
title: '',
|
||||
price: ''
|
||||
});
|
||||
|
||||
const fetchProfessors = async () => {
|
||||
try {
|
||||
const res = await professorApi.getAll({ limit: 100 });
|
||||
const data = res.data || res;
|
||||
professors.value = data.items || data.professors || data || [];
|
||||
} catch (e) {
|
||||
console.warn('Professors fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchClassesForCourse = async () => {
|
||||
if (!courseId) return;
|
||||
loadingClasses.value = true;
|
||||
try {
|
||||
const res = await classApi.getAll({ limit: 100, courseId });
|
||||
const data = res.data || res;
|
||||
courseClasses.value = Array.isArray(data) ? data : (data.items || data.data || []);
|
||||
} catch (e) {
|
||||
console.warn('Classes fetch error:', e);
|
||||
} finally {
|
||||
loadingClasses.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCourse = async () => {
|
||||
if (!courseId) return;
|
||||
try {
|
||||
const res = await courseApi.getOne(courseId);
|
||||
const data = res.data || res;
|
||||
Object.assign(form, {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
type: data.type || 'General',
|
||||
price: data.price || 0,
|
||||
professor: data.professor?._id || data.professor || null,
|
||||
capacity: data.capacity || 20,
|
||||
sectionCount: data.sectionCount || 1,
|
||||
hoursPerSection: data.hoursPerSection ?? 1.5,
|
||||
isOfficial: data.isOfficial !== false,
|
||||
showOnFrontend: data.showOnFrontend !== false
|
||||
});
|
||||
highlightsText.value = Array.isArray(data.highlights) ? data.highlights.join('\n') : '';
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitCourse = async () => {
|
||||
if (!form.title) { errors.title = 'عنوان دوره الزامی است'; return; }
|
||||
if (form.price === null) { errors.price = 'شهریه الزامی است'; return; }
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
highlights: highlightsText.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
};
|
||||
if (isEditMode.value) {
|
||||
await courseApi.update(courseId, payload);
|
||||
showSuccess('مشخصات دوره با موفقیت ویرایش شد');
|
||||
} else {
|
||||
const res = await courseApi.create(payload);
|
||||
const newCourse = res.data || res;
|
||||
showSuccess('دوره جدید با موفقیت ایجاد شد');
|
||||
if (newCourse._id || newCourse.id) {
|
||||
router.push(`/courses/edit/${newCourse._id || newCourse.id}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProfessors();
|
||||
fetchCourse();
|
||||
fetchClassesForCourse();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<!-- /src/views/courses/CourseListView.vue -->
|
||||
<template>
|
||||
<div class="course-list-view">
|
||||
<PageHeader :title="$t('courses.title')" :subtitle="$t('courses.subtitle')">
|
||||
<PermissionGate permission="courses:create">
|
||||
<Button :label="$t('courses.addCourse')" icon="pi pi-plus" @click="$router.push('/courses/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="title" header="عنوان دوره" sortable>
|
||||
<template #body="{ data }">
|
||||
<router-link :to="`/courses/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
||||
{{ data.title }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="type" header="نوع دوره">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.type === 'Private' ? $t('courses.typePrivate') : $t('courses.typeGeneral')" :severity="data.type === 'Private' ? 'warning' : 'info'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="price" header="شهریه">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="rating" header="امتیاز">
|
||||
<template #body="{ data }">
|
||||
<Rating :modelValue="data.rating || 5" readonly :cancel="false" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="isOfficial" header="مدرک رسمی">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.isOfficial ? $t('courses.official') : $t('courses.unofficial')" :severity="data.isOfficial ? 'success' : 'secondary'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="تخفیف فعال">
|
||||
<template #body="{ data }">
|
||||
<Tag v-if="data.activeDiscount" value="دارای تخفیف" severity="danger" />
|
||||
<span v-else class="text-muted text-xs">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 130px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="courses:read">
|
||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/courses/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="courses:update">
|
||||
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/courses/edit/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="courses:delete">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Rating from 'primevue/rating';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(courseApi.getAll);
|
||||
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedCourse = ref(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const confirmDelete = (course) => {
|
||||
selectedCourse.value = course;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCourse.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await courseApi.delete(selectedCourse.value._id || selectedCourse.value.id);
|
||||
showSuccess('دوره آموزشی با موفقیت حذف شد');
|
||||
deleteDialogVisible.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,432 @@
|
||||
<!-- /src/views/dashboard/DashboardHomeView.vue -->
|
||||
<template>
|
||||
<div class="dashboard-home-view">
|
||||
<PageHeader title="داشبورد مدیریتی" subtitle="خلاصه آمار، عملکرد و دسترسیهای سریع سیستم">
|
||||
<Button icon="pi pi-refresh" text rounded v-tooltip.top="'بروزرسانی'" :loading="isLoading" @click="loadStats" />
|
||||
</PageHeader>
|
||||
|
||||
<!-- ── Stats Cards ──────────────────────────────────────────────────────── -->
|
||||
<div class="grid mb-4">
|
||||
<div class="col-12 sm:col-6 lg:col-3" v-for="stat in statCards" :key="stat.title">
|
||||
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3">
|
||||
<div class="flex-grow-1">
|
||||
<span class="text-muted text-xs block mb-1">{{ stat.title }}</span>
|
||||
<div class="flex align-items-baseline gap-2">
|
||||
<span class="text-3xl font-bold text-color">
|
||||
<span v-if="isLoading">—</span>
|
||||
<span v-else>{{ toPersianDigits(stat.value) }}</span>
|
||||
</span>
|
||||
<span v-if="stat.sub !== undefined && !isLoading" class="text-xs text-muted">
|
||||
({{ toPersianDigits(stat.sub) }} فعال)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="stat.bgClass">
|
||||
<i :class="stat.icon" class="text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Charts Row ───────────────────────────────────────────────────────── -->
|
||||
<div class="grid mb-4">
|
||||
<!-- Daily Enrollment Chart -->
|
||||
<div class="col-12 lg:col-8">
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
|
||||
<div class="flex align-items-center justify-content-between mb-4">
|
||||
<h3 class="text-base font-bold text-color m-0">روند ثبتنام کاربران (۱۴ روز اخیر)</h3>
|
||||
<Tag value="روزانه" severity="info" />
|
||||
</div>
|
||||
<div class="chart-container" style="height: 200px;">
|
||||
<div v-if="isLoading" class="flex align-items-center justify-content-center h-full">
|
||||
<ProgressSpinner style="width: 40px; height: 40px;" />
|
||||
</div>
|
||||
<div v-else-if="!dailyLabels.length" class="flex align-items-center justify-content-center h-full text-muted">
|
||||
<span>دادهای برای نمایش وجود ندارد</span>
|
||||
</div>
|
||||
<div v-else class="bar-chart-wrapper h-full flex align-items-end gap-1">
|
||||
<div
|
||||
v-for="(item, idx) in dailyEnrollmentData"
|
||||
:key="idx"
|
||||
class="bar-group flex flex-column align-items-center gap-1 flex-grow-1"
|
||||
>
|
||||
<span class="text-xs font-bold text-primary">{{ toPersianDigits(item.count) }}</span>
|
||||
<div
|
||||
class="bar-fill border-round-top"
|
||||
:style="{ height: getBarHeight(item.count, maxEnrollment) + 'px', minHeight: '4px' }"
|
||||
></div>
|
||||
<span class="bar-label text-muted text-center">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Doughnuts -->
|
||||
<div class="col-12 lg:col-4">
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
|
||||
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت فعال / کل</h3>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div v-for="ring in ringStats" :key="ring.label" class="flex align-items-center gap-3">
|
||||
<div class="ring-chart flex-shrink-0" :style="getRingStyle(ring)">
|
||||
<span class="ring-pct text-xs font-bold">{{ ring.pct }}٪</span>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-sm font-semibold text-color">{{ ring.label }}</div>
|
||||
<div class="text-xs text-muted mt-1">
|
||||
<span class="font-bold" :class="ring.activeClass">{{ toPersianDigits(ring.active) }}</span>
|
||||
<span class="mx-1">/</span>
|
||||
<span>{{ toPersianDigits(ring.total) }} کل</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quick Access ─────────────────────────────────────────────────────── -->
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||
<h3 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">دسترسی سریع</h3>
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="link in quickLinks"
|
||||
:key="link.to"
|
||||
class="col-6 sm:col-4 lg:col-2"
|
||||
>
|
||||
<div
|
||||
class="quick-link-card border-round-xl p-3 flex flex-column align-items-center gap-2 cursor-pointer text-center"
|
||||
:class="link.cardClass"
|
||||
@click="$router.push(link.to)"
|
||||
>
|
||||
<div class="quick-link-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center" :class="link.iconBg">
|
||||
<i :class="[link.icon, 'text-xl', link.iconColor]"></i>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-color">{{ link.label }}</span>
|
||||
<Tag v-if="link.badge !== undefined" :value="toPersianDigits(link.badge)" :severity="link.badgeSeverity || 'secondary'" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Recent Sessions ─────────────────────────────────────────────────── -->
|
||||
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="text-base font-bold text-color m-0">آخرین جلسات</h3>
|
||||
<Button label="مشاهده همه" icon="pi pi-arrow-left" text size="small" @click="$router.push('/sessions')" />
|
||||
</div>
|
||||
<DataTable :value="recentSessions" class="p-datatable-sm text-sm" :loading="isLoading">
|
||||
<template #empty>
|
||||
<div class="text-center text-muted p-4">هنوز جلسهای ثبت نشده است</div>
|
||||
</template>
|
||||
<Column field="topic" header="موضوع">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="course" header="دوره">
|
||||
<template #body="{ data }">
|
||||
{{ data.course?.title || data.courseTitle || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="class" header="کلاس">
|
||||
<template #body="{ data }">
|
||||
{{ data.class?.name || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="professor" header="استاد">
|
||||
<template #body="{ data }">
|
||||
{{
|
||||
data.professor
|
||||
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
|
||||
: '—'
|
||||
}}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="day" header="تاریخ">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.day || data.date) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="time" header="ساعت">
|
||||
<template #body="{ data }">
|
||||
<span dir="ltr">{{ data.startTime || '—' }} – {{ data.endTime || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status || 'scheduled'" type="session" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { dashboardApi } from '@/api/dashboardApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import ProgressSpinner from 'primevue/progressspinner';
|
||||
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
const isLoading = ref(false);
|
||||
const stats = ref({
|
||||
totals: { users: 0, activeUsers: 0, professors: 0, activeProfessors: 0, courses: 0, sessions: 0 },
|
||||
recentSessions: [],
|
||||
charts: { dailyUsers: [], dailySessions: [] }
|
||||
});
|
||||
|
||||
// ── Stat Cards ────────────────────────────────────────────────────────────────
|
||||
const statCards = computed(() => [
|
||||
{
|
||||
title: 'کل کاربران',
|
||||
value: stats.value.totals.users,
|
||||
sub: stats.value.totals.activeUsers,
|
||||
icon: 'pi pi-users text-blue-500',
|
||||
bgClass: 'bg-blue-100'
|
||||
},
|
||||
{
|
||||
title: 'اساتید موسسه',
|
||||
value: stats.value.totals.professors,
|
||||
sub: stats.value.totals.activeProfessors,
|
||||
icon: 'pi pi-id-card text-green-500',
|
||||
bgClass: 'bg-green-100'
|
||||
},
|
||||
{
|
||||
title: 'دورههای آموزشی',
|
||||
value: stats.value.totals.courses,
|
||||
icon: 'pi pi-book text-purple-500',
|
||||
bgClass: 'bg-purple-100'
|
||||
},
|
||||
{
|
||||
title: 'کل جلسات',
|
||||
value: stats.value.totals.sessions,
|
||||
icon: 'pi pi-calendar text-orange-500',
|
||||
bgClass: 'bg-orange-100'
|
||||
}
|
||||
]);
|
||||
|
||||
// ── Ring Stats ────────────────────────────────────────────────────────────────
|
||||
const ringStats = computed(() => {
|
||||
const t = stats.value.totals;
|
||||
return [
|
||||
{
|
||||
label: 'کاربران فعال',
|
||||
total: t.users,
|
||||
active: t.activeUsers,
|
||||
pct: t.users ? Math.round((t.activeUsers / t.users) * 100) : 0,
|
||||
color: '#3B82F6',
|
||||
activeClass: 'text-blue-500'
|
||||
},
|
||||
{
|
||||
label: 'اساتید فعال',
|
||||
total: t.professors,
|
||||
active: t.activeProfessors,
|
||||
pct: t.professors ? Math.round((t.activeProfessors / t.professors) * 100) : 0,
|
||||
color: '#10B981',
|
||||
activeClass: 'text-green-500'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
// ── Chart Helpers ─────────────────────────────────────────────────────────────
|
||||
const dailyEnrollmentData = computed(() => {
|
||||
const raw = stats.value.charts?.dailyUsers || [];
|
||||
return raw.map((item) => ({
|
||||
count: item.count || 0,
|
||||
label: formatJalali(item.date || buildDateFromParts(item._id), 'jD jMMMM')
|
||||
}));
|
||||
});
|
||||
|
||||
const buildDateFromParts = (parts) => {
|
||||
if (!parts?.year || !parts?.month || !parts?.day) return null;
|
||||
return new Date(parts.year, parts.month - 1, parts.day);
|
||||
};
|
||||
|
||||
const dailyLabels = computed(() => dailyEnrollmentData.value.map((i) => i.label));
|
||||
|
||||
const maxEnrollment = computed(() => {
|
||||
const counts = dailyEnrollmentData.value.map((i) => i.count);
|
||||
return Math.max(...counts, 1);
|
||||
});
|
||||
|
||||
const MAX_BAR_HEIGHT = 140;
|
||||
const getBarHeight = (val, max) => Math.max(4, (val / max) * MAX_BAR_HEIGHT);
|
||||
|
||||
const getRingStyle = (ring) => {
|
||||
const deg = (ring.pct / 100) * 360;
|
||||
return {
|
||||
background: `conic-gradient(${ring.color} ${deg}deg, var(--surface-border) ${deg}deg)`,
|
||||
borderRadius: '50%',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative'
|
||||
};
|
||||
};
|
||||
|
||||
// ── Recent Sessions ───────────────────────────────────────────────────────────
|
||||
const recentSessions = computed(() => stats.value.recentSessions || []);
|
||||
|
||||
// ── Quick Links ───────────────────────────────────────────────────────────────
|
||||
const quickLinks = computed(() => [
|
||||
{
|
||||
to: '/users/create',
|
||||
label: 'افزودن کاربر',
|
||||
icon: 'pi pi-user-plus',
|
||||
iconBg: 'bg-blue-100',
|
||||
iconColor: 'text-blue-600',
|
||||
cardClass: 'quick-link-blue',
|
||||
badge: stats.value.totals.users,
|
||||
badgeSeverity: 'info'
|
||||
},
|
||||
{
|
||||
to: '/professors/create',
|
||||
label: 'افزودن استاد',
|
||||
icon: 'pi pi-plus-circle',
|
||||
iconBg: 'bg-green-100',
|
||||
iconColor: 'text-green-600',
|
||||
cardClass: 'quick-link-green',
|
||||
badge: stats.value.totals.professors,
|
||||
badgeSeverity: 'success'
|
||||
},
|
||||
{
|
||||
to: '/courses/create',
|
||||
label: 'دوره جدید',
|
||||
icon: 'pi pi-book',
|
||||
iconBg: 'bg-purple-100',
|
||||
iconColor: 'text-purple-600',
|
||||
cardClass: 'quick-link-purple',
|
||||
badge: stats.value.totals.courses,
|
||||
badgeSeverity: 'secondary'
|
||||
},
|
||||
{
|
||||
to: '/sessions/create',
|
||||
label: 'جلسه جدید',
|
||||
icon: 'pi pi-calendar-plus',
|
||||
iconBg: 'bg-orange-100',
|
||||
iconColor: 'text-orange-600',
|
||||
cardClass: 'quick-link-orange',
|
||||
badge: stats.value.totals.sessions,
|
||||
badgeSeverity: 'warning'
|
||||
},
|
||||
{
|
||||
to: '/payments',
|
||||
label: 'امور مالی',
|
||||
icon: 'pi pi-wallet',
|
||||
iconBg: 'bg-teal-100',
|
||||
iconColor: 'text-teal-600',
|
||||
cardClass: 'quick-link-teal'
|
||||
},
|
||||
{
|
||||
to: '/roles',
|
||||
label: 'نقشها',
|
||||
icon: 'pi pi-shield',
|
||||
iconBg: 'bg-red-100',
|
||||
iconColor: 'text-red-600',
|
||||
cardClass: 'quick-link-red'
|
||||
}
|
||||
]);
|
||||
|
||||
// ── Load Data ─────────────────────────────────────────────────────────────────
|
||||
const loadStats = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res = await dashboardApi.getStats();
|
||||
const data = res.data || res;
|
||||
// Support both { data: { totals... } } and { totals... } structures
|
||||
stats.value = data.data || data;
|
||||
} catch (err) {
|
||||
console.warn('Dashboard stats not available, showing zeros:', err.message);
|
||||
// Graceful fallback — zeros, no crash
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
.stat-card:hover & {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.bar-chart-wrapper {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, var(--primary-color), var(--primary-color-light, #818cf8));
|
||||
min-width: 12px;
|
||||
transition: height 0.4s ease;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 9px;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ring-chart {
|
||||
position: relative;
|
||||
|
||||
.ring-pct {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--surface-card);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-link-card {
|
||||
background: var(--surface-ground);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50, rgba(99, 102, 241, 0.04));
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- /src/views/errors/ForbiddenView.vue -->
|
||||
<template>
|
||||
<div class="forbidden-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
|
||||
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-red-100 text-red-600 mb-4">
|
||||
<i class="pi pi-lock text-5xl"></i>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.forbiddenTitle') }}</h1>
|
||||
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.forbiddenMessage') }}</p>
|
||||
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Button from 'primevue/button';
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- /src/views/errors/NotFoundView.vue -->
|
||||
<template>
|
||||
<div class="not-found-view flex flex-column align-items-center justify-content-center min-h-screen text-center p-4">
|
||||
<div class="inline-flex align-items-center justify-content-center w-6rem h-6rem border-circle bg-orange-100 text-orange-600 mb-4">
|
||||
<i class="pi pi-exclamation-circle text-5xl"></i>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-color mb-2">{{ $t('errors.notFoundTitle') }}</h1>
|
||||
<p class="text-muted text-base max-w-28rem mb-4">{{ $t('errors.notFoundMessage') }}</p>
|
||||
<Button :label="$t('errors.backToDashboard')" icon="pi pi-home" @click="$router.push('/')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Button from 'primevue/button';
|
||||
</script>
|
||||
@@ -0,0 +1,131 @@
|
||||
<!-- /src/views/notifications/NotificationListView.vue -->
|
||||
<template>
|
||||
<div class="notification-list-view">
|
||||
<PageHeader :title="$t('notifications.title')" :subtitle="$t('notifications.subtitle')" />
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="recipient" header="گیرنده">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.user?.name || data.recipient || '-' }}</span>
|
||||
<span class="text-xs text-muted block" dir="ltr">{{ data.user?.phoneNumber || data.targetAddress || '' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="channel" header="کانال ارسال">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="getChannelLabel(data.channel)" :severity="getChannelSeverity(data.channel)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="subject" header="موضوع / متن">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm truncate max-w-20rem block" :title="data.subject || data.content">
|
||||
{{ data.subject || data.content || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="وضعیت تحویل">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status || 'delivered'" type="notification" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="retryCount" header="تعداد تلاش">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.retryCount || 0) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 120px">
|
||||
<template #body="{ data }">
|
||||
<PermissionGate permission="notifications:retry">
|
||||
<Button
|
||||
v-if="data.status === 'failed' || data.status === 'ناموفق'"
|
||||
icon="pi pi-refresh"
|
||||
label="تلاش مجدد"
|
||||
size="small"
|
||||
severity="warning"
|
||||
:loading="retryingId === (data._id || data.id)"
|
||||
@click="handleRetry(data)"
|
||||
/>
|
||||
</PermissionGate>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { notificationApi } from '@/api/notificationApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(notificationApi.getAll);
|
||||
|
||||
const retryingId = ref(null);
|
||||
|
||||
const getChannelLabel = (ch) => {
|
||||
if (ch === 'bale') return 'پیامرسان بله';
|
||||
if (ch === 'sms') return 'پیامک SMS';
|
||||
if (ch === 'email') return 'ایمیل';
|
||||
return ch || 'بله';
|
||||
};
|
||||
|
||||
const getChannelSeverity = (ch) => {
|
||||
if (ch === 'bale') return 'success';
|
||||
if (ch === 'sms') return 'info';
|
||||
if (ch === 'email') return 'warning';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
const handleRetry = async (item) => {
|
||||
const id = item._id || item.id;
|
||||
retryingId.value = id;
|
||||
try {
|
||||
await notificationApi.retry(id);
|
||||
showSuccess('ارسال مجدد با موفقیت درخواست شد');
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
retryingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<!-- /src/views/payments/PaymentDetailView.vue -->
|
||||
<template>
|
||||
<div class="payment-detail-view" v-if="payment">
|
||||
<PageHeader :title="$t('payments.paymentDetail')" :subtitle="`کد صورتحساب: ${payment._id || payment.id}`">
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/payments')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid mb-4">
|
||||
<!-- Summary Card -->
|
||||
<div class="col-12 lg:col-4">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h3 class="text-lg font-bold mb-3">خلاصه صورتحساب</h3>
|
||||
<div class="flex flex-column gap-3">
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">نام کاربر / دانشجو</span>
|
||||
<span class="font-bold text-color text-base">{{ payment.user?.name || payment.userName || 'کاربر' }} {{ payment.user?.surname || '' }}</span>
|
||||
</div>
|
||||
<div v-if="payment.classes && payment.classes.length">
|
||||
<span class="text-muted text-xs block mb-1">کلاسهای مربوطه</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="payment.course">
|
||||
<span class="text-muted text-xs block mb-1">دوره آموزشی</span>
|
||||
<span class="font-semibold text-color text-sm">{{ payment.course?.title }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">مبلغ کل صورتحساب</span>
|
||||
<span class="font-bold text-color text-xl">{{ toPersianDigits(payment.amount?.toLocaleString()) }} تومان</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">مبلغ کل دریافتی</span>
|
||||
<span class="font-bold text-green-600 text-lg">{{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">باقیمانده</span>
|
||||
<span class="font-bold text-red-500 text-lg">{{ toPersianDigits((payment.amount - (payment.paidAmount || 0)).toLocaleString()) }} تومان</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted text-xs block mb-1">وضعیت پرداخت</span>
|
||||
<StatusTag :status="payment.status" type="payment" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction History & Form -->
|
||||
<div class="col-12 lg:col-8">
|
||||
<div class="surface-card p-4 border-round border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="text-lg font-bold m-0">تاریخچه تراکنشهای دریافتی</h3>
|
||||
<PermissionGate permission="payments:update">
|
||||
<Button label="ثبت تراکنش جدید" icon="pi pi-plus" size="small" severity="success" @click="showTransactionModal = true" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
<DataTable :value="payment.transactions || []" class="p-datatable-sm text-sm">
|
||||
<Column field="amount" header="مبلغ تراکنش">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="method" header="روش پرداخت">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.method === 'online' ? 'درگاه آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')" severity="info" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="receiptNumber" header="شماره فیش / پیگیری">
|
||||
<template #body="{ data }">
|
||||
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="createdAt" header="تاریخ ثبت">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.createdAt || data.date) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Record New Transaction Modal -->
|
||||
<Dialog v-model:visible="showTransactionModal" header="ثبت تراکنش جدید" modal :style="{ width: '450px' }">
|
||||
<div class="flex flex-column gap-3 py-2">
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
||||
<InputNumber v-model="trxForm.amount" class="w-full text-sm" suffix=" تومان" />
|
||||
</div>
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">روش پرداخت *</label>
|
||||
<Dropdown v-model="trxForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||
</div>
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">شماره فیش / پیگیری *</label>
|
||||
<InputText v-model.trim="trxForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="انصراف" text severity="secondary" @click="showTransactionModal = false" />
|
||||
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="isSubmittingTrx" @click="handleRecordTransaction" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { paymentApi } from '@/api/paymentApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/select';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
|
||||
const route = useRoute();
|
||||
const paymentId = route.params.id;
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const payment = ref(null);
|
||||
const showTransactionModal = ref(false);
|
||||
const isSubmittingTrx = ref(false);
|
||||
|
||||
const methodOptions = [
|
||||
{ label: 'کارت به کارت', value: 'card' },
|
||||
{ label: 'درگاه آنلاین', value: 'online' },
|
||||
{ label: 'نقدی / شبا', value: 'cash' }
|
||||
];
|
||||
|
||||
const trxForm = reactive({
|
||||
amount: 500000,
|
||||
method: 'card',
|
||||
receiptNumber: ''
|
||||
});
|
||||
|
||||
const fetchPayment = async () => {
|
||||
try {
|
||||
const res = await paymentApi.getOne(paymentId);
|
||||
payment.value = res.data || res;
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecordTransaction = async () => {
|
||||
if (!trxForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
|
||||
if (!trxForm.receiptNumber) { showError('لطفا شماره فیش یا پیگیری را وارد کنید'); return; }
|
||||
|
||||
isSubmittingTrx.value = true;
|
||||
try {
|
||||
await paymentApi.recordTransaction(paymentId, trxForm);
|
||||
showSuccess('تراکنش جدید با موفقیت ثبت شد');
|
||||
showTransactionModal.value = false;
|
||||
fetchPayment();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmittingTrx.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPayment();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
<!-- /src/views/payments/PaymentListView.vue -->
|
||||
<template>
|
||||
<div class="payment-list-view">
|
||||
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
|
||||
<PermissionGate permission="payments:create">
|
||||
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="showCreateModal = true" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="user" header="نام دانشجو / کاربر">
|
||||
<template #body="{ data }">
|
||||
<router-link :to="`/payments/view/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
||||
{{ data.user?.name || data.userName || 'کاربر' }} {{ data.user?.surname || '' }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="classes" header="کلاسهای مربوطه">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1" v-if="data.classes && data.classes.length">
|
||||
<Tag v-for="c in data.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
||||
</div>
|
||||
<span v-else-if="data.course" class="text-muted text-xs">{{ data.course?.title }}</span>
|
||||
<span v-else class="text-muted text-xs">-</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="amount" header="مبلغ کل (تومان)" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="paidAmount" header="مبلغ پرداختی">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="dueDate" header="تاریخ سررسید" sortable>
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.dueDate) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status" type="payment" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 110px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="payments:read">
|
||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="payments:delete">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<!-- Create Payment Modal -->
|
||||
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '480px' }">
|
||||
<div class="flex flex-column gap-3 py-2">
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
|
||||
<Dropdown v-model="createForm.user" :options="usersList" optionLabel="fullName" optionValue="_id" filter placeholder="کاربر را انتخاب کنید" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس مربوطه *</label>
|
||||
<MultiSelect
|
||||
v-model="createForm.classes"
|
||||
:options="classesList"
|
||||
optionLabel="name"
|
||||
optionValue="_id"
|
||||
display="chip"
|
||||
placeholder="کلاسها را انتخاب کنید"
|
||||
class="w-full text-sm"
|
||||
@change="onClassesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">مبلغ کل صورتحساب (تومان) *</label>
|
||||
<InputNumber v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||
<DatePicker v-model="createForm.dueDate" class="w-full text-sm" placeholder="1403/01/01" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
||||
<Button label="ایجاد صورتحساب" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreatePayment" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { paymentApi } from '@/api/paymentApi';
|
||||
import { userApi } from '@/api/userApi';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/select';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import DatePicker from 'vue3-persian-datetime-picker';
|
||||
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(paymentApi.getAll);
|
||||
|
||||
const showCreateModal = ref(false);
|
||||
const usersList = ref([]);
|
||||
const classesList = ref([]);
|
||||
const isCreating = ref(false);
|
||||
|
||||
const createForm = reactive({
|
||||
user: null,
|
||||
classes: [],
|
||||
amount: 0,
|
||||
dueDate: ''
|
||||
});
|
||||
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedPayment = ref(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const onClassesSelected = () => {
|
||||
if (!createForm.classes || createForm.classes.length === 0) return;
|
||||
let totalFee = 0;
|
||||
createForm.classes.forEach(classId => {
|
||||
const c = classesList.value.find(item => (item._id || item.id) === classId);
|
||||
if (c) {
|
||||
totalFee += (c.tuitionFee || c.course?.price || 0);
|
||||
}
|
||||
});
|
||||
if (totalFee > 0) {
|
||||
createForm.amount = totalFee;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDropdownData = async () => {
|
||||
try {
|
||||
const [uRes, cRes] = await Promise.all([
|
||||
userApi.getAll({ limit: 150 }),
|
||||
classApi.getAll({ limit: 100 })
|
||||
]);
|
||||
const uData = uRes.data || uRes;
|
||||
const rawUsers = uData.items || uData.users || uData || [];
|
||||
usersList.value = rawUsers.map(u => ({ ...u, fullName: `${u.name} ${u.surname}` }));
|
||||
|
||||
const cData = cRes.data || cRes;
|
||||
classesList.value = cData.items || cData.classes || cData || [];
|
||||
} catch (e) {
|
||||
console.warn('Dropdown fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePayment = async () => {
|
||||
if (!createForm.user) { showError('لطفا کاربر را انتخاب کنید'); return; }
|
||||
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
|
||||
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await paymentApi.create(createForm);
|
||||
showSuccess('صورتحساب جدید با موفقیت ایجاد شد');
|
||||
showCreateModal.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (payment) => {
|
||||
selectedPayment.value = payment;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedPayment.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await paymentApi.delete(selectedPayment.value._id || selectedPayment.value.id);
|
||||
showSuccess('صورتحساب با موفقیت حذف شد');
|
||||
deleteDialogVisible.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
fetchDropdownData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!-- /src/views/professors/ProfessorFormView.vue -->
|
||||
<template>
|
||||
<div class="professor-form-view w-full max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? $t('professors.editProfessor') : $t('professors.addProfessor')"
|
||||
:subtitle="isEditMode ? 'ویرایش رزومه و تخصصهای استاد' : 'ثبت نام و اطلاعات استاد جدید'"
|
||||
>
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
||||
<form @submit.prevent="handleSubmit" class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
||||
<InputText v-model.trim="form.name" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
|
||||
<InputText v-model.trim="form.surname" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
|
||||
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
|
||||
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('professors.bio') }}</label>
|
||||
<Textarea v-model="form.bio" rows="3" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/professors')" />
|
||||
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { professorApi } from '@/api/professorApi';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const professorId = route.params.id;
|
||||
const isEditMode = computed(() => !!professorId);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
surname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
bio: ''
|
||||
});
|
||||
|
||||
const fetchProfessor = async () => {
|
||||
if (!professorId) return;
|
||||
try {
|
||||
const res = await professorApi.getOne(professorId);
|
||||
const data = res.data || res;
|
||||
Object.assign(form, {
|
||||
name: data.name || '',
|
||||
surname: data.surname || '',
|
||||
phone: data.phone || '',
|
||||
email: data.email || '',
|
||||
bio: data.bio || ''
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.surname) { showError('نام و نام خانوادگی الزامی است'); return; }
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
await professorApi.update(professorId, form);
|
||||
showSuccess('اطلاعات استاد با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await professorApi.create(form);
|
||||
showSuccess('استاد جدید با موفقیت ایجاد شد');
|
||||
}
|
||||
router.push('/professors');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProfessor();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<!-- /src/views/professors/ProfessorListView.vue -->
|
||||
<template>
|
||||
<div class="professor-list-view">
|
||||
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
|
||||
<PermissionGate permission="professors:create">
|
||||
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="name" header="نام و نام خانوادگی" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.name }} {{ data.surname }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="specialization" header="تخصص">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.specialization || 'عمومی'" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="phoneNumber" header="شماره همراه">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="email" header="ایمیل">
|
||||
<template #body="{ data }">
|
||||
{{ data.email || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="activeCoursesCount" header="دورههای فعال">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.activeCoursesCount || data.courses?.length || 0) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 110px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="professors:update">
|
||||
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/professors/edit/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="professors:delete">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { professorApi } from '@/api/professorApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(professorApi.getAll);
|
||||
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedProf = ref(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const confirmDelete = (prof) => {
|
||||
selectedProf.value = prof;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedProf.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await professorApi.delete(selectedProf.value._id || selectedProf.value.id);
|
||||
showSuccess('استاد با موفقیت حذف شد');
|
||||
deleteDialogVisible.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,306 @@
|
||||
<!-- /src/views/roles/RoleFormView.vue -->
|
||||
<template>
|
||||
<div class="role-form-view w-full max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? $t('roles.editRole') : $t('roles.addRole')"
|
||||
:subtitle="isEditMode ? 'ویرایش مشخصات نقش و سطح دسترسیها' : 'ایجاد نقش سیستمی جدید'"
|
||||
>
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Basic Info -->
|
||||
<div class="col-12">
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<h2 class="text-base font-bold text-color mb-4 pb-2 border-bottom-1 border-color">مشخصات نقش</h2>
|
||||
<div class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('roles.roleName') }} *</label>
|
||||
<InputText
|
||||
v-model.trim="form.name"
|
||||
class="w-full text-sm"
|
||||
:disabled="isSystemRole"
|
||||
:placeholder="isSystemRole ? 'نقشهای سیستمی قابل تغییر نام نیستند' : 'مثلا: مشاور، حسابدار'"
|
||||
/>
|
||||
<small v-if="isSystemRole" class="text-orange-500 text-xs flex align-items-center gap-1">
|
||||
<i class="pi pi-lock text-xs"></i> نقش سیستمی — نام قابل تغییر نیست
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">توضیحات نقش</label>
|
||||
<InputText
|
||||
v-model.trim="form.description"
|
||||
class="w-full text-sm"
|
||||
:disabled="isSystemRole"
|
||||
placeholder="شرح وظایف و حوزه دسترسی این نقش..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions -->
|
||||
<div class="col-12">
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||
<div class="flex align-items-center justify-content-between mb-4 pb-2 border-bottom-1 border-color">
|
||||
<h2 class="text-base font-bold text-color m-0">{{ $t('roles.permissionsTitle') }}</h2>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<span class="text-muted text-xs">{{ selectedCount }} از {{ totalCount }} مجوز انتخاب شده</span>
|
||||
<Button
|
||||
:label="isAllSelected ? 'لغو انتخاب همه' : 'انتخاب همه مجوزها'"
|
||||
:icon="isAllSelected ? 'pi pi-times-circle' : 'pi pi-check-circle'"
|
||||
size="small"
|
||||
:severity="isAllSelected ? 'secondary' : 'success'"
|
||||
text
|
||||
:disabled="isSystemRole"
|
||||
@click="toggleSelectAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isSystemRole" class="surface-ground p-3 border-round border-1 border-color mb-4 text-sm flex align-items-center gap-2">
|
||||
<i class="pi pi-info-circle text-primary"></i>
|
||||
<span>مجوزهای نقشهای سیستمی قابل تغییر نیستند. برای دیدن مجوزهای فعال این نقش، لیست زیر را مشاهده کنید.</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="group in permissionGroups"
|
||||
:key="group.key"
|
||||
class="col-12 md:col-6 xl:col-4 mb-3"
|
||||
>
|
||||
<div class="permission-group border-round border-1 border-color overflow-hidden">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
class="group-header p-3 flex align-items-center justify-content-between cursor-pointer"
|
||||
:class="getGroupHeaderClass(group)"
|
||||
@click="!isSystemRole && toggleGroup(group)"
|
||||
>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<i :class="group.icon" class="text-base"></i>
|
||||
<span class="font-semibold text-sm">{{ group.label }}</span>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<Badge
|
||||
:value="`${getGroupSelectedCount(group)}/${group.permissions.length}`"
|
||||
:severity="getGroupSelectedCount(group) === group.permissions.length ? 'success' : getGroupSelectedCount(group) > 0 ? 'warning' : 'secondary'"
|
||||
/>
|
||||
<Checkbox
|
||||
:modelValue="isGroupFullySelected(group)"
|
||||
:indeterminate="isGroupPartiallySelected(group)"
|
||||
:disabled="isSystemRole"
|
||||
binary
|
||||
@click.stop="!isSystemRole && toggleGroup(group)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permission Items -->
|
||||
<div class="group-items">
|
||||
<div
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.key"
|
||||
class="perm-item flex align-items-center gap-2 px-3 py-2 border-top-1 border-color"
|
||||
:class="{ 'perm-active': form.permissions.includes(perm.key) }"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="form.permissions"
|
||||
:value="perm.key"
|
||||
:disabled="isSystemRole"
|
||||
/>
|
||||
<label
|
||||
:for="`perm-${perm.key}`"
|
||||
class="text-xs flex-grow-1"
|
||||
:class="{ 'font-semibold text-primary': form.permissions.includes(perm.key), 'cursor-pointer': !isSystemRole }"
|
||||
@click="!isSystemRole && togglePermission(perm.key)"
|
||||
>
|
||||
{{ perm.label }}
|
||||
</label>
|
||||
<code class="text-xs text-muted" style="font-size: 10px;">{{ perm.key }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="col-12">
|
||||
<div class="flex justify-content-end gap-2">
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/roles')" />
|
||||
<Button
|
||||
:label="$t('app.save')"
|
||||
icon="pi pi-check"
|
||||
:loading="isSubmitting"
|
||||
:disabled="isSystemRole"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { roleApi } from '@/api/roleApi';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/constants/permissions';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Badge from 'primevue/badge';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const roleId = route.params.id;
|
||||
const isEditMode = computed(() => !!roleId);
|
||||
const isSubmitting = ref(false);
|
||||
const isSystemRole = ref(false);
|
||||
|
||||
const permissionGroups = PERMISSION_GROUPS;
|
||||
const totalCount = ALL_PERMISSIONS.length;
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: []
|
||||
});
|
||||
|
||||
// ── Computed helpers ──────────────────────────────────────────────────────────
|
||||
const selectedCount = computed(() => form.permissions.length);
|
||||
|
||||
const isAllSelected = computed(
|
||||
() => form.permissions.length === ALL_PERMISSIONS.length
|
||||
);
|
||||
|
||||
const isGroupFullySelected = (group) =>
|
||||
group.permissions.every(p => form.permissions.includes(p.key));
|
||||
|
||||
const isGroupPartiallySelected = (group) => {
|
||||
const count = group.permissions.filter(p => form.permissions.includes(p.key)).length;
|
||||
return count > 0 && count < group.permissions.length;
|
||||
};
|
||||
|
||||
const getGroupSelectedCount = (group) =>
|
||||
group.permissions.filter(p => form.permissions.includes(p.key)).length;
|
||||
|
||||
const getGroupHeaderClass = (group) => {
|
||||
if (isGroupFullySelected(group)) return 'bg-green-50';
|
||||
if (isGroupPartiallySelected(group)) return 'bg-orange-50';
|
||||
return 'surface-ground';
|
||||
};
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
form.permissions = [];
|
||||
} else {
|
||||
form.permissions = [...ALL_PERMISSIONS];
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (group) => {
|
||||
const groupKeys = group.permissions.map(p => p.key);
|
||||
if (isGroupFullySelected(group)) {
|
||||
form.permissions = form.permissions.filter(p => !groupKeys.includes(p));
|
||||
} else {
|
||||
const toAdd = groupKeys.filter(k => !form.permissions.includes(k));
|
||||
form.permissions = [...form.permissions, ...toAdd];
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (key) => {
|
||||
const idx = form.permissions.indexOf(key);
|
||||
if (idx === -1) {
|
||||
form.permissions.push(key);
|
||||
} else {
|
||||
form.permissions.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
const fetchRole = async () => {
|
||||
if (!roleId) return;
|
||||
try {
|
||||
const res = await roleApi.getOne(roleId);
|
||||
const data = res.data || res;
|
||||
isSystemRole.value = !!data.isSystem;
|
||||
Object.assign(form, {
|
||||
name: data.name || '',
|
||||
description: data.description || '',
|
||||
permissions: data.permissions || []
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name) { showError('نام نقش الزامی است'); return; }
|
||||
if (isSystemRole.value) { showError('نقشهای سیستمی قابل ویرایش نیستند'); return; }
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (isEditMode.value) {
|
||||
await roleApi.update(roleId, form);
|
||||
showSuccess('نقش با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await roleApi.create(form);
|
||||
showSuccess('نقش جدید با موفقیت ایجاد شد');
|
||||
}
|
||||
router.push('/roles');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRole();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.permission-group {
|
||||
background: var(--surface-card);
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.group-header {
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-item {
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
&.perm-active {
|
||||
background-color: var(--primary-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.bg-green-50 {
|
||||
background-color: rgba(16, 185, 129, 0.08) !important;
|
||||
}
|
||||
.bg-orange-50 {
|
||||
background-color: rgba(249, 115, 22, 0.06) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!-- /src/views/roles/RoleListView.vue -->
|
||||
<template>
|
||||
<div class="role-list-view">
|
||||
<PageHeader :title="$t('roles.title')" :subtitle="$t('roles.subtitle')">
|
||||
<PermissionGate permission="roles:create">
|
||||
<Button :label="$t('roles.addRole')" icon="pi pi-shield" @click="$router.push('/roles/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="name" header="نام نقش" sortable>
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.name }}</span>
|
||||
<Tag v-if="data.isSystem" value="سیستمی" severity="danger" class="mr-2 text-xs" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="توضیحات">
|
||||
<template #body="{ data }">
|
||||
{{ data.description || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="permissionCount" header="تعداد مجوزها">
|
||||
<template #body="{ data }">
|
||||
<Badge :value="toPersianDigits(data.permissions?.length || 0)" severity="info" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 110px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="roles:update">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="warning"
|
||||
:disabled="data.isSystem"
|
||||
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'ویرایش'"
|
||||
@click="$router.push(`/roles/edit/${data._id || data.id}`)"
|
||||
/>
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="roles:delete">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="danger"
|
||||
:disabled="data.isSystem"
|
||||
v-tooltip.top="data.isSystem ? $t('roles.systemRoleWarning') : 'حذف'"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { roleApi } from '@/api/roleApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Badge from 'primevue/badge';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(roleApi.getAll);
|
||||
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedRole = ref(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const confirmDelete = (role) => {
|
||||
if (role.isSystem) return;
|
||||
selectedRole.value = role;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedRole.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await roleApi.delete(selectedRole.value._id || selectedRole.value.id);
|
||||
showSuccess('نقش با موفقیت حذف شد');
|
||||
deleteDialogVisible.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,247 @@
|
||||
<!-- /src/views/sessions/SessionFormView.vue -->
|
||||
<template>
|
||||
<div class="session-form-view w-full max-w-5xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? $t('sessions.editSession') : $t('sessions.addSession')"
|
||||
:subtitle="isEditMode ? 'ویرایش اطلاعات جلسه آموزشی' : 'تعریف جلسه جدید'"
|
||||
>
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
||||
<form @submit.prevent="handleSubmit" class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">انتخاب دوره *</label>
|
||||
<Dropdown
|
||||
v-model="form.course"
|
||||
:options="courses"
|
||||
optionLabel="title"
|
||||
optionValue="_id"
|
||||
placeholder="دوره را انتخاب کنید"
|
||||
class="w-full text-sm"
|
||||
filter
|
||||
@change="onCourseChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">کلاس *</label>
|
||||
<Dropdown
|
||||
v-model="form.class"
|
||||
:options="classes"
|
||||
optionLabel="name"
|
||||
optionValue="_id"
|
||||
placeholder="کلاس را انتخاب کنید"
|
||||
class="w-full text-sm"
|
||||
:disabled="!form.course"
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">استاد مدرس *</label>
|
||||
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="استاد را انتخاب کنید" class="w-full text-sm" filter />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('sessions.date') }} *</label>
|
||||
<DatePicker v-model="form.day" class="w-full text-sm" placeholder="1403/01/01" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.startTime') }} *</label>
|
||||
<InputText v-model="form.startTime" class="w-full text-sm" dir="ltr" placeholder="19:00" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('courses.endTime') }} *</label>
|
||||
<InputText v-model="form.endTime" class="w-full text-sm" dir="ltr" placeholder="20:30" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">وضعیت جلسه</label>
|
||||
<Dropdown v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">مکان برگزاری</label>
|
||||
<InputText v-model="form.place" class="w-full text-sm" placeholder="کلاس / آنلاین / آدرس" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('sessions.topic') }}</label>
|
||||
<InputText v-model="form.topic" class="w-full text-sm" placeholder="عنوان یا موضوع این جلسه" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">یادداشت جلسه</label>
|
||||
<Textarea v-model="form.note" rows="3" class="w-full text-sm" placeholder="یادداشت داخلی درباره این جلسه…" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
|
||||
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import moment from 'jalali-moment';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { classApi } from '@/api/classApi';
|
||||
import { professorApi } from '@/api/professorApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Dropdown from 'primevue/select';
|
||||
import DatePicker from 'vue3-persian-datetime-picker';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { toLatinDigits } = usePersianDate();
|
||||
|
||||
const sessionId = route.params.id;
|
||||
const isEditMode = computed(() => !!sessionId);
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const courses = ref([]);
|
||||
const classes = ref([]);
|
||||
const professors = ref([]);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'طبق برنامه', value: 'scheduled' },
|
||||
{ label: 'برگزارشده', value: 'held' },
|
||||
{ label: 'لغوشده', value: 'cancelled' }
|
||||
];
|
||||
|
||||
const form = reactive({
|
||||
course: null,
|
||||
class: null,
|
||||
professor: null,
|
||||
day: moment().locale('fa').format('jYYYY/jMM/jDD'),
|
||||
startTime: '19:00',
|
||||
endTime: '20:30',
|
||||
status: 'scheduled',
|
||||
topic: '',
|
||||
place: '',
|
||||
note: ''
|
||||
});
|
||||
|
||||
const toGregorianIso = (jalaliValue) => {
|
||||
if (!jalaliValue) return null;
|
||||
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
|
||||
const latin = toLatinDigits(String(jalaliValue));
|
||||
const m = moment(latin, 'jYYYY/jMM/jDD');
|
||||
return m.isValid() ? m.toDate().toISOString() : null;
|
||||
};
|
||||
|
||||
const toJalaliDisplay = (value) => {
|
||||
if (!value) return '';
|
||||
return moment(value).locale('fa').format('jYYYY/jMM/jDD');
|
||||
};
|
||||
|
||||
const loadClasses = async (courseId) => {
|
||||
if (!courseId) {
|
||||
classes.value = [];
|
||||
return;
|
||||
}
|
||||
const res = await classApi.getAll({ limit: 100, courseId });
|
||||
const data = res.data || res;
|
||||
classes.value = Array.isArray(data) ? data : (data.items || data.data || []);
|
||||
};
|
||||
|
||||
const onCourseChange = async () => {
|
||||
form.class = null;
|
||||
await loadClasses(form.course);
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cRes, pRes] = await Promise.all([
|
||||
courseApi.getAll({ limit: 100 }),
|
||||
professorApi.getAll({ limit: 100 })
|
||||
]);
|
||||
|
||||
const cData = cRes.data || cRes;
|
||||
courses.value = Array.isArray(cData) ? cData : (cData.items || cData.courses || cData.data || []);
|
||||
|
||||
const pData = pRes.data || pRes;
|
||||
professors.value = (Array.isArray(pData) ? pData : (pData.items || pData.professors || pData.data || [])).map((p) => ({
|
||||
...p,
|
||||
name: `${p.name || ''} ${p.surname || ''}`.trim()
|
||||
}));
|
||||
|
||||
if (sessionId) {
|
||||
const res = await sessionApi.getOne(sessionId);
|
||||
const data = res.data || res;
|
||||
const courseId = data.course?._id || data.course || null;
|
||||
await loadClasses(courseId);
|
||||
Object.assign(form, {
|
||||
course: courseId,
|
||||
class: data.class?._id || data.class || null,
|
||||
professor: data.professor?._id || data.professor || null,
|
||||
day: toJalaliDisplay(data.day || data.date),
|
||||
startTime: data.startTime || '19:00',
|
||||
endTime: data.endTime || '20:30',
|
||||
status: data.status || 'scheduled',
|
||||
topic: data.topic || '',
|
||||
place: data.place || '',
|
||||
note: data.note || ''
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.course) { showError('لطفا دوره را انتخاب کنید'); return; }
|
||||
if (!form.class) { showError('لطفا کلاس را انتخاب کنید'); return; }
|
||||
if (!form.professor) { showError('لطفا استاد را انتخاب کنید'); return; }
|
||||
if (!form.day) { showError('لطفا تاریخ جلسه را وارد کنید'); return; }
|
||||
if (!form.startTime || !form.endTime) { showError('ساعت شروع و پایان الزامی است'); return; }
|
||||
|
||||
const dayIso = toGregorianIso(form.day);
|
||||
if (!dayIso) { showError('تاریخ جلسه نامعتبر است'); return; }
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
course: form.course,
|
||||
class: form.class,
|
||||
professor: form.professor,
|
||||
day: dayIso,
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
status: form.status,
|
||||
topic: form.topic,
|
||||
place: form.place,
|
||||
note: form.note
|
||||
};
|
||||
|
||||
if (isEditMode.value) {
|
||||
await sessionApi.update(sessionId, payload);
|
||||
showSuccess('جلسه با موفقیت ویرایش شد');
|
||||
} else {
|
||||
await sessionApi.create(payload);
|
||||
showSuccess('جلسه جدید با موفقیت ایجاد شد');
|
||||
}
|
||||
router.push('/sessions');
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!-- /src/views/sessions/SessionListView.vue -->
|
||||
<template>
|
||||
<div class="session-list-view">
|
||||
<PageHeader :title="$t('sessions.title')" :subtitle="$t('sessions.subtitle')">
|
||||
<PermissionGate permission="sessions:create">
|
||||
<Button :label="$t('sessions.addSession')" icon="pi pi-plus" @click="$router.push('/sessions/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
selectable
|
||||
v-model:selection="selectedSessions"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<template #toolbar>
|
||||
<div v-if="selectedSessions.length" class="flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||
<Tag :value="`${toPersianDigits(selectedSessions.length)} انتخابشده`" severity="info" />
|
||||
<PermissionGate permission="sessions:update">
|
||||
<Select
|
||||
v-model="bulkStatus"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="تغییر وضعیت"
|
||||
class="text-sm"
|
||||
style="min-width: 10rem"
|
||||
/>
|
||||
<Button
|
||||
label="اعمال وضعیت"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:loading="isBulkUpdating"
|
||||
:disabled="!bulkStatus"
|
||||
@click="applyBulkStatus"
|
||||
/>
|
||||
</PermissionGate>
|
||||
<PermissionGate permission="sessions:delete">
|
||||
<Button
|
||||
label="حذف انتخابشدهها"
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
@click="confirmBulkDelete"
|
||||
/>
|
||||
</PermissionGate>
|
||||
<Button label="لغو انتخاب" text size="small" severity="secondary" @click="selectedSessions = []" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column field="topic" header="موضوع جلسه">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold text-color">{{ data.topic || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="course" header="عنوان دوره">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold text-color">{{ data.course?.title || data.courseTitle || '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="class" header="کلاس">
|
||||
<template #body="{ data }">
|
||||
{{ data.class?.name || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="professor" header="استاد مدرس">
|
||||
<template #body="{ data }">
|
||||
{{
|
||||
data.professor
|
||||
? `${data.professor.name || ''} ${data.professor.surname || ''}`.trim() || '—'
|
||||
: (data.professorName || '—')
|
||||
}}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="date" header="تاریخ برگزاری" sortable sortField="day">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.day || data.date) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="timeRange" header="زمان برگزاری">
|
||||
<template #body="{ data }">
|
||||
{{ data.startTime || '—' }} - {{ data.endTime || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status || 'scheduled'" type="session" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عملیات" style="width: 170px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="sessions:attendance">
|
||||
<Button
|
||||
icon="pi pi-check-square"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
severity="success"
|
||||
v-tooltip.top="$t('sessions.enterAttendance')"
|
||||
@click="$router.push(`/sessions/attendance/${data._id || data.id}`)"
|
||||
/>
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="sessions:update">
|
||||
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/sessions/edit/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="sessions:delete">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
:title="bulkDeleteMode ? 'حذف گروهی جلسات' : ''"
|
||||
:message="bulkDeleteMessage"
|
||||
@confirm="handleDeleteConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { sessionApi } from '@/api/sessionApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
import Select from 'primevue/select';
|
||||
|
||||
const { formatJalali, toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(sessionApi.getAll, {
|
||||
sortBy: 'day',
|
||||
sortOrder: 'asc'
|
||||
});
|
||||
|
||||
const selectedSessions = ref([]);
|
||||
const bulkStatus = ref(null);
|
||||
const isBulkUpdating = ref(false);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedSession = ref(null);
|
||||
const bulkDeleteMode = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
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 bulkDeleteMessage = computed(() => {
|
||||
if (bulkDeleteMode.value) {
|
||||
return `آیا از حذف ${toPersianDigits(selectedSessions.value.length)} جلسه انتخابشده اطمینان دارید؟ این عملیات قابل بازگشت نیست.`;
|
||||
}
|
||||
return 'آیا از حذف این جلسه اطمینان دارید؟ این عملیات قابل بازگشت نیست.';
|
||||
});
|
||||
|
||||
const confirmDelete = (session) => {
|
||||
bulkDeleteMode.value = false;
|
||||
selectedSession.value = session;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
if (!selectedSessions.value.length) return;
|
||||
bulkDeleteMode.value = true;
|
||||
selectedSession.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 (selectedSession.value) {
|
||||
await sessionApi.delete(selectedSession.value._id || selectedSession.value.id);
|
||||
showSuccess('جلسه با موفقیت حذف شد');
|
||||
}
|
||||
deleteDialogVisible.value = false;
|
||||
await loadData();
|
||||
} 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 loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isBulkUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,267 @@
|
||||
<!-- /src/views/users/UserDetailView.vue -->
|
||||
<template>
|
||||
<div class="user-detail-view" v-if="user">
|
||||
<PageHeader :title="`${user.name || ''} ${user.surname || ''}`" :subtitle="`کد ملی: ${toPersianDigits(user.nationalId)}`">
|
||||
<PermissionGate permission="users:update">
|
||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
|
||||
</PermissionGate>
|
||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/users')" />
|
||||
</PageHeader>
|
||||
|
||||
<!-- Main Profile Tabs -->
|
||||
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
|
||||
<TabList>
|
||||
<Tab value="0">{{ $t('users.tabInfo') }}</Tab>
|
||||
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
|
||||
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
|
||||
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
|
||||
<Tab value="4">{{ $t('users.tabCertificates') }}</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<!-- Tab 1: User Info -->
|
||||
<TabPanel value="0">
|
||||
<div class="grid p-3">
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">نام و نام خانوادگی</span>
|
||||
<span class="font-bold text-color text-base">{{ user.name }} {{ user.surname }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">کد ملی</span>
|
||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalId) }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">شماره همراه</span>
|
||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">پست الکترونیکی</span>
|
||||
<span class="font-bold text-color text-base">{{ user.email || '-' }}</span>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">پیامرسان ترجیحی</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
|
||||
:key="messenger"
|
||||
:value="messenger"
|
||||
severity="info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">نقش سیستم</span>
|
||||
<Tag :value="user.role?.name || 'دانشجو'" severity="secondary" />
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
|
||||
<span class="text-color text-sm">{{ user.address || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Tab 2: Enrolled Courses -->
|
||||
<TabPanel value="1">
|
||||
<div class="p-3">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="text-lg font-bold m-0">دورههای ثبتنام شده</h3>
|
||||
<PermissionGate permission="users:enroll">
|
||||
<Button label="ثبتنام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
<DataTable :value="enrolledCourses" class="p-datatable-sm text-sm">
|
||||
<Column field="title" header="عنوان دوره" />
|
||||
<Column field="type" header="نوع">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="price" header="شهریه">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="enrollDate" header="تاریخ ثبتنام">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.enrollDate || data.createdAt) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Tab 3: Sessions & Attendance -->
|
||||
<TabPanel value="2">
|
||||
<div class="p-3">
|
||||
<h3 class="text-lg font-bold mb-3">تاریخچه حضور و غیاب دانشجو</h3>
|
||||
<DataTable :value="attendances" class="p-datatable-sm text-sm">
|
||||
<Column field="sessionTitle" header="عنوان جلسه / دوره" />
|
||||
<Column field="date" header="تاریخ جلسه">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.date) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="وضعیت حضور">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="getAttendanceSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="note" header="یادداشت" />
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Tab 4: Payments -->
|
||||
<TabPanel value="3">
|
||||
<div class="p-3">
|
||||
<div class="flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="text-lg font-bold m-0">صورتحسابها و پرداختها</h3>
|
||||
<PermissionGate permission="payments:update">
|
||||
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
<DataTable :value="payments" class="p-datatable-sm text-sm">
|
||||
<Column field="amount" header="مبلغ کل">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="paidAmount" header="پرداختی">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.status" type="payment" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="dueDate" header="تاریخ سررسید">
|
||||
<template #body="{ data }">
|
||||
{{ formatJalali(data.dueDate) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- Tab 5: Certificates -->
|
||||
<TabPanel value="4">
|
||||
<div class="p-3">
|
||||
<h3 class="text-lg font-bold mb-3">گواهینامههای صادر شده دانشجو</h3>
|
||||
<CertificateUploader v-model="userCertificates" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<!-- Enroll Modal -->
|
||||
<Dialog v-model:visible="showEnrollModal" header="ثبتنام دانشجو در دوره جدید" modal :style="{ width: '450px' }">
|
||||
<div class="flex flex-column gap-3 py-2">
|
||||
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
|
||||
<Dropdown
|
||||
v-model="selectedCourseId"
|
||||
:options="allCourses"
|
||||
optionLabel="title"
|
||||
optionValue="_id"
|
||||
placeholder="دوره را انتخاب کنید"
|
||||
class="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="انصراف" text severity="secondary" @click="showEnrollModal = false" />
|
||||
<Button label="تایید ثبتنام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { userApi } from '@/api/userApi';
|
||||
import { courseApi } from '@/api/courseApi';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import CertificateUploader from '@/components/uploader/CertificateUploader.vue';
|
||||
import Tabs from 'primevue/tabs';
|
||||
import TabList from 'primevue/tablist';
|
||||
import Tab from 'primevue/tab';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Dropdown from 'primevue/select';
|
||||
|
||||
const route = useRoute();
|
||||
const userId = route.params.id;
|
||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const user = ref(null);
|
||||
const enrolledCourses = ref([]);
|
||||
const attendances = ref([]);
|
||||
const payments = ref([]);
|
||||
const userCertificates = ref([]);
|
||||
|
||||
const showEnrollModal = ref(false);
|
||||
const allCourses = ref([]);
|
||||
const selectedCourseId = ref(null);
|
||||
const isEnrolling = ref(false);
|
||||
|
||||
const getAttendanceSeverity = (status) => {
|
||||
if (status === 'حاضر' || status === 'present') return 'success';
|
||||
if (status === 'غایب' || status === 'absent') return 'danger';
|
||||
if (status === 'تاخیر' || status === 'late') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const fetchUserDetail = async () => {
|
||||
try {
|
||||
const res = await userApi.getOne(userId);
|
||||
user.value = res.data || res;
|
||||
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
|
||||
payments.value = user.value.payments || [];
|
||||
userCertificates.value = user.value.certificates || [];
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllCourses = async () => {
|
||||
try {
|
||||
const res = await courseApi.getAll({ limit: 100 });
|
||||
const data = res.data || res;
|
||||
allCourses.value = data.items || data.courses || data || [];
|
||||
} catch (e) {
|
||||
console.warn('Courses fetch error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnroll = async () => {
|
||||
if (!selectedCourseId.value) return;
|
||||
isEnrolling.value = true;
|
||||
try {
|
||||
await userApi.enroll(userId, { courseId: selectedCourseId.value });
|
||||
showSuccess('دانشجو با موفقیت در دوره ثبتنام شد');
|
||||
showEnrollModal.value = false;
|
||||
fetchUserDetail();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isEnrolling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserDetail();
|
||||
fetchAllCourses();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,276 @@
|
||||
<!-- /src/views/users/UserFormView.vue -->
|
||||
<template>
|
||||
<div class="user-form-view w-full max-w-4xl mx-auto">
|
||||
<PageHeader
|
||||
:title="isEditMode ? $t('users.editUser') : $t('users.addUser')"
|
||||
:subtitle="isEditMode ? 'ویرایش اطلاعات حساب کاربر' : 'ایجاد حساب کاربری جدید — نام کاربری و رمز عبور بهصورت خودکار ساخته و پیامک میشود'"
|
||||
>
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
||||
</PageHeader>
|
||||
|
||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm">
|
||||
<form @submit.prevent="handleSubmit" class="grid">
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.name') }} *</label>
|
||||
<InputText v-model.trim="form.name" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.surname') }} *</label>
|
||||
<InputText v-model.trim="form.surname" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.nationalId') }} *</label>
|
||||
<InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.phone') }} *</label>
|
||||
<InputText v-model.trim="form.phone" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.email') }}</label>
|
||||
<InputText v-model.trim="form.email" class="w-full text-sm" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.role') }} *</label>
|
||||
<Dropdown v-model="form.role" :options="roles" optionLabel="name" optionValue="_id" placeholder="انتخاب نقش" class="w-full text-sm" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
<label class="font-semibold text-sm">{{ $t('users.preferredMessenger') }}</label>
|
||||
<MultiSelect
|
||||
v-model="form.preferredMessenger"
|
||||
:options="messengerOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
display="chip"
|
||||
placeholder="اختیاری — چند مورد قابل انتخاب"
|
||||
class="w-full text-sm preferred-messenger-select"
|
||||
:showClear="false"
|
||||
:maxSelectedLabels="3"
|
||||
selectedItemsLabel="{0} مورد انتخاب شده"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
|
||||
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
|
||||
<InputText
|
||||
v-model.trim="form.username"
|
||||
class="w-full text-sm"
|
||||
dir="ltr"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
|
||||
<label class="font-semibold text-sm">رمز عبور جدید (اختیاری)</label>
|
||||
<InputText v-model="form.password" type="password" class="w-full text-sm" dir="ltr" placeholder="در صورت عدم تغییر خالی بگذارید" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditMode" class="col-12">
|
||||
<p class="text-sm text-color-secondary m-0 line-height-3">
|
||||
نام کاربری و رمز عبور ساده بهصورت خودکار ساخته میشود و از طریق پیامک برای کاربر ارسال میگردد. بعداً میتوان رمز را تغییر داد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
|
||||
<Button label="انصراف" text severity="secondary" @click="$router.push('/users')" />
|
||||
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="credentialsVisible"
|
||||
modal
|
||||
header="حساب ساخته شد"
|
||||
:style="{ width: '28rem' }"
|
||||
:closable="false"
|
||||
>
|
||||
<p class="text-sm mb-3 line-height-3">
|
||||
اطلاعات ورود ساخته شد و در صورت فعال بودن پیامک، برای کاربر ارسال میشود. این رمز فقط یکبار نمایش داده میشود:
|
||||
</p>
|
||||
<div class="flex flex-column gap-2 surface-ground p-3 border-round">
|
||||
<div class="flex justify-content-between gap-2">
|
||||
<span class="text-color-secondary text-sm">نام کاربری</span>
|
||||
<code class="font-semibold" dir="ltr">{{ createdCredentials.username }}</code>
|
||||
</div>
|
||||
<div class="flex justify-content-between gap-2">
|
||||
<span class="text-color-secondary text-sm">رمز عبور</span>
|
||||
<code class="font-semibold" dir="ltr">{{ createdCredentials.password }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="متوجه شدم" icon="pi pi-check" @click="finishAfterCreate" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { userApi } from '@/api/userApi';
|
||||
import { roleApi } from '@/api/roleApi';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dropdown from 'primevue/select';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const userId = route.params.id;
|
||||
const isEditMode = computed(() => !!userId);
|
||||
const isSubmitting = ref(false);
|
||||
const roles = ref([]);
|
||||
const credentialsVisible = ref(false);
|
||||
const createdCredentials = reactive({ username: '', password: '' });
|
||||
|
||||
const messengerOptions = [
|
||||
{ label: 'پیامک (SMS)', value: 'SMS' },
|
||||
{ label: 'بله', value: 'Bale' },
|
||||
{ label: 'واتساپ', value: 'WhatsApp' },
|
||||
{ label: 'تلگرام', value: 'Telegram' },
|
||||
{ label: 'ایمیل', value: 'Email' }
|
||||
];
|
||||
|
||||
const toMessengerList = (value) => {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? [...value] : [value];
|
||||
};
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
surname: '',
|
||||
nationalId: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: null,
|
||||
preferredMessenger: [],
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
surname: form.surname,
|
||||
nationalIdCode: form.nationalId,
|
||||
phoneNumber: form.phone,
|
||||
email: form.email || undefined,
|
||||
role: form.role || undefined
|
||||
};
|
||||
|
||||
if (form.preferredMessenger?.length) {
|
||||
payload.preferredMessenger = form.preferredMessenger;
|
||||
} else if (isEditMode.value) {
|
||||
payload.preferredMessenger = [];
|
||||
}
|
||||
|
||||
if (isEditMode.value && form.password) {
|
||||
payload.password = form.password;
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const res = await roleApi.getAll({ limit: 100 });
|
||||
const data = res.data || res;
|
||||
roles.value = Array.isArray(data)
|
||||
? data
|
||||
: (data.items || data.roles || data.data || []);
|
||||
} catch (e) {
|
||||
console.warn('Fetch roles error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUser = async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const res = await userApi.getOne(userId);
|
||||
const data = res.data || res;
|
||||
Object.assign(form, {
|
||||
name: data.name || '',
|
||||
surname: data.surname || '',
|
||||
nationalId: data.nationalIdCode || data.nationalId || '',
|
||||
phone: data.phoneNumber || data.phone || '',
|
||||
email: data.email || '',
|
||||
role: data.role?._id || data.role || null,
|
||||
preferredMessenger: toMessengerList(data.preferredMessenger),
|
||||
username: data.username || '',
|
||||
password: ''
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const finishAfterCreate = () => {
|
||||
credentialsVisible.value = false;
|
||||
router.push('/users');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name || !form.surname) {
|
||||
showError('نام و نام خانوادگی الزامی است');
|
||||
return;
|
||||
}
|
||||
if (!form.nationalId || !form.phone) {
|
||||
showError('کد ملی و شماره همراه الزامی است');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (isEditMode.value) {
|
||||
await userApi.update(userId, payload);
|
||||
showSuccess('اطلاعات کاربر با موفقیت ویرایش شد');
|
||||
router.push('/users');
|
||||
} else {
|
||||
const res = await userApi.create(payload);
|
||||
const data = res.data || res;
|
||||
const creds = data.generatedCredentials || {};
|
||||
createdCredentials.username = creds.username || data.username || '';
|
||||
createdCredentials.password = creds.password || '';
|
||||
showSuccess('کاربر جدید با موفقیت ایجاد شد');
|
||||
if (createdCredentials.username && createdCredentials.password) {
|
||||
credentialsVisible.value = true;
|
||||
} else {
|
||||
router.push('/users');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles();
|
||||
fetchUser();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preferred-messenger-select :deep(.p-multiselect-label) {
|
||||
padding-inline-end: 2.5rem;
|
||||
}
|
||||
|
||||
.preferred-messenger-select :deep(.p-chip),
|
||||
.preferred-messenger-select :deep(.p-multiselect-token) {
|
||||
margin-inline-end: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!-- /src/views/users/UserListView.vue -->
|
||||
<template>
|
||||
<div class="user-list-view">
|
||||
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
|
||||
<PermissionGate permission="users:create">
|
||||
<Button :label="$t('users.addUser')" icon="pi pi-user-plus" @click="$router.push('/users/create')" />
|
||||
</PermissionGate>
|
||||
</PageHeader>
|
||||
|
||||
<DataTableWrapper
|
||||
:items="items"
|
||||
:totalCount="totalCount"
|
||||
:page="queryParams.page"
|
||||
:limit="queryParams.limit"
|
||||
:sortBy="queryParams.sortBy"
|
||||
:sortOrder="queryParams.sortOrder"
|
||||
:loading="isLoading"
|
||||
@page-change="onPageChange"
|
||||
@sort-change="onSort"
|
||||
@search-change="onSearch"
|
||||
>
|
||||
<Column field="name" header="نام و نام خانوادگی" sortable>
|
||||
<template #body="{ data }">
|
||||
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
||||
{{ data.name }} {{ data.surname }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nationalId" header="کد ملی">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.nationalId) || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="phoneNumber" header="شماره همراه">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="preferredMessenger" header="پیامرسان ترجیحی">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1 justify-content-end">
|
||||
<Tag
|
||||
v-for="messenger in (Array.isArray(data.preferredMessenger) ? data.preferredMessenger : (data.preferredMessenger ? [data.preferredMessenger] : ['SMS']))"
|
||||
:key="messenger"
|
||||
:value="messenger"
|
||||
severity="info"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="activeCoursesCount" header="دورههای فعال">
|
||||
<template #body="{ data }">
|
||||
{{ toPersianDigits(data.activeCoursesCount || data.enrolledCourses?.length || 0) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="isActive" header="وضعیت">
|
||||
<template #body="{ data }">
|
||||
<StatusTag :status="data.isActive !== false" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="عمولیات" style="width: 130px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex align-items-center gap-1">
|
||||
<PermissionGate permission="users:read">
|
||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/users/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="users:update">
|
||||
<Button icon="pi pi-pencil" text rounded size="small" severity="warning" v-tooltip.top="'ویرایش'" @click="$router.push(`/users/edit/${data._id || data.id}`)" />
|
||||
</PermissionGate>
|
||||
|
||||
<PermissionGate permission="users:delete">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTableWrapper>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
v-model="deleteDialogVisible"
|
||||
:loading="isDeleting"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useDataTable } from '@/composables/useDataTable';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { userApi } from '@/api/userApi';
|
||||
import PageHeader from '@/components/common/PageHeader.vue';
|
||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||
import StatusTag from '@/components/common/StatusTag.vue';
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const { toPersianDigits } = usePersianDate();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
isLoading,
|
||||
queryParams,
|
||||
loadData,
|
||||
onPageChange,
|
||||
onSort,
|
||||
onSearch
|
||||
} = useDataTable(userApi.getAll);
|
||||
|
||||
const deleteDialogVisible = ref(false);
|
||||
const selectedUser = ref(null);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const confirmDelete = (user) => {
|
||||
selectedUser.value = user;
|
||||
deleteDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await userApi.delete(selectedUser.value._id || selectedUser.value.id);
|
||||
showSuccess('کاربر با موفقیت حذف شد');
|
||||
deleteDialogVisible.value = false;
|
||||
loadData();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user