Add professor share & financial reports UI, expense management page

Adds payout configuration fields (percentage/hourly + per-session
expense allowance) with a live payout preview to the class form,
a new Financial Reports page (per-class and date-range/monthly tabs,
per-session drill-down dialog) with reusable stat cards, and an
Institutional Expenses management page. Registers routes, sidebar
navigation, and permission constants for the new financial_reports
and expenses permissions.
This commit is contained in:
2026-08-21 12:19:18 +03:30
parent ee3bc2b856
commit 26762e4dac
13 changed files with 1055 additions and 3 deletions
+9
View File
@@ -2,6 +2,15 @@
<template>
<div class="class-detail-view" v-if="classData">
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`">
<PermissionGate permission="financial_reports:read">
<Button
label="گزارش مالی کلاس"
icon="pi pi-chart-line"
text
severity="info"
@click="$router.push(`/financial-reports?classId=${classId}`)"
/>
</PermissionGate>
<Button label="ویرایش" icon="pi pi-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
</PageHeader>
+83 -1
View File
@@ -70,6 +70,51 @@
</div>
</div>
<div class="col-12">
<div class="payout-fields p-3 border-1 border-color border-round">
<h3 class="text-base font-bold text-color m-0 mb-3">سهم و هزینه استاد</h3>
<div class="grid">
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">مدل محاسبه سهم استاد</label>
<Dropdown
v-model="form.payoutType"
:options="payoutTypeOptions"
optionLabel="label"
optionValue="value"
class="w-full text-sm"
/>
</div>
<div v-if="form.payoutType === 'percentage'" class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">درصد سهم استاد از درآمد کلاس</label>
<InputNumber v-model="form.payoutPercentage" :min="0" :max="100" class="w-full text-sm" suffix=" %" />
</div>
<div v-else class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">نرخ ساعتی استاد</label>
<InputNumber v-model="form.payoutHourlyRate" :min="0" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">هزینه جانبی هر جلسه</label>
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" suffix=" تومان" placeholder="مثلا: رفت‌وآمد، پذیرایی" />
</div>
</div>
<div class="mt-3 p-2 border-round surface-100 flex flex-column gap-1">
<span class="text-xs text-muted">
مدت هر جلسه برای محاسبه ساعتی:
<strong class="text-color">{{ toPersianDigits(sessionDurationHoursPreview) }} ساعت</strong>
<template v-if="!sessionDurationHoursPreview"> (از ساعت شروع/پایان کلاس یا دوره تعیین می‌شود)</template>
</span>
<span class="text-xs text-muted" v-if="form.payoutType === 'hourly'">
سهم تخمینی هر جلسه: <strong class="text-color">{{ toPersianDigits(sessionShareEstimate.toLocaleString()) }} تومان</strong>
(نرخ ساعتی × مدت جلسه)
</span>
</div>
</div>
</div>
<div class="col-12 md:col-4 flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ شروع</label>
<DatePicker v-model="form.startDate" class="w-full text-sm" placeholder="1403/01/01" />
@@ -240,7 +285,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment';
import { classApi } from '@/api/classApi';
@@ -268,6 +313,7 @@ import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import DatePicker from 'vue3-persian-datetime-picker';
import { WEEKDAYS } from '@/utils/classSchedule';
import { resolveSessionDurationHours, calculateHourlyShare } from '@/utils/professorShare';
const route = useRoute();
const router = useRouter();
@@ -309,10 +355,19 @@ const form = reactive({
startTime: '',
endTime: '',
numberOfSessions: null,
payoutType: 'percentage',
payoutPercentage: 0,
payoutHourlyRate: 0,
extraExpensePerSession: 0,
isActive: true,
adminNotes: []
});
const weekdays = WEEKDAYS;
const payoutTypeOptions = [
{ label: 'درصدی از درآمد کلاس', value: 'percentage' },
{ label: 'نرخ ساعتی', value: 'hourly' }
];
const selectedCourseHoursPerSection = ref(null);
const finalTuitionFee = computed(() => {
const tuition = Number(form.tuitionFee) || 0;
@@ -320,6 +375,18 @@ const finalTuitionFee = computed(() => {
return Math.max(0, tuition - (Number(form.discount) || 0));
});
const sessionDurationHoursPreview = computed(() => resolveSessionDurationHours({
hoursPerSection: selectedCourseHoursPerSection.value,
startTime: form.startTime,
endTime: form.endTime
}));
const sessionShareEstimate = computed(() => calculateHourlyShare({
payoutHourlyRate: form.payoutHourlyRate,
sessionDurationHours: sessionDurationHoursPreview.value,
sessionsCount: 1
}));
const removeDialogMessage = computed(() => {
const name = studentToRemove.value?.name;
return name
@@ -369,6 +436,7 @@ const applyCourseDefaults = (course) => {
form.numberOfSessions = course.sectionCount;
}
if (course.sectionCount) defaultSessionCount.value = course.sectionCount;
selectedCourseHoursPerSection.value = course.hoursPerSection ?? null;
};
const fetchData = async () => {
@@ -410,10 +478,15 @@ const fetchData = async () => {
startTime: data.startTime || '',
endTime: data.endTime || '',
numberOfSessions: data.numberOfSessions ?? null,
payoutType: data.payoutType === 'hourly' ? 'hourly' : 'percentage',
payoutPercentage: data.payoutPercentage || 0,
payoutHourlyRate: data.payoutHourlyRate || 0,
extraExpensePerSession: data.extraExpensePerSession || 0,
isActive: data.isActive !== false,
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
});
students.value = data.students || [];
selectedCourseHoursPerSection.value = data.course?.hoursPerSection ?? null;
if (data.numberOfSessions) {
defaultSessionCount.value = data.numberOfSessions;
} else if (data.course?.sectionCount) {
@@ -458,6 +531,10 @@ const handleSubmit = async () => {
startTime: form.startTime || '',
endTime: form.endTime || '',
numberOfSessions: form.numberOfSessions,
payoutType: form.payoutType,
payoutPercentage: form.payoutType === 'percentage' ? form.payoutPercentage : 0,
payoutHourlyRate: form.payoutType === 'hourly' ? form.payoutHourlyRate : 0,
extraExpensePerSession: form.extraExpensePerSession,
isActive: form.isActive,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
};
@@ -517,5 +594,10 @@ const handleRemoveStudent = async () => {
}
};
watch(() => form.course, (courseId) => {
const course = courses.value.find((c) => String(c._id) === String(courseId));
selectedCourseHoursPerSection.value = course?.hoursPerSection ?? null;
});
onMounted(fetchData);
</script>