fix: resolve course/class fields, surname i18n, optional nationalId, auto class end date, login styling, and mobile sidebar navigation
This commit is contained in:
@@ -1,39 +1,109 @@
|
||||
<!-- /src/components/layout/AppSidebar.vue -->
|
||||
<template>
|
||||
<div class="app-sidebar p-3 surface-card border-left-1 border-color flex flex-column h-full flex-shrink-0">
|
||||
<div class="logo flex align-items-center gap-3 px-2 py-3 mb-3 border-bottom-1 border-color">
|
||||
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
|
||||
<span class="font-bold text-xl text-color">گام نو</span>
|
||||
</div>
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside
|
||||
class="app-sidebar desktop-sidebar surface-card border-left-1 border-color flex flex-column h-full flex-shrink-0"
|
||||
:class="{ 'sidebar-collapsed': !uiStore.isSidebarOpen }"
|
||||
>
|
||||
<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 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 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) }"
|
||||
>
|
||||
<i :class="item.icon" class="text-lg"></i>
|
||||
<span class="font-medium text-sm">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const route = useRoute();
|
||||
const permissionStore = usePermissionStore();
|
||||
const uiStore = useUiStore();
|
||||
|
||||
// Automatically close mobile sidebar on navigation
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
uiStore.closeMobileSidebar();
|
||||
}
|
||||
);
|
||||
|
||||
const menuGroups = computed(() => {
|
||||
const groups = [
|
||||
@@ -124,6 +194,72 @@ function isMenuActive(to) {
|
||||
max-width: 240px;
|
||||
flex-shrink: 0;
|
||||
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 {
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-height: 100vh !important;
|
||||
width: 100vw !important;
|
||||
min-height: 100dvh !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
background: var(--bg-primary);
|
||||
margin: 0 !important;
|
||||
padding: 1.5rem !important;
|
||||
padding: 1rem !important;
|
||||
position: relative !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<AppTopbar />
|
||||
<div class="layout-main-container flex flex-grow-1 min-w-0">
|
||||
<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 />
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"addUser": "افزودن کاربر جدید",
|
||||
"editUser": "ویرایش کاربر",
|
||||
"name": "نام",
|
||||
"surname": "نام خانوادگی",
|
||||
"gender": "جنسیت",
|
||||
"nationalId": "کد ملی",
|
||||
"phone": "شماره همراه",
|
||||
@@ -79,6 +80,8 @@
|
||||
"subtitle": "لیست اساتید و مربیان موسسه",
|
||||
"addProfessor": "افزودن استاد جدید",
|
||||
"editProfessor": "ویرایش اطلاعات استاد",
|
||||
"name": "نام",
|
||||
"surname": "نام خانوادگی",
|
||||
"bio": "بیوگرافی / رزومه",
|
||||
"expertise": "تخصص ها",
|
||||
"cardNumber": "شماره کارت",
|
||||
|
||||
@@ -15,6 +15,14 @@ export const useUiStore = defineStore('ui', () => {
|
||||
isMobileSidebarOpen.value = !isMobileSidebarOpen.value;
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
isMobileSidebarOpen.value = false;
|
||||
}
|
||||
|
||||
function openMobileSidebar() {
|
||||
isMobileSidebarOpen.value = true;
|
||||
}
|
||||
|
||||
function setBreadcrumbs(items) {
|
||||
breadcrumbs.value = items;
|
||||
}
|
||||
@@ -25,6 +33,8 @@ export const useUiStore = defineStore('ui', () => {
|
||||
breadcrumbs,
|
||||
toggleSidebar,
|
||||
toggleMobileSidebar,
|
||||
closeMobileSidebar,
|
||||
openMobileSidebar,
|
||||
setBreadcrumbs
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import moment from 'jalali-moment';
|
||||
|
||||
export const WEEKDAYS = [
|
||||
{ label: 'شنبه', value: 6 },
|
||||
{ label: 'یکشنبه', value: 0 },
|
||||
@@ -39,3 +41,44 @@ export function formatClassSchedule(cls) {
|
||||
if (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') : '';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
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', () => {
|
||||
it('formats weekdays in Iran week order', () => {
|
||||
@@ -19,4 +19,13 @@ describe('class schedule display', () => {
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,44 +3,48 @@
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-color m-0">{{ $t('auth.loginTitle') }}</h1>
|
||||
<p class="text-muted text-xs sm:text-sm mt-2 mb-0">{{ $t('auth.welcome') }}</p>
|
||||
</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" />
|
||||
<div class="input-with-icon">
|
||||
<i class="pi pi-user field-icon"></i>
|
||||
<InputText
|
||||
id="username"
|
||||
v-model.trim="form.username"
|
||||
class="w-full text-sm"
|
||||
class="w-full text-sm login-input"
|
||||
:class="{ 'p-invalid': errors.username }"
|
||||
autocomplete="username"
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
<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" />
|
||||
<div class="input-with-icon">
|
||||
<i class="pi pi-lock field-icon"></i>
|
||||
<Password
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
class="w-full text-sm"
|
||||
inputClass="w-full text-sm"
|
||||
class="w-full text-sm login-password"
|
||||
inputClass="w-full text-sm login-input"
|
||||
:class="{ 'p-invalid': errors.password }"
|
||||
autocomplete="current-password"
|
||||
placeholder="رمز عبور"
|
||||
dir="ltr"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
<small v-if="errors.password" class="text-red-500 text-xs">{{ errors.password }}</small>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +52,7 @@
|
||||
type="submit"
|
||||
:label="$t('auth.loginButton')"
|
||||
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"
|
||||
/>
|
||||
</form>
|
||||
@@ -65,8 +69,6 @@ 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();
|
||||
@@ -127,12 +129,65 @@ const handleLogin = async () => {
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 28rem !important;
|
||||
max-width: 26rem !important;
|
||||
margin: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
width: 100% !important;
|
||||
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>
|
||||
|
||||
@@ -116,13 +116,18 @@
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</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" />
|
||||
<label class="font-semibold text-sm">تاریخ پایان (محاسبه خودکار)</label>
|
||||
<InputText
|
||||
:modelValue="calculatedEndDateDisplay"
|
||||
disabled
|
||||
class="w-full text-sm opacity-90 surface-100 font-semibold"
|
||||
placeholder="بر اساس روزها و تعداد جلسات محاسبه میشود"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 Column from 'primevue/column';
|
||||
import DatePicker from 'vue3-persian-datetime-picker';
|
||||
import { WEEKDAYS } from '@/utils/classSchedule';
|
||||
import { WEEKDAYS, calculateClassEndDate } from '@/utils/classSchedule';
|
||||
import { resolveSessionDurationHours, calculateHourlyShare } from '@/utils/professorShare';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -375,6 +380,12 @@ const finalTuitionFee = computed(() => {
|
||||
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({
|
||||
hoursPerSection: selectedCourseHoursPerSection.value,
|
||||
startTime: form.startTime,
|
||||
@@ -515,6 +526,8 @@ const handleSubmit = async () => {
|
||||
if (!form.course) { showError('دوره مرتبط الزامی است'); return; }
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const autoEndDate = calculateClassEndDate(form.startDate, form.days, form.numberOfSessions);
|
||||
const effectiveEndDate = autoEndDate || form.endDate;
|
||||
const payload = {
|
||||
name: form.name,
|
||||
course: form.course,
|
||||
@@ -526,7 +539,7 @@ const handleSubmit = async () => {
|
||||
freeSpots: form.freeSpots,
|
||||
showOnFrontend: form.showOnFrontend,
|
||||
startDate: toGregorianIso(form.startDate),
|
||||
endDate: toGregorianIso(form.endDate),
|
||||
endDate: toGregorianIso(effectiveEndDate),
|
||||
days: form.days || [],
|
||||
startTime: form.startTime || '',
|
||||
endTime: form.endTime || '',
|
||||
@@ -545,8 +558,13 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
const res = await classApi.create(payload);
|
||||
const created = res.data || res;
|
||||
showSuccess('کلاس جدید با موفقیت ایجاد شد');
|
||||
router.push(`/classes/edit/${created._id || created.id}`);
|
||||
const newClassId = created._id || created.id || (created.data && (created.data._id || created.data.id));
|
||||
showSuccess('کلاس جدید با موفقیت ایجاد شد. اکنون میتوانید جلسات کلاس را بسازید.');
|
||||
if (newClassId) {
|
||||
router.push(`/classes/edit/${newClassId}`);
|
||||
} else {
|
||||
router.push('/classes');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- /src/views/courses/CourseDetailView.vue -->
|
||||
<template>
|
||||
<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">
|
||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/courses/edit/${course._id || course.id}`)" />
|
||||
</PermissionGate>
|
||||
@@ -49,10 +49,6 @@
|
||||
<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'" />
|
||||
|
||||
@@ -29,34 +29,15 @@
|
||||
<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 class="col-12 md:col-6 flex align-items-center gap-4 mt-2">
|
||||
<div class="flex align-items-center gap-2">
|
||||
<InputSwitch v-model="form.isOfficial" />
|
||||
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
|
||||
</div>
|
||||
<div class="flex align-items-center gap-2">
|
||||
<InputSwitch v-model="form.showOnFrontend" />
|
||||
<label class="font-semibold text-sm">نمایش در وبسایت</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 flex flex-column gap-2">
|
||||
@@ -167,7 +148,6 @@
|
||||
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 { formatClassSchedule } from '@/utils/classSchedule';
|
||||
import { usePersianDate } from '@/composables/usePersianDate';
|
||||
@@ -195,7 +175,6 @@ const isEditMode = computed(() => !!courseId);
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const loadingClasses = ref(false);
|
||||
const professors = ref([]);
|
||||
const courseClasses = ref([]);
|
||||
|
||||
const typeOptions = [
|
||||
@@ -208,10 +187,6 @@ const form = reactive({
|
||||
description: '',
|
||||
type: 'General',
|
||||
price: 1500000,
|
||||
professor: null,
|
||||
capacity: 20,
|
||||
sectionCount: 12,
|
||||
hoursPerSection: 1.5,
|
||||
isOfficial: true,
|
||||
showOnFrontend: true
|
||||
});
|
||||
@@ -223,16 +198,6 @@ const errors = reactive({
|
||||
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;
|
||||
@@ -257,10 +222,6 @@ const fetchCourse = async () => {
|
||||
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
|
||||
});
|
||||
@@ -289,9 +250,12 @@ const handleSubmitCourse = async () => {
|
||||
} 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}`);
|
||||
const newId = newCourse._id || newCourse.id || (newCourse.data && (newCourse.data._id || newCourse.data.id));
|
||||
showSuccess('دوره جدید با موفقیت ایجاد شد. اکنون میتوانید کلاس جدید تعریف کنید.');
|
||||
if (newId) {
|
||||
router.push(`/courses/edit/${newId}`);
|
||||
} else {
|
||||
router.push('/courses');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -302,7 +266,6 @@ const handleSubmitCourse = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProfessors();
|
||||
fetchCourse();
|
||||
fetchClassesForCourse();
|
||||
});
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
</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.nationalIdCode" class="w-full text-sm" dir="ltr" />
|
||||
<label class="font-semibold text-sm">{{ $t('users.nationalId') }}</label>
|
||||
<InputText v-model.trim="form.nationalIdCode" class="w-full text-sm" dir="ltr" placeholder="اختیاری — در صورت خالی بودن کد موقت ساخته میشود" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
@@ -113,10 +113,6 @@ const handleSubmit = async () => {
|
||||
showError('نام و نام خانوادگی الزامی است');
|
||||
return;
|
||||
}
|
||||
if (!form.nationalIdCode) {
|
||||
showError('کد ملی الزامی است');
|
||||
return;
|
||||
}
|
||||
if (!form.phoneNumber) {
|
||||
showError('شماره همراه الزامی است');
|
||||
return;
|
||||
@@ -126,7 +122,7 @@ const handleSubmit = async () => {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
surname: form.surname,
|
||||
nationalIdCode: form.nationalIdCode,
|
||||
nationalIdCode: form.nationalIdCode ? form.nationalIdCode.trim() : undefined,
|
||||
phoneNumber: form.phoneNumber,
|
||||
email: form.email || undefined,
|
||||
cardNumber: form.cardNumber || undefined,
|
||||
|
||||
@@ -29,8 +29,8 @@
|
||||
</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" />
|
||||
<label class="font-semibold text-sm">{{ $t('users.nationalId') }}</label>
|
||||
<InputText v-model.trim="form.nationalId" class="w-full text-sm" dir="ltr" placeholder="اختیاری — در صورت خالی بودن کد موقت ساخته میشود" />
|
||||
</div>
|
||||
|
||||
<div class="col-12 md:col-6 flex flex-column gap-2">
|
||||
@@ -297,7 +297,7 @@ const buildPayload = () => {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
gender: form.gender || undefined,
|
||||
nationalIdCode: form.nationalId,
|
||||
nationalIdCode: form.nationalId ? form.nationalId.trim() : undefined,
|
||||
phoneNumber: form.phone,
|
||||
email: form.email || undefined,
|
||||
role: form.role || undefined,
|
||||
@@ -436,8 +436,8 @@ const handleSubmit = async () => {
|
||||
showError('نام الزامی است');
|
||||
return;
|
||||
}
|
||||
if (!form.nationalId || !form.phone) {
|
||||
showError('کد ملی و شماره همراه الزامی است');
|
||||
if (!form.phone) {
|
||||
showError('شماره همراه الزامی است');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user