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,7 +1,12 @@
|
|||||||
<!-- /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
|
||||||
|
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>
|
<i class="pi pi-graduation-cap text-primary text-3xl"></i>
|
||||||
<span class="font-bold text-xl text-color">گام نو</span>
|
<span class="font-bold text-xl text-color">گام نو</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +29,81 @@
|
|||||||
</div>
|
</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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": "شماره کارت",
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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') : '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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'" />
|
||||||
|
|||||||
@@ -29,35 +29,16 @@
|
|||||||
<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" />
|
|
||||||
</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" />
|
<InputSwitch v-model="form.isOfficial" />
|
||||||
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
|
<label class="font-semibold text-sm">{{ $t('courses.isOfficial') }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
<div class="col-12 md:col-3 flex align-items-center gap-2 mt-4">
|
|
||||||
<InputSwitch v-model="form.showOnFrontend" />
|
<InputSwitch v-model="form.showOnFrontend" />
|
||||||
<label class="font-semibold text-sm">نمایش در وبسایت</label>
|
<label class="font-semibold text-sm">نمایش در وبسایت</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12 flex flex-column gap-2">
|
<div class="col-12 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">{{ $t('courses.description') }}</label>
|
<label class="font-semibold text-sm">{{ $t('courses.description') }}</label>
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user