fix: resolve course/class fields, surname i18n, optional nationalId, auto class end date, login styling, and mobile sidebar navigation

This commit is contained in:
2026-08-21 15:03:28 +03:30
parent b1c3dd6218
commit 86f1f9bab0
13 changed files with 348 additions and 117 deletions
+157 -21
View File
@@ -1,39 +1,109 @@
<!-- /src/components/layout/AppSidebar.vue --> <!-- /src/components/layout/AppSidebar.vue -->
<template> <template>
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full flex-shrink-0"> <!-- Desktop Sidebar -->
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color"> <aside
<i class="pi pi-graduation-cap text-primary text-3xl"></i> class="app-sidebar desktop-sidebar surface-card border-left-1 border-color flex flex-column h-full flex-shrink-0"
<span class="font-bold text-xl text-color">گام نو</span> :class="{ 'sidebar-collapsed': !uiStore.isSidebarOpen }"
</div> >
<div class="sidebar-inner flex flex-column h-full p-3">
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color flex-shrink-0">
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
<span class="font-bold text-xl text-color">گام نو</span>
</div>
<div class="menu-list flex-grow-1 overflow-y-auto"> <div class="menu-list flex-grow-1 overflow-y-auto">
<div v-for="group in menuGroups" :key="group.key" class="menu-group mb-3"> <div v-for="group in menuGroups" :key="group.key" class="menu-group mb-3">
<div class="menu-group-label px-3 py-2 text-xs font-bold text-muted uppercase"> <div class="menu-group-label px-3 py-2 text-xs font-bold text-muted uppercase">
{{ group.label }} {{ group.label }}
</div>
<router-link
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
:class="{ 'active-item': isMenuActive(item.to) }"
>
<i :class="item.icon" class="text-lg"></i>
<span class="font-medium text-sm">{{ item.label }}</span>
</router-link>
</div> </div>
<router-link
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
:class="{ 'active-item': isMenuActive(item.to) }"
>
<i :class="item.icon" class="text-lg"></i>
<span class="font-medium text-sm">{{ item.label }}</span>
</router-link>
</div> </div>
</div> </div>
</div> </aside>
<!-- Mobile Drawer & Backdrop -->
<Teleport to="body">
<Transition name="sidebar-fade">
<div
v-if="uiStore.isMobileSidebarOpen"
class="mobile-sidebar-backdrop"
@click="uiStore.closeMobileSidebar"
/>
</Transition>
<Transition name="sidebar-slide">
<div
v-if="uiStore.isMobileSidebarOpen"
class="mobile-sidebar-drawer surface-card flex flex-column p-3 shadow-8 border-left-1 border-color"
dir="rtl"
>
<div class="flex align-items-center justify-content-between px-2 py-3 mb-3 border-bottom-1 border-color flex-shrink-0">
<div class="logo flex align-items-center gap-2">
<i class="pi pi-graduation-cap text-primary text-2xl"></i>
<span class="font-bold text-lg text-color">گام نو</span>
</div>
<Button
icon="pi pi-times"
text
rounded
severity="secondary"
size="small"
@click="uiStore.closeMobileSidebar"
/>
</div>
<div class="menu-list flex-grow-1 overflow-y-auto">
<div v-for="group in menuGroups" :key="group.key" class="menu-group mb-3">
<div class="menu-group-label px-3 py-2 text-xs font-bold text-muted uppercase">
{{ group.label }}
</div>
<router-link
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="menu-item flex align-items-center gap-3 px-3 py-2 border-round text-color mb-1 transition-colors"
:class="{ 'active-item': isMenuActive(item.to) }"
@click="uiStore.closeMobileSidebar"
>
<i :class="item.icon" class="text-lg"></i>
<span class="font-medium text-sm">{{ item.label }}</span>
</router-link>
</div>
</div>
</div>
</Transition>
</Teleport>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
import { useUiStore } from '@/stores/uiStore';
import { PERMISSIONS } from '@/constants/permissions'; import { PERMISSIONS } from '@/constants/permissions';
import Button from 'primevue/button';
const route = useRoute(); const route = useRoute();
const permissionStore = usePermissionStore(); const permissionStore = usePermissionStore();
const uiStore = useUiStore();
// Automatically close mobile sidebar on navigation
watch(
() => route.path,
() => {
uiStore.closeMobileSidebar();
}
);
const menuGroups = computed(() => { const menuGroups = computed(() => {
const groups = [ const groups = [
@@ -124,6 +194,72 @@ function isMenuActive(to) {
max-width: 240px; max-width: 240px;
flex-shrink: 0; flex-shrink: 0;
min-height: 100vh; min-height: 100vh;
transition: width 0.25s ease, min-width 0.25s ease, max-width 0.25s ease, opacity 0.2s ease, padding 0.25s ease;
overflow: hidden;
.sidebar-inner {
width: 240px;
min-width: 240px;
}
&.sidebar-collapsed {
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
padding: 0 !important;
border-left: none !important;
border-right: none !important;
opacity: 0;
pointer-events: none;
}
}
@media (max-width: 991px) {
.desktop-sidebar {
display: none !important;
}
}
.mobile-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(3px);
z-index: 1100;
}
.mobile-sidebar-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
height: 100vh;
height: 100dvh;
z-index: 1101;
background: var(--surface-card);
box-sizing: border-box;
}
.sidebar-slide-enter-active,
.sidebar-slide-leave-active {
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-slide-enter-from,
.sidebar-slide-leave-to {
transform: translateX(100%);
}
.sidebar-fade-enter-active,
.sidebar-fade-leave-active {
transition: opacity 0.2s ease;
}
.sidebar-fade-enter-from,
.sidebar-fade-leave-to {
opacity: 0;
} }
.menu-group-label { .menu-group-label {
+4 -2
View File
@@ -15,12 +15,14 @@
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
min-height: 100vh !important; min-height: 100vh !important;
width: 100vw !important; min-height: 100dvh !important;
width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
box-sizing: border-box !important; box-sizing: border-box !important;
background: var(--bg-primary); background: var(--bg-primary);
margin: 0 !important; margin: 0 !important;
padding: 1.5rem !important; padding: 1rem !important;
position: relative !important; position: relative !important;
overflow-y: auto !important;
} }
</style> </style>
+1 -1
View File
@@ -4,7 +4,7 @@
<AppTopbar /> <AppTopbar />
<div class="layout-main-container flex flex-grow-1 min-w-0"> <div class="layout-main-container flex flex-grow-1 min-w-0">
<AppSidebar /> <AppSidebar />
<main class="layout-main flex-grow-1 p-4 overflow-x-hidden min-w-0"> <main class="layout-main flex-grow-1 p-3 sm:p-4 overflow-x-hidden min-w-0">
<AppBreadcrumb /> <AppBreadcrumb />
<router-view /> <router-view />
</main> </main>
+3
View File
@@ -51,6 +51,7 @@
"addUser": "افزودن کاربر جدید", "addUser": "افزودن کاربر جدید",
"editUser": "ویرایش کاربر", "editUser": "ویرایش کاربر",
"name": "نام", "name": "نام",
"surname": "نام خانوادگی",
"gender": "جنسیت", "gender": "جنسیت",
"nationalId": "کد ملی", "nationalId": "کد ملی",
"phone": "شماره همراه", "phone": "شماره همراه",
@@ -79,6 +80,8 @@
"subtitle": "لیست اساتید و مربیان موسسه", "subtitle": "لیست اساتید و مربیان موسسه",
"addProfessor": "افزودن استاد جدید", "addProfessor": "افزودن استاد جدید",
"editProfessor": "ویرایش اطلاعات استاد", "editProfessor": "ویرایش اطلاعات استاد",
"name": "نام",
"surname": "نام خانوادگی",
"bio": "بیوگرافی / رزومه", "bio": "بیوگرافی / رزومه",
"expertise": "تخصص ها", "expertise": "تخصص ها",
"cardNumber": "شماره کارت", "cardNumber": "شماره کارت",
+10
View File
@@ -15,6 +15,14 @@ export const useUiStore = defineStore('ui', () => {
isMobileSidebarOpen.value = !isMobileSidebarOpen.value; isMobileSidebarOpen.value = !isMobileSidebarOpen.value;
} }
function closeMobileSidebar() {
isMobileSidebarOpen.value = false;
}
function openMobileSidebar() {
isMobileSidebarOpen.value = true;
}
function setBreadcrumbs(items) { function setBreadcrumbs(items) {
breadcrumbs.value = items; breadcrumbs.value = items;
} }
@@ -25,6 +33,8 @@ export const useUiStore = defineStore('ui', () => {
breadcrumbs, breadcrumbs,
toggleSidebar, toggleSidebar,
toggleMobileSidebar, toggleMobileSidebar,
closeMobileSidebar,
openMobileSidebar,
setBreadcrumbs setBreadcrumbs
}; };
}); });
+43
View File
@@ -1,3 +1,5 @@
import moment from 'jalali-moment';
export const WEEKDAYS = [ export const WEEKDAYS = [
{ label: 'شنبه', value: 6 }, { label: 'شنبه', value: 6 },
{ label: 'یکشنبه', value: 0 }, { label: 'یکشنبه', value: 0 },
@@ -39,3 +41,44 @@ export function formatClassSchedule(cls) {
if (days && time) return `${days} | ${time}`; if (days && time) return `${days} | ${time}`;
return days || time || ''; return days || time || '';
} }
/**
* Calculates the class end date in Jalali (jYYYY/jMM/jDD) based on start date, selected days, and number of sessions.
* @param {string|Date} startDate - Jalali string (e.g. 1403/01/01) or ISO string / Date
* @param {number[]} days - Array of weekday indexes (0=Sun, 1=Mon, ..., 6=Sat)
* @param {number} numberOfSessions - Total number of sessions
* @returns {string} Jalali formatted date string, or empty string if not calculable
*/
export function calculateClassEndDate(startDate, days = [], numberOfSessions = 0) {
const countNeeded = parseInt(numberOfSessions, 10);
if (!startDate || isNaN(countNeeded) || countNeeded < 1) return '';
const validDays = Array.isArray(days)
? [...new Set(days.map(Number).filter((d) => d >= 0 && d <= 6))]
: [];
if (!validDays.length) return '';
const latinStartDate = String(startDate)
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d))
.trim();
let current = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]);
if (!current.isValid()) return '';
let count = 0;
let lastMatchingDate = null;
let safetyLimit = 365 * 5;
while (count < countNeeded && safetyLimit > 0) {
safetyLimit--;
if (validDays.includes(current.day())) {
count++;
lastMatchingDate = current.clone();
}
if (count >= countNeeded) break;
current.add(1, 'day');
}
return lastMatchingDate ? lastMatchingDate.locale('fa').format('jYYYY/jMM/jDD') : '';
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { formatClassDays, formatClassTime, formatClassSchedule } from './classSchedule.js'; import { formatClassDays, formatClassTime, formatClassSchedule, calculateClassEndDate } from './classSchedule.js';
describe('class schedule display', () => { describe('class schedule display', () => {
it('formats weekdays in Iran week order', () => { it('formats weekdays in Iran week order', () => {
@@ -19,4 +19,13 @@ describe('class schedule display', () => {
); );
assert.equal(formatClassSchedule({}), ''); assert.equal(formatClassSchedule({}), '');
}); });
it('calculates class end date from start date, days and number of sessions', () => {
// 1403/01/01 is Wednesday (day 3)
// If days are [6, 2] (Saturday, Tuesday), 2 sessions:
// Session 1: Saturday 1403/01/04
// Session 2: Tuesday 1403/01/07
const endDate = calculateClassEndDate('1403/01/01', [6, 2], 2);
assert.equal(endDate, '1403/01/07');
});
}); });
+71 -16
View File
@@ -3,44 +3,48 @@
<div class="login-card-container"> <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="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="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"> <div class="inline-flex align-items-center justify-content-center w-4rem h-4rem border-circle bg-primary-light text-primary mb-3 shadow-1">
<i class="pi pi-building text-3xl"></i> <i class="pi pi-building text-3xl"></i>
</div> </div>
<h1 class="text-2xl font-bold text-color m-0">{{ $t('auth.loginTitle') }}</h1> <h1 class="text-xl sm: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> <p class="text-muted text-xs sm:text-sm mt-2 mb-0">{{ $t('auth.welcome') }}</p>
</div> </div>
<form @submit.prevent="handleLogin" class="flex flex-column gap-3"> <form @submit.prevent="handleLogin" class="flex flex-column gap-3">
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label for="username" class="font-semibold text-sm text-color">{{ $t('auth.username') }}</label> <label for="username" class="font-semibold text-sm text-color">{{ $t('auth.username') }}</label>
<IconField iconPosition="right"> <div class="input-with-icon">
<InputIcon class="pi pi-user" /> <i class="pi pi-user field-icon"></i>
<InputText <InputText
id="username" id="username"
v-model.trim="form.username" v-model.trim="form.username"
class="w-full text-sm" class="w-full text-sm login-input"
:class="{ 'p-invalid': errors.username }" :class="{ 'p-invalid': errors.username }"
autocomplete="username" autocomplete="username"
placeholder="نام کاربری"
dir="ltr"
/> />
</IconField> </div>
<small v-if="errors.username" class="text-red-500 text-xs">{{ errors.username }}</small> <small v-if="errors.username" class="text-red-500 text-xs">{{ errors.username }}</small>
</div> </div>
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label for="password" class="font-semibold text-sm text-color">{{ $t('auth.password') }}</label> <label for="password" class="font-semibold text-sm text-color">{{ $t('auth.password') }}</label>
<IconField iconPosition="right"> <div class="input-with-icon">
<InputIcon class="pi pi-lock" /> <i class="pi pi-lock field-icon"></i>
<Password <Password
id="password" id="password"
v-model="form.password" v-model="form.password"
:feedback="false" :feedback="false"
toggleMask toggleMask
class="w-full text-sm" class="w-full text-sm login-password"
inputClass="w-full text-sm" inputClass="w-full text-sm login-input"
:class="{ 'p-invalid': errors.password }" :class="{ 'p-invalid': errors.password }"
autocomplete="current-password" autocomplete="current-password"
placeholder="رمز عبور"
dir="ltr"
/> />
</IconField> </div>
<small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small> <small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small>
</div> </div>
@@ -48,7 +52,7 @@
type="submit" type="submit"
:label="$t('auth.loginButton')" :label="$t('auth.loginButton')"
icon="pi pi-sign-in" icon="pi pi-sign-in"
class="w-full mt-2 font-bold" class="w-full mt-2 font-bold py-2 sm:py-3"
:loading="loading" :loading="loading"
/> />
</form> </form>
@@ -65,8 +69,6 @@ import { useI18n } from 'vue-i18n';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import Password from 'primevue/password'; import Password from 'primevue/password';
import Button from 'primevue/button'; import Button from 'primevue/button';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -127,12 +129,65 @@ const handleLogin = async () => {
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
width: 100% !important; width: 100% !important;
max-width: 28rem !important; max-width: 26rem !important;
margin: auto !important; margin: auto !important;
box-sizing: border-box !important;
} }
.login-view { .login-view {
width: 100% !important; width: 100% !important;
background: var(--surface-card); background: var(--surface-card);
box-sizing: border-box !important;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
width: 100%;
.field-icon {
position: absolute;
right: 0.85rem;
color: var(--text-color-secondary, #94a3b8);
font-size: 1rem;
z-index: 2;
pointer-events: none;
transition: color 0.2s ease;
}
&:focus-within .field-icon {
color: var(--primary-color, #0284c7);
}
:deep(.login-input) {
padding-right: 2.5rem !important;
padding-left: 0.85rem !important;
height: 2.75rem;
border-radius: 0.65rem;
}
:deep(.login-password) {
width: 100%;
.p-password-input {
padding-right: 2.5rem !important;
padding-left: 2.5rem !important;
height: 2.75rem;
border-radius: 0.65rem;
}
.p-password-toggle-mask-icon,
.p-password-mask-icon,
.p-password-unmask-icon {
position: absolute;
left: 0.85rem;
right: auto;
top: 50%;
transform: translateY(-50%);
z-index: 3;
cursor: pointer;
}
}
} }
</style> </style>
+25 -7
View File
@@ -116,13 +116,18 @@
</div> </div>
<div class="col-12 md:col-4 flex flex-column gap-2"> <div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label> <label class="font-semibold text-sm">تاریخ شروع *</label>
<DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" /> <DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div> </div>
<div class="col-12 md:col-4 flex flex-column gap-2"> <div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ پایان</label> <label class="font-semibold text-sm">تاریخ پایان (محاسبه خودکار)</label>
<DatePicker v-model="form.endDate" class="w-full text-sm" placeholder="1403/03/01" /> <InputText
:modelValue="calculatedEndDateDisplay"
disabled
class="w-full text-sm opacity-90 surface-100 font-semibold"
placeholder="بر اساس روزها و تعداد جلسات محاسبه می‌شود"
/>
</div> </div>
<div class="col-12 md:col-4 flex flex-column gap-2"> <div class="col-12 md:col-4 flex flex-column gap-2">
@@ -312,7 +317,7 @@ import Tag from 'primevue/tag';
import DataTable from 'primevue/datatable'; import DataTable from 'primevue/datatable';
import Column from 'primevue/column'; import Column from 'primevue/column';
import DatePicker from 'vue3-persian-datetime-picker'; import DatePicker from 'vue3-persian-datetime-picker';
import { WEEKDAYS } from '@/utils/classSchedule'; import { WEEKDAYS, calculateClassEndDate } from '@/utils/classSchedule';
import { resolveSessionDurationHours, calculateHourlyShare } from '@/utils/professorShare'; import { resolveSessionDurationHours, calculateHourlyShare } from '@/utils/professorShare';
const route = useRoute(); const route = useRoute();
@@ -375,6 +380,12 @@ const finalTuitionFee = computed(() => {
return Math.max(0, tuition - (Number(form.discount) || 0)); return Math.max(0, tuition - (Number(form.discount) || 0));
}); });
const calculatedEndDateDisplay = computed(() => {
const autoDate = calculateClassEndDate(form.startDate, form.days, form.numberOfSessions);
if (autoDate) return toPersianDigits(autoDate);
return form.endDate ? toPersianDigits(form.endDate) : '—';
});
const sessionDurationHoursPreview = computed(() => resolveSessionDurationHours({ const sessionDurationHoursPreview = computed(() => resolveSessionDurationHours({
hoursPerSection: selectedCourseHoursPerSection.value, hoursPerSection: selectedCourseHoursPerSection.value,
startTime: form.startTime, startTime: form.startTime,
@@ -515,6 +526,8 @@ const handleSubmit = async () => {
if (!form.course) { showError('دوره مرتبط الزامی است'); return; } if (!form.course) { showError('دوره مرتبط الزامی است'); return; }
isSubmitting.value = true; isSubmitting.value = true;
try { try {
const autoEndDate = calculateClassEndDate(form.startDate, form.days, form.numberOfSessions);
const effectiveEndDate = autoEndDate || form.endDate;
const payload = { const payload = {
name: form.name, name: form.name,
course: form.course, course: form.course,
@@ -526,7 +539,7 @@ const handleSubmit = async () => {
freeSpots: form.freeSpots, freeSpots: form.freeSpots,
showOnFrontend: form.showOnFrontend, showOnFrontend: form.showOnFrontend,
startDate: toGregorianIso(form.startDate), startDate: toGregorianIso(form.startDate),
endDate: toGregorianIso(form.endDate), endDate: toGregorianIso(effectiveEndDate),
days: form.days || [], days: form.days || [],
startTime: form.startTime || '', startTime: form.startTime || '',
endTime: form.endTime || '', endTime: form.endTime || '',
@@ -545,8 +558,13 @@ const handleSubmit = async () => {
} else { } else {
const res = await classApi.create(payload); const res = await classApi.create(payload);
const created = res.data || res; const created = res.data || res;
showSuccess('کلاس جدید با موفقیت ایجاد شد'); const newClassId = created._id || created.id || (created.data && (created.data._id || created.data.id));
router.push(`/classes/edit/${created._id || created.id}`); showSuccess('کلاس جدید با موفقیت ایجاد شد. اکنون می‌توانید جلسات کلاس را بسازید.');
if (newClassId) {
router.push(`/classes/edit/${newClassId}`);
} else {
router.push('/classes');
}
} }
} catch (err) { } catch (err) {
showError(err); showError(err);
+1 -5
View File
@@ -1,7 +1,7 @@
<!-- /src/views/courses/CourseDetailView.vue --> <!-- /src/views/courses/CourseDetailView.vue -->
<template> <template>
<div class="course-detail-view" v-if="course"> <div class="course-detail-view" v-if="course">
<PageHeader :title="course.title" :subtitle="`استاد: ${course.professor?.name || course.professor || '-'}`"> <PageHeader :title="course.title" :subtitle="course.type === 'Private' ? 'دوره خصوصی' : 'دوره عمومی'">
<PermissionGate permission="courses:update"> <PermissionGate permission="courses:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" /> <Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
</PermissionGate> </PermissionGate>
@@ -49,10 +49,6 @@
<span class="text-muted text-xs block mb-1">شهریه</span> <span class="text-muted text-xs block mb-1">شهریه</span>
<span class="font-bold text-color">{{ toPersianDigits(course.price?.toLocaleString()) }} تومان</span> <span class="font-bold text-color">{{ toPersianDigits(course.price?.toLocaleString()) }} تومان</span>
</div> </div>
<div>
<span class="text-muted text-xs block mb-1">ظرفیت</span>
<span class="font-bold text-color">{{ toPersianDigits(course.capacity) }} نفر</span>
</div>
<div> <div>
<span class="text-muted text-xs block mb-1">مدرک رسمی</span> <span class="text-muted text-xs block mb-1">مدرک رسمی</span>
<Tag :value="course.isOfficial ? 'دارای مدرک رسمی' : 'بدون مدرک رسمی'" :severity="course.isOfficial ? 'success' : 'secondary'" /> <Tag :value="course.isOfficial ? 'دارای مدرک رسمی' : 'بدون مدرک رسمی'" :severity="course.isOfficial ? 'success' : 'secondary'" />
+15 -52
View File
@@ -29,34 +29,15 @@
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small> <small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
</div> </div>
<div class="col-12 md:col-6 flex flex-column gap-2"> <div class="col-12 md:col-6 flex align-items-center gap-4 mt-2">
<label class="font-semibold text-sm">{{ $t('courses.professor') }}</label> <div class="flex align-items-center gap-2">
<Dropdown v-model="form.professor" :options="professors" optionLabel="name" optionValue="_id" placeholder="انتخاب استاد" class="w-full text-sm" /> <InputSwitch v-model="form.isOfficial" />
</div> <label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
</div>
<div class="col-12 md:col-3 flex flex-column gap-2"> <div class="flex align-items-center gap-2">
<label class="font-semibold text-sm">{{ $t('courses.capacity') }}</label> <InputSwitch v-model="form.showOnFrontend" />
<InputNumber v-model="form.capacity" class="w-full text-sm" :min="1" /> <label class="font-semibold text-sm">نمایش در وبسایت</label>
</div> </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>
<div class="col-12 flex flex-column gap-2"> <div class="col-12 flex flex-column gap-2">
@@ -167,7 +148,6 @@
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { courseApi } from '@/api/courseApi'; import { courseApi } from '@/api/courseApi';
import { professorApi } from '@/api/professorApi';
import { classApi } from '@/api/classApi'; import { classApi } from '@/api/classApi';
import { formatClassSchedule } from '@/utils/classSchedule'; import { formatClassSchedule } from '@/utils/classSchedule';
import { usePersianDate } from '@/composables/usePersianDate'; import { usePersianDate } from '@/composables/usePersianDate';
@@ -195,7 +175,6 @@ const isEditMode = computed(() => !!courseId);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const loadingClasses = ref(false); const loadingClasses = ref(false);
const professors = ref([]);
const courseClasses = ref([]); const courseClasses = ref([]);
const typeOptions = [ const typeOptions = [
@@ -208,10 +187,6 @@ const form = reactive({
description: '', description: '',
type: 'General', type: 'General',
price: 1500000, price: 1500000,
professor: null,
capacity: 20,
sectionCount: 12,
hoursPerSection: 1.5,
isOfficial: true, isOfficial: true,
showOnFrontend: true showOnFrontend: true
}); });
@@ -223,16 +198,6 @@ const errors = reactive({
price: '' 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 () => { const fetchClassesForCourse = async () => {
if (!courseId) return; if (!courseId) return;
loadingClasses.value = true; loadingClasses.value = true;
@@ -257,10 +222,6 @@ const fetchCourse = async () => {
description: data.description || '', description: data.description || '',
type: data.type || 'General', type: data.type || 'General',
price: data.price || 0, 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, isOfficial: data.isOfficial !== false,
showOnFrontend: data.showOnFrontend !== false showOnFrontend: data.showOnFrontend !== false
}); });
@@ -289,9 +250,12 @@ const handleSubmitCourse = async () => {
} else { } else {
const res = await courseApi.create(payload); const res = await courseApi.create(payload);
const newCourse = res.data || res; const newCourse = res.data || res;
showSuccess('دوره جدید با موفقیت ایجاد شد'); const newId = newCourse._id || newCourse.id || (newCourse.data && (newCourse.data._id || newCourse.data.id));
if (newCourse._id || newCourse.id) { showSuccess('دوره جدید با موفقیت ایجاد شد. اکنون می‌توانید کلاس جدید تعریف کنید.');
router.push(`/courses/edit/${newCourse._id || newCourse.id}`); if (newId) {
router.push(`/courses/edit/${newId}`);
} else {
router.push('/courses');
} }
} }
} catch (err) { } catch (err) {
@@ -302,7 +266,6 @@ const handleSubmitCourse = async () => {
}; };
onMounted(() => { onMounted(() => {
fetchProfessors();
fetchCourse(); fetchCourse();
fetchClassesForCourse(); fetchClassesForCourse();
}); });
+3 -7
View File
@@ -21,8 +21,8 @@
</div> </div>
<div class="col-12 md:col-6 flex flex-column gap-2"> <div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.nationalId') }} *</label> <label class="font-semibold text-sm">{{ $t('users.nationalId') }}</label>
<InputText v-model.trim="form.nationalIdCode" class="w-full text-sm" dir="ltr" /> <InputText v-model.trim="form.nationalIdCode" class="w-full text-sm" dir="ltr" placeholder="اختیاری — در صورت خالی بودن کد موقت ساخته می‌شود" />
</div> </div>
<div class="col-12 md:col-6 flex flex-column gap-2"> <div class="col-12 md:col-6 flex flex-column gap-2">
@@ -113,10 +113,6 @@ const handleSubmit = async () => {
showError('نام و نام خانوادگی الزامی است'); showError('نام و نام خانوادگی الزامی است');
return; return;
} }
if (!form.nationalIdCode) {
showError('کد ملی الزامی است');
return;
}
if (!form.phoneNumber) { if (!form.phoneNumber) {
showError('شماره همراه الزامی است'); showError('شماره همراه الزامی است');
return; return;
@@ -126,7 +122,7 @@ const handleSubmit = async () => {
const payload = { const payload = {
name: form.name, name: form.name,
surname: form.surname, surname: form.surname,
nationalIdCode: form.nationalIdCode, nationalIdCode: form.nationalIdCode ? form.nationalIdCode.trim() : undefined,
phoneNumber: form.phoneNumber, phoneNumber: form.phoneNumber,
email: form.email || undefined, email: form.email || undefined,
cardNumber: form.cardNumber || undefined, cardNumber: form.cardNumber || undefined,
+5 -5
View File
@@ -29,8 +29,8 @@
</div> </div>
<div class="col-12 md:col-6 flex flex-column gap-2"> <div class="col-12 md:col-6 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('users.nationalId') }} *</label> <label class="font-semibold text-sm">{{ $t('users.nationalId') }}</label>
<InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" /> <InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" placeholder="اختیاری — در صورت خالی بودن کد موقت ساخته می‌شود" />
</div> </div>
<div class="col-12 md:col-6 flex flex-column gap-2"> <div class="col-12 md:col-6 flex flex-column gap-2">
@@ -297,7 +297,7 @@ const buildPayload = () => {
const payload = { const payload = {
name: form.name, name: form.name,
gender: form.gender || undefined, gender: form.gender || undefined,
nationalIdCode: form.nationalId, nationalIdCode: form.nationalId ? form.nationalId.trim() : undefined,
phoneNumber: form.phone, phoneNumber: form.phone,
email: form.email || undefined, email: form.email || undefined,
role: form.role || undefined, role: form.role || undefined,
@@ -436,8 +436,8 @@ const handleSubmit = async () => {
showError('نام الزامی است'); showError('نام الزامی است');
return; return;
} }
if (!form.nationalId || !form.phone) { if (!form.phone) {
showError('کد ملی و شماره همراه الزامی است'); showError('شماره همراه الزامی است');
return; return;
} }