feat: improve bill flow, user columns, and admin notes UI

Select class before user when creating bills, fix national ID and registered classes display, remove course ratings, fix notification badge, and show Persian datetime in the topbar.
This commit is contained in:
2026-08-15 02:39:10 +03:30
parent 6b5549019c
commit b8e206caab
9 changed files with 228 additions and 38 deletions
+11 -3
View File
@@ -55,6 +55,10 @@
<label class="font-semibold text-sm">کلاس فعال است</label>
</div>
<div class="col-12">
<AdminNotesField v-model="form.adminNotes" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="goBack" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
@@ -129,6 +133,7 @@ import { userApi } from '@/api/userApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
@@ -167,7 +172,8 @@ const form = reactive({
tuitionFee: 0,
startDate: '',
endDate: '',
isActive: true
isActive: true,
adminNotes: []
});
const availableUsers = computed(() => {
@@ -242,7 +248,8 @@ const fetchData = async () => {
tuitionFee: data.tuitionFee || 0,
startDate: toJalaliDisplay(data.startDate),
endDate: toJalaliDisplay(data.endDate),
isActive: data.isActive !== false
isActive: data.isActive !== false,
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
});
students.value = data.students || [];
if (data.course?.sectionCount) defaultSessionCount.value = data.course.sectionCount;
@@ -277,7 +284,8 @@ const handleSubmit = async () => {
tuitionFee: form.tuitionFee,
startDate: toGregorianIso(form.startDate),
endDate: toGregorianIso(form.endDate),
isActive: form.isActive
isActive: form.isActive,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
};
if (isEditMode.value) {
await classApi.update(classId, payload);
-7
View File
@@ -39,12 +39,6 @@
</template>
</Column>
<Column field="rating" header="امتیاز">
<template #body="{ data }">
<Rating :modelValue="data.rating || 5" readonly :cancel="false" />
</template>
</Column>
<Column field="isOfficial" header="مدرک رسمی">
<template #body="{ data }">
<Tag :value="data.isOfficial ? $t('courses.official') : $t('courses.unofficial')" :severity="data.isOfficial ? 'success' : 'secondary'" />
@@ -98,7 +92,6 @@ import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Rating from 'primevue/rating';
const { toPersianDigits } = usePersianDate();
const { showSuccess, showError } = useToast();
+72 -14
View File
@@ -3,7 +3,7 @@
<div class="payment-list-view">
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
<PermissionGate permission="payments:create">
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="showCreateModal = true" />
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="openCreateModal" />
</PermissionGate>
</PageHeader>
@@ -80,24 +80,37 @@
<Dialog v-model:visible="showCreateModal" header="ایجاد صورتحساب جدید" modal :style="{ width: '480px' }">
<div class="flex flex-column gap-3 py-2">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
<Dropdown v-model="createForm.user" :options="usersList" optionLabel="fullName" optionValue="_id" filter placeholder="کاربر را انتخاب کنید" class="w-full text-sm" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس مربوطه *</label>
<label class="font-semibold text-sm">انتخاب یک یا چند کلاس *</label>
<MultiSelect
v-model="createForm.classes"
:options="classesList"
optionLabel="name"
optionValue="_id"
display="chip"
placeholder="کلاس‌ها را انتخاب کنید"
filter
placeholder="ابتدا کلاس‌ها را انتخاب کنید"
class="w-full text-sm"
@change="onClassesSelected"
/>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
<Dropdown
v-model="createForm.user"
:options="eligibleUsers"
optionLabel="fullName"
optionValue="_id"
filter
:placeholder="createForm.classes?.length ? 'دانشجوی ثبت‌نام‌شده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
class="w-full text-sm"
:disabled="!createForm.classes?.length"
/>
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
هیچ دانشجوی ثبت‌نام‌شده‌ای در کلاس‌های انتخاب‌شده یافت نشد
</small>
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ کل صورتحساب (تومان) *</label>
<InputNumber v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" />
@@ -123,7 +136,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { ref, reactive, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
@@ -174,11 +187,43 @@ const deleteDialogVisible = ref(false);
const selectedPayment = ref(null);
const isDeleting = ref(false);
const studentIdOf = (student) => String(student?._id || student?.id || student);
const eligibleUsers = computed(() => {
if (!createForm.classes?.length) return [];
const selectedIds = new Set(createForm.classes.map(String));
const selectedClasses = classesList.value.filter((c) =>
selectedIds.has(String(c._id || c.id))
);
if (!selectedClasses.length) return [];
// Intersection: user must be registered in every selected class
let eligibleIds = null;
selectedClasses.forEach((cls) => {
const ids = new Set((cls.students || []).map(studentIdOf));
if (eligibleIds === null) {
eligibleIds = ids;
} else {
eligibleIds = new Set([...eligibleIds].filter((id) => ids.has(id)));
}
});
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
});
const onClassesSelected = () => {
if (!createForm.classes || createForm.classes.length === 0) return;
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
createForm.user = null;
}
if (!createForm.classes || createForm.classes.length === 0) {
createForm.amount = 0;
return;
}
let totalFee = 0;
createForm.classes.forEach(classId => {
const c = classesList.value.find(item => (item._id || item.id) === classId);
createForm.classes.forEach((classId) => {
const c = classesList.value.find((item) => (item._id || item.id) === classId);
if (c) {
totalFee += (c.tuitionFee || c.course?.price || 0);
}
@@ -188,15 +233,27 @@ const onClassesSelected = () => {
}
};
const resetCreateForm = () => {
createForm.user = null;
createForm.classes = [];
createForm.amount = 0;
createForm.dueDate = '';
};
const openCreateModal = () => {
resetCreateForm();
showCreateModal.value = true;
};
const fetchDropdownData = async () => {
try {
const [uRes, cRes] = await Promise.all([
userApi.getAll({ limit: 150 }),
userApi.getAll({ limit: 200 }),
classApi.getAll({ limit: 100 })
]);
const uData = uRes.data || uRes;
const rawUsers = uData.items || uData.users || uData || [];
usersList.value = rawUsers.map(u => ({ ...u, fullName: u.name || '' }));
usersList.value = rawUsers.map((u) => ({ ...u, fullName: u.name || '' }));
const cData = cRes.data || cRes;
classesList.value = cData.items || cData.classes || cData || [];
@@ -206,6 +263,7 @@ const fetchDropdownData = async () => {
};
const handleCreatePayment = async () => {
if (!createForm.classes?.length) { showError('لطفا کلاس را انتخاب کنید'); return; }
if (!createForm.user) { showError('لطفا کاربر را انتخاب کنید'); return; }
if (!createForm.amount) { showError('لطفا مبلغ را وارد کنید'); return; }
if (!createForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
+11 -3
View File
@@ -78,6 +78,10 @@
<Textarea v-model="form.note" rows="3" class="w-full text-sm" placeholder="یادداشت داخلی درباره این جلسه…" />
</div>
<div class="col-12">
<AdminNotesField v-model="form.adminNotes" />
</div>
<div class="col-12 flex justify-content-end gap-2 mt-4 pt-3 border-top-1 border-color">
<Button label="انصراف" text severity="secondary" @click="$router.push('/sessions')" />
<Button type="submit" :label="$t('app.save')" icon="pi pi-check" :loading="isSubmitting" />
@@ -98,6 +102,7 @@ import { professorApi } from '@/api/professorApi';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/select';
@@ -133,7 +138,8 @@ const form = reactive({
status: 'scheduled',
topic: '',
place: '',
note: ''
note: '',
adminNotes: []
});
const toGregorianIso = (jalaliValue) => {
@@ -195,7 +201,8 @@ const fetchData = async () => {
status: data.status || 'scheduled',
topic: data.topic || '',
place: data.place || '',
note: data.note || ''
note: data.note || '',
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
});
}
} catch (err) {
@@ -225,7 +232,8 @@ const handleSubmit = async () => {
status: form.status,
topic: form.topic,
place: form.place,
note: form.note
note: form.note,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
};
if (isEditMode.value) {
+8 -2
View File
@@ -1,7 +1,7 @@
<!-- /src/views/users/UserDetailView.vue -->
<template>
<div class="user-detail-view" v-if="user">
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalId)}`">
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalIdCode || user.nationalId)}`">
<PermissionGate permission="users:update">
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
</PermissionGate>
@@ -31,7 +31,7 @@
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">کد ملی</span>
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalId) }}</span>
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId) }}</span>
</div>
<div class="col-12 sm:col-6 md:col-4 mb-3">
<span class="text-muted text-xs block mb-1">شماره همراه</span>
@@ -84,6 +84,12 @@
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
<span class="text-color text-sm">{{ user.address || '-' }}</span>
</div>
<div class="col-12 mb-3" v-if="user.adminNotes?.length">
<span class="text-muted text-xs block mb-1">یادداشتهای ادمین</span>
<ul class="m-0 pr-3 text-sm line-height-3">
<li v-for="(note, i) in user.adminNotes" :key="i">{{ note }}</li>
</ul>
</div>
</div>
</TabPanel>
+11 -3
View File
@@ -104,6 +104,10 @@
<InputText v-model.trim="form.address" class="w-full text-sm" />
</div>
<div class="col-12">
<AdminNotesField v-model="form.adminNotes" />
</div>
<div class="col-12 md:col-6 flex flex-column gap-2" v-if="isEditMode">
<label class="font-semibold text-sm">{{ $t('auth.username') }}</label>
<InputText
@@ -166,6 +170,7 @@ import { userApi } from '@/api/userApi';
import { roleApi } from '@/api/roleApi';
import { useToast } from '@/composables/useToast';
import PageHeader from '@/components/common/PageHeader.vue';
import AdminNotesField from '@/components/common/AdminNotesField.vue';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/select';
import MultiSelect from 'primevue/multiselect';
@@ -225,7 +230,8 @@ const form = reactive({
parentPhoneNumber: '',
address: '',
username: '',
password: ''
password: '',
adminNotes: []
});
const buildPayload = () => {
@@ -243,7 +249,8 @@ const buildPayload = () => {
postalCode: form.postalCode || undefined,
education: form.education || undefined,
parentPhoneNumber: form.parentPhoneNumber || undefined,
address: form.address || undefined
address: form.address || undefined,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
};
if (form.preferredMessenger?.length) {
@@ -295,7 +302,8 @@ const fetchUser = async () => {
parentPhoneNumber: data.parentPhoneNumber || '',
address: data.address || '',
username: data.username || '',
password: ''
password: '',
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
});
} catch (err) {
showError(err);
+4 -4
View File
@@ -27,9 +27,9 @@
</template>
</Column>
<Column field="nationalId" header="کد ملی">
<Column field="nationalIdCode" header="کد ملی">
<template #body="{ data }">
{{ toPersianDigits(data.nationalId) || '-' }}
{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}
</template>
</Column>
@@ -52,9 +52,9 @@
</template>
</Column>
<Column field="activeCoursesCount" header="دوره‌های فعال">
<Column field="registeredClassesCount" header="کلاس‌های ثبت‌نامی">
<template #body="{ data }">
{{ toPersianDigits(data.activeCoursesCount || data.enrolledCourses?.length || 0) }}
{{ toPersianDigits(data.registeredClassesCount ?? 0) }}
</template>
</Column>