From de87412bb128f2e8ca715fe7e88d129f6adbe097 Mon Sep 17 00:00:00 2001 From: Kavehhn174 Date: Sun, 23 Aug 2026 14:15:26 +0330 Subject: [PATCH] fix(dashboard): fix date conversion offset, calendar arrow directions, payment duplicate warning, attendance class filter, and session auto-generation --- package.json | 2 +- src/assets/styles/main.scss | 31 ++++++-- .../classes/ClassSessionsSection.vue | 19 ++--- src/composables/usePersianDate.js | 62 +++++++++++++-- src/composables/usePersianDate.test.js | 40 ++++++++++ src/views/attendances/AttendanceListView.vue | 58 +++++++++++--- src/views/classes/ClassDetailView.vue | 25 ++++--- src/views/classes/ClassFormView.vue | 23 +++++- src/views/courses/CourseFormView.vue | 7 +- src/views/payments/PaymentDetailView.vue | 10 ++- src/views/payments/PaymentListView.vue | 75 +++++++++++++------ 11 files changed, 279 insertions(+), 73 deletions(-) create mode 100644 src/composables/usePersianDate.test.js diff --git a/package.json b/package.json index e2bcd89..950f385 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "node --test src/utils/uploadResponse.test.js src/utils/classSchedule.test.js src/utils/paymentAmount.test.js src/utils/professorShare.test.js" + "test": "node --test src/composables/usePersianDate.test.js src/utils/uploadResponse.test.js src/utils/classSchedule.test.js src/utils/paymentAmount.test.js src/utils/professorShare.test.js" }, "dependencies": { "@fontsource/vazirmatn": "^5.3.0", diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss index 50911e9..9da3491 100644 --- a/src/assets/styles/main.scss +++ b/src/assets/styles/main.scss @@ -119,18 +119,40 @@ a { .vpd-controls { color: var(--text-primary) !important; - display: flex; - align-items: center; - justify-content: space-between; + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + direction: rtl !important; padding: 6px 10px; + .vpd-prev { + order: 1 !important; + + svg { + transform: rotate(180deg) !important; + } + } + + .vpd-month-label { + order: 2 !important; + flex: 1 !important; + text-align: center !important; + } + + .vpd-next { + order: 3 !important; + + svg { + transform: rotate(0deg) !important; + } + } + .vpd-next, .vpd-prev, button { display: inline-flex !important; align-items: center !important; justify-content: center !important; - transform: none !important; width: 32px !important; height: 32px !important; border-radius: 8px !important; @@ -144,7 +166,6 @@ a { width: 14px !important; height: 14px !important; fill: var(--text-primary) !important; - transform: none !important; path { fill: var(--text-primary) !important; diff --git a/src/components/classes/ClassSessionsSection.vue b/src/components/classes/ClassSessionsSection.vue index e0a9031..d3db1b4 100644 --- a/src/components/classes/ClassSessionsSection.vue +++ b/src/components/classes/ClassSessionsSection.vue @@ -294,12 +294,13 @@ const props = defineProps({ courseId: { type: [String, Object], required: true }, professorId: { type: [String, Object], default: null }, defaultSessionCount: { type: Number, default: 12 }, + startDate: { type: [String, Date], default: '' }, days: { type: Array, default: () => [] }, startTime: { type: String, default: '' }, endTime: { type: String, default: '' } }); -const { toPersianDigits, toLatinDigits, toGregorianIso, formatJalali, getTodayJalali } = usePersianDate(); +const { toPersianDigits, toLatinDigits, toGregorianIso, formatJalali, getTodayJalali, parseToMoment, toJalaliPickerValue } = usePersianDate(); const { showSuccess, showError } = useToast(); const sessions = ref([]); @@ -407,16 +408,17 @@ const weekdays = [ const autoForm = reactive({ sessionCount: props.defaultSessionCount || 12, - startDate: getTodayJalali(), + startDate: toJalaliPickerValue(props.startDate) || getTodayJalali(), startTime: props.startTime || '19:00', endTime: props.endTime || '20:30', - selectedDays: props.days?.length ? [...props.days] : [6, 2] + selectedDays: props.days?.length ? props.days.map(Number) : [6, 2] }); watch( - () => [props.days, props.startTime, props.endTime, props.defaultSessionCount], + () => [props.startDate, props.days, props.startTime, props.endTime, props.defaultSessionCount], () => { - if (props.days?.length) autoForm.selectedDays = [...props.days]; + if (props.startDate) autoForm.startDate = toJalaliPickerValue(props.startDate) || autoForm.startDate; + if (props.days?.length) autoForm.selectedDays = props.days.map(Number); if (props.startTime) autoForm.startTime = props.startTime; if (props.endTime) autoForm.endTime = props.endTime; if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount; @@ -519,9 +521,8 @@ const generateSessionsPreview = () => { } const list = []; - const latinStartDate = toLatinDigits(autoForm.startDate); - let currentMoment = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]); - if (!currentMoment.isValid()) { + const currentMoment = parseToMoment(autoForm.startDate); + if (!currentMoment || !currentMoment.isValid()) { showError('تاریخ شروع نامعتبر است'); return; } @@ -532,7 +533,7 @@ const generateSessionsPreview = () => { while (count < targetCount && safetyLimit > 0) { safetyLimit--; - if (autoForm.selectedDays.includes(currentMoment.day())) { + if (autoForm.selectedDays.map(Number).includes(currentMoment.day())) { count++; list.push({ sessionNumber: count, diff --git a/src/composables/usePersianDate.js b/src/composables/usePersianDate.js index a8f68c2..b2b89c0 100644 --- a/src/composables/usePersianDate.js +++ b/src/composables/usePersianDate.js @@ -19,23 +19,71 @@ export function usePersianDate() { return result; }; + const parseToMoment = (value) => { + if (!value) return null; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null; + return moment.utc(value.toISOString()); + } + const latin = toLatinDigits(String(value)).trim(); + if (!latin) return null; + + if (/^\d{4}-\d{2}-\d{2}T/.test(latin)) { + const m = moment.utc(latin); + return m.isValid() ? m : null; + } + + const match = latin.match(/^(\d{3,4})[./\-](\d{1,2})[./\-](\d{1,2})/); + if (match) { + const year = parseInt(match[1], 10); + const month = parseInt(match[2], 10); + const day = parseInt(match[3], 10); + if (year >= 1200 && year <= 1599) { + const normalized = `${year}/${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}`; + const m = moment(normalized, 'jYYYY/jMM/jDD'); + return m.isValid() ? m : null; + } + if (year >= 1900 && year <= 2200) { + const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const m = moment.utc(normalized, 'YYYY-MM-DD'); + return m.isValid() ? m : null; + } + } + + const m = moment.utc(latin); + return m.isValid() ? m : null; + }; + const formatJalali = (date, formatStr = 'jYYYY/jMM/jDD') => { if (!date) return '-'; try { - return toPersianDigits(moment(date).locale('fa').format(formatStr)); + const m = parseToMoment(date); + if (!m || !m.isValid()) return '-'; + return toPersianDigits(m.locale('fa').format(formatStr)); } catch (e) { return '-'; } }; const formatJalaliWithTime = (date) => { - return formatJalali(date, 'jYYYY/jMM/jDD - HH:mm'); + if (!date) return '-'; + try { + const latin = toLatinDigits(String(date)).trim(); + if (/^\d{4}-\d{2}-\d{2}T/.test(latin) || date instanceof Date) { + return toPersianDigits(moment(date).locale('fa').format('jYYYY/jMM/jDD - HH:mm')); + } + return formatJalali(date, 'jYYYY/jMM/jDD - HH:mm'); + } catch (e) { + return '-'; + } }; const toJalaliPickerValue = (value) => { if (!value) return ''; try { - return toLatinDigits(moment(value).locale('fa').format('jYYYY/jMM/jDD')); + const m = parseToMoment(value); + if (!m || !m.isValid()) return ''; + return toLatinDigits(m.locale('fa').format('jYYYY/jMM/jDD')); } catch (e) { return ''; } @@ -43,11 +91,10 @@ export function usePersianDate() { const toGregorianIso = (jalaliValue) => { if (!jalaliValue) return undefined; - if (jalaliValue instanceof Date) return jalaliValue.toISOString(); try { - const latin = toLatinDigits(String(jalaliValue)); - const m = moment(latin, 'jYYYY/jMM/jDD'); - return m.isValid() ? m.toDate().toISOString() : undefined; + const m = parseToMoment(jalaliValue); + if (!m || !m.isValid()) return undefined; + return `${m.format('YYYY-MM-DD')}T00:00:00.000Z`; } catch (e) { return undefined; } @@ -66,6 +113,7 @@ export function usePersianDate() { return { toPersianDigits, toLatinDigits, + parseToMoment, formatJalali, formatJalaliWithTime, toJalaliPickerValue, diff --git a/src/composables/usePersianDate.test.js b/src/composables/usePersianDate.test.js new file mode 100644 index 0000000..23cf092 --- /dev/null +++ b/src/composables/usePersianDate.test.js @@ -0,0 +1,40 @@ +// /src/composables/usePersianDate.test.js +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { usePersianDate } from './usePersianDate.js'; + +describe('usePersianDate', () => { + const { + toPersianDigits, + toLatinDigits, + toGregorianIso, + toJalaliPickerValue, + formatJalali, + getTodayJalali + } = usePersianDate(); + + it('converts Persian digits and Latin digits back and forth', () => { + assert.equal(toPersianDigits('1405/05/26'), '۱۴۰۵/۰۵/۲۶'); + assert.equal(toLatinDigits('۱۴۰۵/۰۵/۲۶'), '1405/05/26'); + }); + + it('converts Jalali date string to Gregorian ISO without shifting day', () => { + assert.equal(toGregorianIso('1405/05/26'), '2026-08-17T00:00:00.000Z'); + assert.equal(toGregorianIso('1405-05-26'), '2026-08-17T00:00:00.000Z'); + assert.equal(toGregorianIso('۱۴۰۵/۰۵/۲۶'), '2026-08-17T00:00:00.000Z'); + assert.equal(toGregorianIso('2026-08-17'), '2026-08-17T00:00:00.000Z'); + assert.equal(toGregorianIso('2026-08-17T00:00:00.000Z'), '2026-08-17T00:00:00.000Z'); + }); + + it('converts ISO or Gregorian date to Jalali picker value without 1-day offset', () => { + assert.equal(toJalaliPickerValue('2026-08-17T00:00:00.000Z'), '1405/05/26'); + assert.equal(toJalaliPickerValue('2026-08-17'), '1405/05/26'); + assert.equal(toJalaliPickerValue('1405/05/26'), '1405/05/26'); + assert.equal(toJalaliPickerValue('۱۴۰۵/۰۵/۲۶'), '1405/05/26'); + }); + + it('formats Jalali date for display', () => { + assert.equal(formatJalali('2026-08-17T00:00:00.000Z'), '۱۴۰۵/۰۵/۲۶'); + assert.equal(formatJalali('1405/05/26'), '۱۴۰۵/۰۵/۲۶'); + }); +}); diff --git a/src/views/attendances/AttendanceListView.vue b/src/views/attendances/AttendanceListView.vue index 5e71119..40a9332 100644 --- a/src/views/attendances/AttendanceListView.vue +++ b/src/views/attendances/AttendanceListView.vue @@ -47,14 +47,27 @@ @search-change="onSearch" > @@ -130,6 +143,7 @@ import { ref, onMounted } from 'vue'; import { useDataTable } from '@/composables/useDataTable'; import { usePersianDate } from '@/composables/usePersianDate'; import { sessionApi } from '@/api/sessionApi'; +import { classApi } from '@/api/classApi'; import PageHeader from '@/components/common/PageHeader.vue'; import DataTableWrapper from '@/components/common/DataTableWrapper.vue'; import StatusTag from '@/components/common/StatusTag.vue'; @@ -137,6 +151,7 @@ import Button from 'primevue/button'; import Column from 'primevue/column'; import Tag from 'primevue/tag'; import SelectButton from 'primevue/selectbutton'; +import Dropdown from 'primevue/dropdown'; const { formatJalali, toPersianDigits } = usePersianDate(); @@ -146,11 +161,14 @@ const filterOptions = [ { label: 'ثبت شده', value: 'recorded' } ]; +const selectedClassId = ref(null); +const classesList = ref([]); const summary = ref({ pending: 0, recorded: 0, total: 0 }); const fetchAttendanceSessions = (params) => sessionApi.getAll({ ...params, + class: selectedClassId.value || undefined, attendanceScope: params.attendanceScope || attendanceFilter.value }); @@ -185,11 +203,29 @@ const onAttendanceFilterChange = () => { loadData(); }; +const onClassFilterChange = () => { + queryParams.class = selectedClassId.value || undefined; + queryParams.page = 1; + loadData(); + loadSummary(); +}; + +const loadClasses = async () => { + try { + const res = await classApi.getAll({ limit: 500, sortBy: 'name', sortOrder: 'asc' }); + const data = res.data || res; + classesList.value = data.items || data.classes || data.data || data || []; + } catch (err) { + classesList.value = []; + } +}; + const loadSummary = async () => { try { + const classParam = selectedClassId.value || undefined; const [pendingRes, recordedRes] = await Promise.all([ - sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'pending', sortBy: 'day', sortOrder: 'desc' }), - sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'recorded', sortBy: 'day', sortOrder: 'desc' }) + sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'pending', class: classParam, sortBy: 'day', sortOrder: 'desc' }), + sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'recorded', class: classParam, sortBy: 'day', sortOrder: 'desc' }) ]); const pending = pendingRes.meta?.totalCount ?? 0; const recorded = recordedRes.meta?.totalCount ?? 0; @@ -204,6 +240,6 @@ const loadSummary = async () => { }; onMounted(async () => { - await Promise.all([loadData(), loadSummary()]); + await Promise.all([loadData(), loadSummary(), loadClasses()]); }); diff --git a/src/views/classes/ClassDetailView.vue b/src/views/classes/ClassDetailView.vue index 1f7d43f..be6c530 100644 --- a/src/views/classes/ClassDetailView.vue +++ b/src/views/classes/ClassDetailView.vue @@ -171,7 +171,10 @@
- + + + تومان +
@@ -181,14 +184,16 @@
- + + + تومان + مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان @@ -257,6 +262,8 @@ import DataTable from 'primevue/datatable'; import Column from 'primevue/column'; import Dialog from 'primevue/dialog'; import InputNumber from 'primevue/inputnumber'; +import InputGroup from 'primevue/inputgroup'; +import InputGroupAddon from 'primevue/inputgroupaddon'; import Checkbox from 'primevue/checkbox'; import Textarea from 'primevue/textarea'; import DatePicker from 'vue3-persian-datetime-picker'; diff --git a/src/views/classes/ClassFormView.vue b/src/views/classes/ClassFormView.vue index 2abc370..f39d9f1 100644 --- a/src/views/classes/ClassFormView.vue +++ b/src/views/classes/ClassFormView.vue @@ -37,7 +37,10 @@
- + + + تومان +
@@ -59,7 +62,10 @@
- + + + تومان +
@@ -92,12 +98,18 @@
- + + + تومان +
- + + + تومان +
@@ -273,6 +285,7 @@ :course-id="form.course ? String(form.course) : ''" :professor-id="form.professor ? String(form.professor) : null" :default-session-count="defaultSessionCount" + :start-date="form.startDate" :days="form.days" :start-time="form.startTime" :end-time="form.endTime" @@ -308,6 +321,8 @@ import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue'; import PermissionGate from '@/components/common/PermissionGate.vue'; import InputText from 'primevue/inputtext'; import InputNumber from 'primevue/inputnumber'; +import InputGroup from 'primevue/inputgroup'; +import InputGroupAddon from 'primevue/inputgroupaddon'; import Dropdown from 'primevue/select'; import MultiSelect from 'primevue/multiselect'; import InputSwitch from 'primevue/toggleswitch'; diff --git a/src/views/courses/CourseFormView.vue b/src/views/courses/CourseFormView.vue index ddbbb35..e9fb103 100644 --- a/src/views/courses/CourseFormView.vue +++ b/src/views/courses/CourseFormView.vue @@ -25,7 +25,10 @@
- + + + تومان + {{ errors.price }}
@@ -158,6 +161,8 @@ import StatusTag from '@/components/common/StatusTag.vue'; import TableSkeleton from '@/components/common/TableSkeleton.vue'; import InputText from 'primevue/inputtext'; import InputNumber from 'primevue/inputnumber'; +import InputGroup from 'primevue/inputgroup'; +import InputGroupAddon from 'primevue/inputgroupaddon'; import Dropdown from 'primevue/select'; import Textarea from 'primevue/textarea'; import InputSwitch from 'primevue/toggleswitch'; diff --git a/src/views/payments/PaymentDetailView.vue b/src/views/payments/PaymentDetailView.vue index 6e1a8ed..6a19a0b 100644 --- a/src/views/payments/PaymentDetailView.vue +++ b/src/views/payments/PaymentDetailView.vue @@ -152,7 +152,10 @@
- + + + تومان +
@@ -198,7 +201,10 @@
- + + + تومان +
diff --git a/src/views/payments/PaymentListView.vue b/src/views/payments/PaymentListView.vue index 1efb7ca..bc94d37 100644 --- a/src/views/payments/PaymentListView.vue +++ b/src/views/payments/PaymentListView.vue @@ -109,7 +109,7 @@ :placeholder="createForm.classes?.length ? 'دانشجوی ثبت‌نام‌شده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'" class="w-full text-sm" :disabled="!createForm.classes?.length" - @change="checkDuplicateInvoice" + @change="() => checkDuplicateInvoice()" /> هیچ دانشجوی ثبت‌نام‌شده‌ای در کلاس‌های انتخاب‌شده یافت نشد @@ -132,7 +132,10 @@
- + + + تومان +
@@ -142,14 +145,16 @@
- + + + تومان + مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان @@ -204,7 +209,10 @@
- + + + تومان +
@@ -214,14 +222,16 @@
- + + + تومان + مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان @@ -298,6 +308,8 @@ import Dialog from 'primevue/dialog'; import Dropdown from 'primevue/select'; import MultiSelect from 'primevue/multiselect'; import InputNumber from 'primevue/inputnumber'; +import InputGroup from 'primevue/inputgroup'; +import InputGroupAddon from 'primevue/inputgroupaddon'; import Checkbox from 'primevue/checkbox'; import Textarea from 'primevue/textarea'; import DatePicker from 'vue3-persian-datetime-picker'; @@ -391,18 +403,33 @@ const eligibleUsers = computed(() => { return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id))); }); +const extractId = (val) => { + if (!val) return null; + if (typeof val === 'object') { + if (val.value !== undefined && (typeof val.value === 'string' || typeof val.value === 'number')) { + return String(val.value); + } + return val._id ? String(val._id) : (val.id ? String(val.id) : null); + } + return String(val); +}; + const checkDuplicateInvoice = async (userIdVal = null) => { - const targetUser = userIdVal || createForm.user; - if (!targetUser || !createForm.classes || !createForm.classes.length) { + const targetUserId = extractId(userIdVal) || extractId(createForm.user); + if (!targetUserId || !createForm.classes || !createForm.classes.length) { duplicateWarning.value = null; return; } try { const classIds = Array.isArray(createForm.classes) - ? createForm.classes.map((c) => c?._id || c?.id || c).join(',') - : String(createForm.classes); + ? createForm.classes.map(extractId).filter(Boolean).join(',') + : (extractId(createForm.classes) || ''); + if (!classIds) { + duplicateWarning.value = null; + return; + } const res = await paymentApi.checkDuplicate({ - userId: String(targetUser?._id || targetUser?.id || targetUser), + userId: targetUserId, classes: classIds }); duplicateWarning.value = res.data?.data || res.data || res;