fix(dashboard): fix date conversion offset, calendar arrow directions, payment duplicate warning, attendance class filter, and session auto-generation

This commit is contained in:
2026-08-23 14:15:26 +03:30
parent 497b065b5c
commit de87412bb1
11 changed files with 279 additions and 73 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "dependencies": {
"@fontsource/vazirmatn": "^5.3.0", "@fontsource/vazirmatn": "^5.3.0",
+26 -5
View File
@@ -119,18 +119,40 @@ a {
.vpd-controls { .vpd-controls {
color: var(--text-primary) !important; color: var(--text-primary) !important;
display: flex; display: flex !important;
align-items: center; align-items: center !important;
justify-content: space-between; justify-content: space-between !important;
direction: rtl !important;
padding: 6px 10px; 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-next,
.vpd-prev, .vpd-prev,
button { button {
display: inline-flex !important; display: inline-flex !important;
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
transform: none !important;
width: 32px !important; width: 32px !important;
height: 32px !important; height: 32px !important;
border-radius: 8px !important; border-radius: 8px !important;
@@ -144,7 +166,6 @@ a {
width: 14px !important; width: 14px !important;
height: 14px !important; height: 14px !important;
fill: var(--text-primary) !important; fill: var(--text-primary) !important;
transform: none !important;
path { path {
fill: var(--text-primary) !important; fill: var(--text-primary) !important;
@@ -294,12 +294,13 @@ const props = defineProps({
courseId: { type: [String, Object], required: true }, courseId: { type: [String, Object], required: true },
professorId: { type: [String, Object], default: null }, professorId: { type: [String, Object], default: null },
defaultSessionCount: { type: Number, default: 12 }, defaultSessionCount: { type: Number, default: 12 },
startDate: { type: [String, Date], default: '' },
days: { type: Array, default: () => [] }, days: { type: Array, default: () => [] },
startTime: { type: String, default: '' }, startTime: { type: String, default: '' },
endTime: { 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 { showSuccess, showError } = useToast();
const sessions = ref([]); const sessions = ref([]);
@@ -407,16 +408,17 @@ const weekdays = [
const autoForm = reactive({ const autoForm = reactive({
sessionCount: props.defaultSessionCount || 12, sessionCount: props.defaultSessionCount || 12,
startDate: getTodayJalali(), startDate: toJalaliPickerValue(props.startDate) || getTodayJalali(),
startTime: props.startTime || '19:00', startTime: props.startTime || '19:00',
endTime: props.endTime || '20:30', endTime: props.endTime || '20:30',
selectedDays: props.days?.length ? [...props.days] : [6, 2] selectedDays: props.days?.length ? props.days.map(Number) : [6, 2]
}); });
watch( 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.startTime) autoForm.startTime = props.startTime;
if (props.endTime) autoForm.endTime = props.endTime; if (props.endTime) autoForm.endTime = props.endTime;
if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount; if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount;
@@ -519,9 +521,8 @@ const generateSessionsPreview = () => {
} }
const list = []; const list = [];
const latinStartDate = toLatinDigits(autoForm.startDate); const currentMoment = parseToMoment(autoForm.startDate);
let currentMoment = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]); if (!currentMoment || !currentMoment.isValid()) {
if (!currentMoment.isValid()) {
showError('تاریخ شروع نامعتبر است'); showError('تاریخ شروع نامعتبر است');
return; return;
} }
@@ -532,7 +533,7 @@ const generateSessionsPreview = () => {
while (count < targetCount && safetyLimit > 0) { while (count < targetCount && safetyLimit > 0) {
safetyLimit--; safetyLimit--;
if (autoForm.selectedDays.includes(currentMoment.day())) { if (autoForm.selectedDays.map(Number).includes(currentMoment.day())) {
count++; count++;
list.push({ list.push({
sessionNumber: count, sessionNumber: count,
+55 -7
View File
@@ -19,23 +19,71 @@ export function usePersianDate() {
return result; 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') => { const formatJalali = (date, formatStr = 'jYYYY/jMM/jDD') => {
if (!date) return '-'; if (!date) return '-';
try { 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) { } catch (e) {
return '-'; return '-';
} }
}; };
const formatJalaliWithTime = (date) => { 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) => { const toJalaliPickerValue = (value) => {
if (!value) return ''; if (!value) return '';
try { 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) { } catch (e) {
return ''; return '';
} }
@@ -43,11 +91,10 @@ export function usePersianDate() {
const toGregorianIso = (jalaliValue) => { const toGregorianIso = (jalaliValue) => {
if (!jalaliValue) return undefined; if (!jalaliValue) return undefined;
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
try { try {
const latin = toLatinDigits(String(jalaliValue)); const m = parseToMoment(jalaliValue);
const m = moment(latin, 'jYYYY/jMM/jDD'); if (!m || !m.isValid()) return undefined;
return m.isValid() ? m.toDate().toISOString() : undefined; return `${m.format('YYYY-MM-DD')}T00:00:00.000Z`;
} catch (e) { } catch (e) {
return undefined; return undefined;
} }
@@ -66,6 +113,7 @@ export function usePersianDate() {
return { return {
toPersianDigits, toPersianDigits,
toLatinDigits, toLatinDigits,
parseToMoment,
formatJalali, formatJalali,
formatJalaliWithTime, formatJalaliWithTime,
toJalaliPickerValue, toJalaliPickerValue,
+40
View File
@@ -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'), '۱۴۰۵/۰۵/۲۶');
});
});
+47 -11
View File
@@ -47,14 +47,27 @@
@search-change="onSearch" @search-change="onSearch"
> >
<template #toolbar> <template #toolbar>
<SelectButton <div class="flex align-items-center justify-content-between flex-wrap gap-2 w-full">
v-model="attendanceFilter" <SelectButton
:options="filterOptions" v-model="attendanceFilter"
optionLabel="label" :options="filterOptions"
optionValue="value" optionLabel="label"
class="text-sm" optionValue="value"
@change="onAttendanceFilterChange" class="text-sm"
/> @change="onAttendanceFilterChange"
/>
<Dropdown
v-model="selectedClassId"
:options="classesList"
optionLabel="name"
optionValue="_id"
placeholder="همه کلاس‌ها"
showClear
filter
class="w-16rem text-sm"
@change="onClassFilterChange"
/>
</div>
</template> </template>
<Column field="topic" header="موضوع جلسه"> <Column field="topic" header="موضوع جلسه">
@@ -130,6 +143,7 @@ import { ref, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable'; import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate'; import { usePersianDate } from '@/composables/usePersianDate';
import { sessionApi } from '@/api/sessionApi'; import { sessionApi } from '@/api/sessionApi';
import { classApi } from '@/api/classApi';
import PageHeader from '@/components/common/PageHeader.vue'; import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue'; import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import StatusTag from '@/components/common/StatusTag.vue'; import StatusTag from '@/components/common/StatusTag.vue';
@@ -137,6 +151,7 @@ import Button from 'primevue/button';
import Column from 'primevue/column'; import Column from 'primevue/column';
import Tag from 'primevue/tag'; import Tag from 'primevue/tag';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import Dropdown from 'primevue/dropdown';
const { formatJalali, toPersianDigits } = usePersianDate(); const { formatJalali, toPersianDigits } = usePersianDate();
@@ -146,11 +161,14 @@ const filterOptions = [
{ label: 'ثبت شده', value: 'recorded' } { label: 'ثبت شده', value: 'recorded' }
]; ];
const selectedClassId = ref(null);
const classesList = ref([]);
const summary = ref({ pending: 0, recorded: 0, total: 0 }); const summary = ref({ pending: 0, recorded: 0, total: 0 });
const fetchAttendanceSessions = (params) => const fetchAttendanceSessions = (params) =>
sessionApi.getAll({ sessionApi.getAll({
...params, ...params,
class: selectedClassId.value || undefined,
attendanceScope: params.attendanceScope || attendanceFilter.value attendanceScope: params.attendanceScope || attendanceFilter.value
}); });
@@ -185,11 +203,29 @@ const onAttendanceFilterChange = () => {
loadData(); 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 () => { const loadSummary = async () => {
try { try {
const classParam = selectedClassId.value || undefined;
const [pendingRes, recordedRes] = await Promise.all([ 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: 'pending', class: classParam, sortBy: 'day', sortOrder: 'desc' }),
sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'recorded', 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 pending = pendingRes.meta?.totalCount ?? 0;
const recorded = recordedRes.meta?.totalCount ?? 0; const recorded = recordedRes.meta?.totalCount ?? 0;
@@ -204,6 +240,6 @@ const loadSummary = async () => {
}; };
onMounted(async () => { onMounted(async () => {
await Promise.all([loadData(), loadSummary()]); await Promise.all([loadData(), loadSummary(), loadClasses()]);
}); });
</script> </script>
+16 -9
View File
@@ -171,7 +171,10 @@
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="class-bulk-amount">مبلغ شهریه هر دانشجو (تومان) *</label> <label class="font-semibold text-sm" for="class-bulk-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
<InputNumber inputId="class-bulk-amount" v-model="bulkForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" /> <InputGroup>
<InputNumber inputId="class-bulk-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
@@ -181,14 +184,16 @@
<div v-if="hasBulkDiscount" class="flex flex-column gap-2"> <div v-if="hasBulkDiscount" class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="class-bulk-discount-amount">مبلغ تخفیف (تومان)</label> <label class="font-semibold text-sm" for="class-bulk-discount-amount">مبلغ تخفیف (تومان)</label>
<InputNumber <InputGroup>
inputId="class-bulk-discount-amount" <InputNumber
v-model="bulkForm.discount" inputId="class-bulk-discount-amount"
class="w-full text-sm" v-model="bulkForm.discount"
suffix=" تومان" class="w-full text-sm"
:min="0" :min="0"
:max="bulkForm.amount || 0" :max="bulkForm.amount || 0"
/> />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
<small class="font-semibold text-color"> <small class="font-semibold text-color">
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
</small> </small>
@@ -257,6 +262,8 @@ import DataTable from 'primevue/datatable';
import Column from 'primevue/column'; import Column from 'primevue/column';
import Dialog from 'primevue/dialog'; import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import InputGroup from 'primevue/inputgroup';
import InputGroupAddon from 'primevue/inputgroupaddon';
import Checkbox from 'primevue/checkbox'; import Checkbox from 'primevue/checkbox';
import Textarea from 'primevue/textarea'; import Textarea from 'primevue/textarea';
import DatePicker from 'vue3-persian-datetime-picker'; import DatePicker from 'vue3-persian-datetime-picker';
+19 -4
View File
@@ -37,7 +37,10 @@
<div class="col-12 md:col-3 flex flex-column gap-2"> <div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">شهریه کلاس</label> <label class="font-semibold text-sm">شهریه کلاس</label>
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" suffix=" تومان" /> <InputGroup>
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="col-12 md:col-3 flex flex-column gap-2"> <div class="col-12 md:col-3 flex flex-column gap-2">
@@ -59,7 +62,10 @@
<div v-if="form.hasDiscount" class="grid mt-1"> <div v-if="form.hasDiscount" class="grid mt-1">
<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>
<InputNumber v-model="form.discount" :min="0" :max="form.tuitionFee || 0" class="w-full text-sm" suffix=" تومان" /> <InputGroup>
<InputNumber v-model="form.discount" :min="0" :max="form.tuitionFee || 0" class="w-full text-sm" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="col-12 md:col-8 flex align-items-end"> <div class="col-12 md:col-8 flex align-items-end">
<span class="font-semibold text-color text-sm"> <span class="font-semibold text-color text-sm">
@@ -92,12 +98,18 @@
<div v-else class="col-12 md:col-4 flex flex-column gap-2"> <div v-else 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>
<InputNumber v-model="form.payoutHourlyRate" :min="0" class="w-full text-sm" suffix=" تومان" /> <InputGroup>
<InputNumber v-model="form.payoutHourlyRate" :min="0" class="w-full text-sm" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</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>
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" suffix=" تومان" placeholder="مثلا: رفت‌وآمد، پذیرایی" /> <InputGroup>
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" placeholder="مثلا: رفت‌وآمد، پذیرایی" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
</div> </div>
@@ -273,6 +285,7 @@
:course-id="form.course ? String(form.course) : ''" :course-id="form.course ? String(form.course) : ''"
:professor-id="form.professor ? String(form.professor) : null" :professor-id="form.professor ? String(form.professor) : null"
:default-session-count="defaultSessionCount" :default-session-count="defaultSessionCount"
:start-date="form.startDate"
:days="form.days" :days="form.days"
:start-time="form.startTime" :start-time="form.startTime"
:end-time="form.endTime" :end-time="form.endTime"
@@ -308,6 +321,8 @@ import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue'; import PermissionGate from '@/components/common/PermissionGate.vue';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import InputGroup from 'primevue/inputgroup';
import InputGroupAddon from 'primevue/inputgroupaddon';
import Dropdown from 'primevue/select'; import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect'; import MultiSelect from 'primevue/multiselect';
import InputSwitch from 'primevue/toggleswitch'; import InputSwitch from 'primevue/toggleswitch';
+6 -1
View File
@@ -25,7 +25,10 @@
<div class="col-12 md:col-3 flex flex-column gap-2"> <div class="col-12 md:col-3 flex flex-column gap-2">
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label> <label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" suffix=" تومان" /> <InputGroup>
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
<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>
@@ -158,6 +161,8 @@ import StatusTag from '@/components/common/StatusTag.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue'; import TableSkeleton from '@/components/common/TableSkeleton.vue';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import InputGroup from 'primevue/inputgroup';
import InputGroupAddon from 'primevue/inputgroupaddon';
import Dropdown from 'primevue/select'; import Dropdown from 'primevue/select';
import Textarea from 'primevue/textarea'; import Textarea from 'primevue/textarea';
import InputSwitch from 'primevue/toggleswitch'; import InputSwitch from 'primevue/toggleswitch';
+8 -2
View File
@@ -152,7 +152,10 @@
<div class="flex flex-column gap-3 py-2"> <div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label> <label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
<InputNumber v-model="trxForm.amount" class="w-full text-sm" suffix=" تومان" :min="1" /> <InputGroup>
<InputNumber v-model="trxForm.amount" class="w-full text-sm" :min="1" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت تراکنش *</label> <label class="font-semibold text-sm">وضعیت تراکنش *</label>
@@ -198,7 +201,10 @@
<div class="flex flex-column gap-3 py-2"> <div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ (تومان) *</label> <label class="font-semibold text-sm">مبلغ (تومان) *</label>
<InputNumber v-model="editForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" /> <InputGroup>
<InputNumber v-model="editForm.amount" class="w-full text-sm" :min="0" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm">وضعیت تراکنش *</label> <label class="font-semibold text-sm">وضعیت تراکنش *</label>
+51 -24
View File
@@ -109,7 +109,7 @@
:placeholder="createForm.classes?.length ? 'دانشجوی ثبت‌نام‌شده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'" :placeholder="createForm.classes?.length ? 'دانشجوی ثبت‌نام‌شده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
class="w-full text-sm" class="w-full text-sm"
:disabled="!createForm.classes?.length" :disabled="!createForm.classes?.length"
@change="checkDuplicateInvoice" @change="() => checkDuplicateInvoice()"
/> />
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500"> <small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
هیچ دانشجوی ثبت‌نام‌شده‌ای در کلاس‌های انتخاب‌شده یافت نشد هیچ دانشجوی ثبت‌نام‌شده‌ای در کلاس‌های انتخاب‌شده یافت نشد
@@ -132,7 +132,10 @@
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label> <label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" /> <InputGroup>
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" :min="0" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
@@ -142,14 +145,16 @@
<div v-if="hasDiscount" class="flex flex-column gap-2"> <div v-if="hasDiscount" class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label> <label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label>
<InputNumber <InputGroup>
inputId="invoice-discount-amount" <InputNumber
v-model="createForm.discount" inputId="invoice-discount-amount"
class="w-full text-sm" v-model="createForm.discount"
suffix=" تومان" class="w-full text-sm"
:min="0" :min="0"
:max="createForm.amount || 0" :max="createForm.amount || 0"
/> />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
<small class="font-semibold text-color"> <small class="font-semibold text-color">
مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان
</small> </small>
@@ -204,7 +209,10 @@
<div class="flex flex-column gap-2"> <div class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="bulk-invoice-amount">مبلغ شهریه هر دانشجو (تومان) *</label> <label class="font-semibold text-sm" for="bulk-invoice-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
<InputNumber inputId="bulk-invoice-amount" v-model="bulkForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" /> <InputGroup>
<InputNumber inputId="bulk-invoice-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
</div> </div>
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">
@@ -214,14 +222,16 @@
<div v-if="hasBulkDiscount" class="flex flex-column gap-2"> <div v-if="hasBulkDiscount" class="flex flex-column gap-2">
<label class="font-semibold text-sm" for="bulk-invoice-discount-amount">مبلغ تخفیف (تومان)</label> <label class="font-semibold text-sm" for="bulk-invoice-discount-amount">مبلغ تخفیف (تومان)</label>
<InputNumber <InputGroup>
inputId="bulk-invoice-discount-amount" <InputNumber
v-model="bulkForm.discount" inputId="bulk-invoice-discount-amount"
class="w-full text-sm" v-model="bulkForm.discount"
suffix=" تومان" class="w-full text-sm"
:min="0" :min="0"
:max="bulkForm.amount || 0" :max="bulkForm.amount || 0"
/> />
<InputGroupAddon>تومان</InputGroupAddon>
</InputGroup>
<small class="font-semibold text-color"> <small class="font-semibold text-color">
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
</small> </small>
@@ -298,6 +308,8 @@ import Dialog from 'primevue/dialog';
import Dropdown from 'primevue/select'; import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect'; import MultiSelect from 'primevue/multiselect';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import InputGroup from 'primevue/inputgroup';
import InputGroupAddon from 'primevue/inputgroupaddon';
import Checkbox from 'primevue/checkbox'; import Checkbox from 'primevue/checkbox';
import Textarea from 'primevue/textarea'; import Textarea from 'primevue/textarea';
import DatePicker from 'vue3-persian-datetime-picker'; 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))); 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 checkDuplicateInvoice = async (userIdVal = null) => {
const targetUser = userIdVal || createForm.user; const targetUserId = extractId(userIdVal) || extractId(createForm.user);
if (!targetUser || !createForm.classes || !createForm.classes.length) { if (!targetUserId || !createForm.classes || !createForm.classes.length) {
duplicateWarning.value = null; duplicateWarning.value = null;
return; return;
} }
try { try {
const classIds = Array.isArray(createForm.classes) const classIds = Array.isArray(createForm.classes)
? createForm.classes.map((c) => c?._id || c?.id || c).join(',') ? createForm.classes.map(extractId).filter(Boolean).join(',')
: String(createForm.classes); : (extractId(createForm.classes) || '');
if (!classIds) {
duplicateWarning.value = null;
return;
}
const res = await paymentApi.checkDuplicate({ const res = await paymentApi.checkDuplicate({
userId: String(targetUser?._id || targetUser?.id || targetUser), userId: targetUserId,
classes: classIds classes: classIds
}); });
duplicateWarning.value = res.data?.data || res.data || res; duplicateWarning.value = res.data?.data || res.data || res;