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
+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" "test": "node --test 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",
+10
View File
@@ -0,0 +1,10 @@
// /src/api/expenseApi.js
import axiosInstance from './axiosInstance';
export const expenseApi = {
getAll: (params) => axiosInstance.get('/expenses/admin/get-all', { params }),
getOne: (id) => axiosInstance.get(`/expenses/admin/get-one/${id}`),
create: (data) => axiosInstance.post('/expenses/admin/create', data),
update: (id, data) => axiosInstance.put(`/expenses/admin/update/${id}`, data),
delete: (id) => axiosInstance.delete(`/expenses/admin/delete/${id}`)
};
+8
View File
@@ -0,0 +1,8 @@
// /src/api/financialReportApi.js
import axiosInstance from './axiosInstance';
export const financialReportApi = {
getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`),
getSessionReport: (sessionId) => axiosInstance.get(`/financial-reports/admin/sessions/${sessionId}`),
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params })
};
@@ -0,0 +1,29 @@
<!-- /src/components/financialReports/FinancialStatCard.vue -->
<template>
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3 h-full">
<div class="flex-grow-1">
<span class="text-muted text-xs block mb-1">{{ label }}</span>
<span class="text-xl font-bold text-color">{{ formattedValue }}</span>
</div>
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="bgClass">
<i :class="[icon, 'text-xl', iconClass]"></i>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
const props = defineProps({
label: { type: String, required: true },
value: { type: Number, default: 0 },
icon: { type: String, default: 'pi pi-wallet' },
bgClass: { type: String, default: 'bg-blue-100' },
iconClass: { type: String, default: 'text-blue-600' }
});
const { toPersianDigits } = usePersianDate();
const formattedValue = computed(() => `${toPersianDigits(Math.round(props.value || 0).toLocaleString())} تومان`);
</script>
+2
View File
@@ -60,6 +60,8 @@ const menuGroups = computed(() => {
label: 'مالی و ارتباطات', label: 'مالی و ارتباطات',
items: [ items: [
{ label: 'امور مالی و پرداخت‌ها', icon: 'pi pi-wallet', to: '/payments' }, { label: 'امور مالی و پرداخت‌ها', icon: 'pi pi-wallet', to: '/payments' },
{ label: 'گزارش‌های مالی', icon: 'pi pi-chart-line', to: '/financial-reports', permission: PERMISSIONS.FINANCIAL_REPORTS_READ },
{ label: 'هزینه‌های موسسه', icon: 'pi pi-money-bill', to: '/expenses', permission: PERMISSIONS.EXPENSES_READ },
{ label: 'اطلاعیه‌ها', icon: 'pi pi-bell', to: '/notifications' }, { label: 'اطلاعیه‌ها', icon: 'pi pi-bell', to: '/notifications' },
{ label: 'درخواست‌های تماس', icon: 'pi pi-comments', to: '/contact-inquiries' }, { label: 'درخواست‌های تماس', icon: 'pi pi-comments', to: '/contact-inquiries' },
{ label: 'ثبت‌نام‌های در انتظار', icon: 'pi pi-user-plus', to: '/pending-students', permission: PERMISSIONS.PENDING_STUDENTS_READ } { label: 'ثبت‌نام‌های در انتظار', icon: 'pi pi-user-plus', to: '/pending-students', permission: PERMISSIONS.PENDING_STUDENTS_READ }
+27 -1
View File
@@ -77,7 +77,14 @@ export const PERMISSIONS = {
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update', CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update',
PENDING_STUDENTS_READ: 'pending_students:read', PENDING_STUDENTS_READ: 'pending_students:read',
PENDING_STUDENTS_UPDATE: 'pending_students:update' PENDING_STUDENTS_UPDATE: 'pending_students:update',
EXPENSES_CREATE: 'expenses:create',
EXPENSES_READ: 'expenses:read',
EXPENSES_UPDATE: 'expenses:update',
EXPENSES_DELETE: 'expenses:delete',
FINANCIAL_REPORTS_READ: 'financial_reports:read'
}; };
export const PERMISSION_GROUPS = [ export const PERMISSION_GROUPS = [
@@ -241,6 +248,25 @@ export const PERMISSION_GROUPS = [
{ key: PERMISSIONS.PENDING_STUDENTS_READ, label: 'مشاهده ثبت‌نام‌های در انتظار' }, { key: PERMISSIONS.PENDING_STUDENTS_READ, label: 'مشاهده ثبت‌نام‌های در انتظار' },
{ key: PERMISSIONS.PENDING_STUDENTS_UPDATE, label: 'تأیید / رد ثبت‌نام' } { key: PERMISSIONS.PENDING_STUDENTS_UPDATE, label: 'تأیید / رد ثبت‌نام' }
] ]
},
{
key: 'expenses',
label: 'هزینه‌های موسسه',
icon: 'pi pi-money-bill',
permissions: [
{ key: PERMISSIONS.EXPENSES_CREATE, label: 'ثبت هزینه' },
{ key: PERMISSIONS.EXPENSES_READ, label: 'مشاهده هزینه‌ها' },
{ key: PERMISSIONS.EXPENSES_UPDATE, label: 'ویرایش هزینه' },
{ key: PERMISSIONS.EXPENSES_DELETE, label: 'حذف هزینه' }
]
},
{
key: 'financial_reports',
label: 'گزارش‌های مالی',
icon: 'pi pi-chart-line',
permissions: [
{ key: PERMISSIONS.FINANCIAL_REPORTS_READ, label: 'مشاهده گزارش‌های مالی' }
]
} }
]; ];
+14
View File
@@ -162,6 +162,20 @@ export const routes = [
component: () => import('@/views/payments/PaymentDetailView.vue') component: () => import('@/views/payments/PaymentDetailView.vue')
}, },
// Financial reports & institutional expenses
{
path: 'financial-reports',
name: 'FinancialReports',
component: () => import('@/views/financialReports/FinancialReportsView.vue'),
meta: { title: 'گزارش‌های مالی', permission: 'financial_reports:read' }
},
{
path: 'expenses',
name: 'ExpenseList',
component: () => import('@/views/expenses/ExpenseListView.vue'),
meta: { title: 'هزینه‌های موسسه', permission: 'expenses:read' }
},
// Roles // Roles
{ {
path: 'roles', path: 'roles',
+72
View File
@@ -0,0 +1,72 @@
// /src/utils/professorShare.js
// Mirrors gameno-api/utils/professorShare.js so the dashboard can preview payout
// calculations client-side before saving a class.
const toNonNegativeNumber = (value) => {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
};
const toPercentage = (value) => Math.min(100, toNonNegativeNumber(value));
const parseClockTime = (value) => {
const raw = String(value || '').trim();
const match = raw.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
};
export function calculateSessionDurationHours(startTime, endTime) {
const start = parseClockTime(startTime);
const end = parseClockTime(endTime);
if (start == null || end == null) return 0;
let diffMinutes = end - start;
if (diffMinutes <= 0) diffMinutes += 24 * 60;
return diffMinutes / 60;
}
export function resolveSessionDurationHours({ hoursPerSection, startTime, endTime } = {}) {
const hours = toNonNegativeNumber(hoursPerSection);
if (hours > 0) return hours;
return calculateSessionDurationHours(startTime, endTime);
}
export function calculatePercentageShare({ payoutPercentage = 0, revenue = 0 } = {}) {
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
}
export function calculateHourlyShare({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) {
return toNonNegativeNumber(payoutHourlyRate) * toNonNegativeNumber(sessionDurationHours) * toNonNegativeNumber(sessionsCount);
}
export function calculateExtraExpenses({ extraExpensePerSession = 0, sessionsCount = 0 } = {}) {
return toNonNegativeNumber(extraExpensePerSession) * toNonNegativeNumber(sessionsCount);
}
export function calculateProfessorPayout(params = {}) {
const baseShare = params.payoutType === 'hourly'
? calculateHourlyShare(params)
: calculatePercentageShare(params);
const extraExpenses = calculateExtraExpenses(params);
return {
baseShare,
extraExpenses,
totalPayout: baseShare + extraExpenses
};
}
export function calculateNetProfit({ totalIncome = 0, professorTotalPayout = 0 } = {}) {
return toNonNegativeNumber(totalIncome) - toNonNegativeNumber(professorTotalPayout);
}
export function calculatePendingReceivables(expectedRevenue = 0, actualReceivedRevenue = 0) {
return Math.max(0, toNonNegativeNumber(expectedRevenue) - toNonNegativeNumber(actualReceivedRevenue));
}
export function calculateOverpaidAmount(expectedRevenue = 0, actualReceivedRevenue = 0) {
return Math.max(0, toNonNegativeNumber(actualReceivedRevenue) - toNonNegativeNumber(expectedRevenue));
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
calculateSessionDurationHours,
resolveSessionDurationHours,
calculatePercentageShare,
calculateHourlyShare,
calculateExtraExpenses,
calculateProfessorPayout,
calculateNetProfit,
calculatePendingReceivables,
calculateOverpaidAmount
} from './professorShare.js';
describe('calculateSessionDurationHours', () => {
it('computes duration between two clock times', () => {
assert.equal(calculateSessionDurationHours('18:00', '20:00'), 2);
});
it('returns 0 for invalid input', () => {
assert.equal(calculateSessionDurationHours('', ''), 0);
assert.equal(calculateSessionDurationHours(null, undefined), 0);
});
});
describe('resolveSessionDurationHours', () => {
it('prefers hoursPerSection over the class time window', () => {
assert.equal(resolveSessionDurationHours({ hoursPerSection: 2, startTime: '18:00', endTime: '20:30' }), 2);
});
it('falls back to the class time window', () => {
assert.equal(resolveSessionDurationHours({ startTime: '18:00', endTime: '19:30' }), 1.5);
});
});
describe('calculateProfessorPayout', () => {
it('computes percentage-based payout with extra expenses', () => {
const result = calculateProfessorPayout({
payoutType: 'percentage',
payoutPercentage: 40,
revenue: 10_000_000,
extraExpensePerSession: 100_000,
sessionsCount: 5
});
assert.equal(result.baseShare, 4_000_000);
assert.equal(result.extraExpenses, 500_000);
assert.equal(result.totalPayout, 4_500_000);
});
it('computes hourly-based payout with extra expenses', () => {
const result = calculateProfessorPayout({
payoutType: 'hourly',
payoutHourlyRate: 300_000,
sessionDurationHours: 1.5,
extraExpensePerSession: 80_000,
sessionsCount: 10
});
assert.equal(result.baseShare, 4_500_000);
assert.equal(result.extraExpenses, 800_000);
assert.equal(result.totalPayout, 5_300_000);
});
});
describe('calculateNetProfit', () => {
it('can go negative to represent a loss', () => {
assert.equal(calculateNetProfit({ totalIncome: 1_000_000, professorTotalPayout: 4_500_000 }), -3_500_000);
});
});
describe('calculatePendingReceivables / calculateOverpaidAmount', () => {
it('never returns negative outstanding, and surfaces overpayments separately', () => {
assert.equal(calculatePendingReceivables(10_000_000, 12_000_000), 0);
assert.equal(calculateOverpaidAmount(10_000_000, 12_000_000), 2_000_000);
});
it('handles zero revenue classes gracefully', () => {
assert.equal(calculatePendingReceivables(0, 0), 0);
assert.equal(calculateExtraExpenses({}), 0);
});
});
+9
View File
@@ -2,6 +2,15 @@
<template> <template>
<div class="class-detail-view" v-if="classData"> <div class="class-detail-view" v-if="classData">
<PageHeader :title="classData.name" :subtitle="`دوره: ${classData.course?.title || '-'}`"> <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-pencil" class="ml-2" @click="$router.push(`/classes/edit/${classId}`)" />
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" /> <Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/classes')" />
</PageHeader> </PageHeader>
+83 -1
View File
@@ -70,6 +70,51 @@
</div> </div>
</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"> <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" />
@@ -240,7 +285,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import moment from 'jalali-moment'; import moment from 'jalali-moment';
import { classApi } from '@/api/classApi'; import { classApi } from '@/api/classApi';
@@ -268,6 +313,7 @@ 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 } from '@/utils/classSchedule';
import { resolveSessionDurationHours, calculateHourlyShare } from '@/utils/professorShare';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -309,10 +355,19 @@ const form = reactive({
startTime: '', startTime: '',
endTime: '', endTime: '',
numberOfSessions: null, numberOfSessions: null,
payoutType: 'percentage',
payoutPercentage: 0,
payoutHourlyRate: 0,
extraExpensePerSession: 0,
isActive: true, isActive: true,
adminNotes: [] adminNotes: []
}); });
const weekdays = WEEKDAYS; const weekdays = WEEKDAYS;
const payoutTypeOptions = [
{ label: 'درصدی از درآمد کلاس', value: 'percentage' },
{ label: 'نرخ ساعتی', value: 'hourly' }
];
const selectedCourseHoursPerSection = ref(null);
const finalTuitionFee = computed(() => { const finalTuitionFee = computed(() => {
const tuition = Number(form.tuitionFee) || 0; const tuition = Number(form.tuitionFee) || 0;
@@ -320,6 +375,18 @@ const finalTuitionFee = computed(() => {
return Math.max(0, tuition - (Number(form.discount) || 0)); 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 removeDialogMessage = computed(() => {
const name = studentToRemove.value?.name; const name = studentToRemove.value?.name;
return name return name
@@ -369,6 +436,7 @@ const applyCourseDefaults = (course) => {
form.numberOfSessions = course.sectionCount; form.numberOfSessions = course.sectionCount;
} }
if (course.sectionCount) defaultSessionCount.value = course.sectionCount; if (course.sectionCount) defaultSessionCount.value = course.sectionCount;
selectedCourseHoursPerSection.value = course.hoursPerSection ?? null;
}; };
const fetchData = async () => { const fetchData = async () => {
@@ -410,10 +478,15 @@ const fetchData = async () => {
startTime: data.startTime || '', startTime: data.startTime || '',
endTime: data.endTime || '', endTime: data.endTime || '',
numberOfSessions: data.numberOfSessions ?? null, 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, isActive: data.isActive !== false,
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : [] adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
}); });
students.value = data.students || []; students.value = data.students || [];
selectedCourseHoursPerSection.value = data.course?.hoursPerSection ?? null;
if (data.numberOfSessions) { if (data.numberOfSessions) {
defaultSessionCount.value = data.numberOfSessions; defaultSessionCount.value = data.numberOfSessions;
} else if (data.course?.sectionCount) { } else if (data.course?.sectionCount) {
@@ -458,6 +531,10 @@ const handleSubmit = async () => {
startTime: form.startTime || '', startTime: form.startTime || '',
endTime: form.endTime || '', endTime: form.endTime || '',
numberOfSessions: form.numberOfSessions, 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, isActive: form.isActive,
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean) 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); onMounted(fetchData);
</script> </script>
+245
View File
@@ -0,0 +1,245 @@
<!-- /src/views/expenses/ExpenseListView.vue -->
<template>
<div class="expense-list-view">
<PageHeader title="هزینه‌های موسسه" subtitle="ثبت و مدیریت هزینه‌های عمومی مؤسسه (اجاره، قبوض، تجهیزات و ...)">
<PermissionGate permission="expenses:create">
<Button label="ثبت هزینه جدید" icon="pi pi-plus" severity="success" @click="openCreateModal" />
</PermissionGate>
</PageHeader>
<div class="grid mb-4">
<div class="col-12 sm:col-6">
<div class="stat-card surface-card p-4 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between gap-3">
<div>
<span class="text-muted text-xs block mb-1">جمع هزینههای صفحه جاری</span>
<span class="text-2xl font-bold text-color">{{ toPersianDigits(pageTotal.toLocaleString()) }} تومان</span>
</div>
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-red-100 flex-shrink-0">
<i class="pi pi-money-bill text-xl text-red-500"></i>
</div>
</div>
</div>
</div>
<DataTableWrapper
:items="items"
:totalCount="totalCount"
:page="queryParams.page"
:limit="queryParams.limit"
:sortBy="queryParams.sortBy"
:sortOrder="queryParams.sortOrder"
:loading="isLoading"
@page-change="onPageChange"
@sort-change="onSort"
@search-change="onSearch"
>
<Column field="title" header="عنوان">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.title }}</span>
</template>
</Column>
<Column field="category" header="دسته‌بندی">
<template #body="{ data }">
<Tag :value="data.category || 'عمومی'" severity="info" class="text-xs" />
</template>
</Column>
<Column field="amount" header="مبلغ" sortable>
<template #body="{ data }">
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
</template>
</Column>
<Column field="date" header="تاریخ" sortable>
<template #body="{ data }">{{ formatJalali(data.date) }}</template>
</Column>
<Column field="recordedBy" header="ثبت‌شده توسط">
<template #body="{ data }">{{ data.recordedBy?.name || '—' }}</template>
</Column>
<Column header="عملیات" style="width: 110px">
<template #body="{ data }">
<div class="flex align-items-center gap-1">
<PermissionGate permission="expenses:update">
<Button icon="pi pi-pencil" text rounded size="small" v-tooltip.top="'ویرایش'" @click="openEditModal(data)" />
</PermissionGate>
<PermissionGate permission="expenses:delete">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" v-tooltip.top="'حذف'" @click="confirmDelete(data)" />
</PermissionGate>
</div>
</template>
</Column>
</DataTableWrapper>
<Dialog v-model:visible="showFormModal" :header="isEditMode ? 'ویرایش هزینه' : 'ثبت هزینه جدید'" 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>
<InputText v-model.trim="form.title" class="w-full text-sm" placeholder="مثلا: اجاره سالن، قبض برق" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">دستهبندی</label>
<InputText v-model.trim="form.category" class="w-full text-sm" placeholder="عمومی" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
<InputNumber v-model="form.amount" :min="0" class="w-full text-sm" suffix=" تومان" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">تاریخ *</label>
<DatePicker v-model="form.date" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">یادداشت</label>
<Textarea v-model="form.notes" rows="3" class="w-full text-sm" maxlength="2000" placeholder="توضیحات تکمیلی…" />
</div>
</div>
<template #footer>
<Button label="انصراف" text severity="secondary" @click="showFormModal = false" />
<Button :label="isEditMode ? 'ذخیره تغییرات' : 'ثبت هزینه'" icon="pi pi-check" severity="success" :loading="isSaving" @click="handleSubmit" />
</template>
</Dialog>
<ConfirmDeleteDialog
v-model="deleteDialogVisible"
title="حذف هزینه"
:message="deleteDialogMessage"
:loading="isDeleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { useDataTable } from '@/composables/useDataTable';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { expenseApi } from '@/api/expenseApi';
import PageHeader from '@/components/common/PageHeader.vue';
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import Button from 'primevue/button';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Textarea from 'primevue/textarea';
import DatePicker from 'vue3-persian-datetime-picker';
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue } = usePersianDate();
const { showSuccess, showError } = useToast();
const {
items,
totalCount,
isLoading,
queryParams,
loadData,
onPageChange,
onSort,
onSearch
} = useDataTable(expenseApi.getAll, { sortBy: 'date', sortOrder: 'desc' });
const pageTotal = computed(() => items.value.reduce((sum, item) => sum + (Number(item.amount) || 0), 0));
const showFormModal = ref(false);
const isSaving = ref(false);
const isEditMode = ref(false);
const editingId = ref(null);
const form = reactive({
title: '',
category: '',
amount: 0,
date: '',
notes: ''
});
const resetForm = () => {
form.title = '';
form.category = '';
form.amount = 0;
form.date = toJalaliPickerValue(new Date());
form.notes = '';
};
const openCreateModal = () => {
isEditMode.value = false;
editingId.value = null;
resetForm();
showFormModal.value = true;
};
const openEditModal = (expense) => {
isEditMode.value = true;
editingId.value = expense._id || expense.id;
form.title = expense.title || '';
form.category = expense.category || '';
form.amount = expense.amount || 0;
form.date = toJalaliPickerValue(expense.date);
form.notes = expense.notes || '';
showFormModal.value = true;
};
const handleSubmit = async () => {
if (!form.title) { showError('عنوان هزینه الزامی است'); return; }
if (!form.amount || form.amount <= 0) { showError('مبلغ هزینه باید بیشتر از صفر باشد'); return; }
if (!form.date) { showError('تاریخ هزینه الزامی است'); return; }
const date = toGregorianIso(form.date);
if (!date) { showError('تاریخ نامعتبر است'); return; }
isSaving.value = true;
try {
const payload = {
title: form.title,
category: form.category || 'عمومی',
amount: form.amount,
date,
notes: form.notes
};
if (isEditMode.value) {
await expenseApi.update(editingId.value, payload);
showSuccess('هزینه با موفقیت ویرایش شد');
} else {
await expenseApi.create(payload);
showSuccess('هزینه جدید با موفقیت ثبت شد');
}
showFormModal.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isSaving.value = false;
}
};
const deleteDialogVisible = ref(false);
const selectedExpense = ref(null);
const isDeleting = ref(false);
const deleteDialogMessage = computed(() => {
const title = selectedExpense.value?.title;
return title ? `آیا هزینه «${title}» حذف شود؟` : 'آیا این هزینه حذف شود؟';
});
const confirmDelete = (expense) => {
selectedExpense.value = expense;
deleteDialogVisible.value = true;
};
const handleDelete = async () => {
if (!selectedExpense.value) return;
isDeleting.value = true;
try {
await expenseApi.delete(selectedExpense.value._id || selectedExpense.value.id);
showSuccess('هزینه با موفقیت حذف شد');
deleteDialogVisible.value = false;
loadData();
} catch (err) {
showError(err);
} finally {
isDeleting.value = false;
}
};
onMounted(loadData);
</script>
@@ -0,0 +1,475 @@
<!-- /src/views/financialReports/FinancialReportsView.vue -->
<template>
<div class="financial-reports-view">
<PageHeader title="گزارش‌های مالی" subtitle="سهم اساتید، سودآوری کلاس‌ها و تحلیل درآمد/هزینه مؤسسه">
<PermissionGate permission="expenses:read">
<Button label="هزینه‌های موسسه" icon="pi pi-money-bill" text @click="$router.push('/expenses')" />
</PermissionGate>
</PageHeader>
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
<TabList>
<Tab value="0">گزارش کلاسها</Tab>
<Tab value="1">گزارش بازه زمانی / ماهانه</Tab>
</TabList>
<TabPanels>
<!-- Per-Class (Lifetime) Report -->
<TabPanel value="0">
<div class="p-3">
<div class="flex flex-column md:flex-row gap-2 mb-4 align-items-end">
<div class="flex flex-column gap-2 flex-grow-1 md:max-w-25rem">
<label class="font-semibold text-sm">انتخاب کلاس</label>
<Dropdown
v-model="selectedClassId"
:options="classes"
optionLabel="name"
optionValue="_id"
filter
placeholder="یک کلاس را انتخاب کنید"
class="w-full text-sm"
@change="loadClassReport"
/>
</div>
<Button icon="pi pi-refresh" text :loading="isClassReportLoading" @click="loadClassReport" v-if="selectedClassId" />
</div>
<div v-if="isClassReportLoading" class="text-center text-muted p-5">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<EmptyState v-else-if="!classReport" description="برای مشاهده گزارش، ابتدا یک کلاس را انتخاب کنید" />
<div v-else class="flex flex-column gap-4">
<!-- Summary cards -->
<div class="grid">
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="درآمد قطعی (وصول‌شده)"
:value="classReport.revenue.actualReceivedRevenue"
icon="pi pi-check-circle"
bg-class="bg-green-100"
icon-class="text-green-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="مطالبات معوق (در انتظار وصول)"
:value="classReport.revenue.pendingReceivables"
icon="pi pi-clock"
bg-class="bg-orange-100"
icon-class="text-orange-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="سهم و هزینه استاد"
:value="classReport.professorPayout.totalPayout"
icon="pi pi-id-card"
bg-class="bg-blue-100"
icon-class="text-blue-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="سود خالص کلاس"
:value="classReport.netProfit"
icon="pi pi-chart-line"
:bg-class="classReport.netProfit >= 0 ? 'bg-teal-100' : 'bg-red-100'"
:icon-class="classReport.netProfit >= 0 ? 'text-teal-600' : 'text-red-600'"
/>
</div>
</div>
<!-- Detail breakdown -->
<div class="grid">
<div class="col-12 lg:col-6">
<div class="surface-100 border-round p-3 h-full">
<h4 class="text-sm font-bold text-color mb-3">وضعیت مالی دانشجویان</h4>
<dl class="detail-list">
<div class="detail-row">
<dt>درآمد مورد انتظار (شهریه‌های ثبت‌شده)</dt>
<dd>{{ formatToman(classReport.revenue.expectedRevenue) }}</dd>
</div>
<div class="detail-row">
<dt>درآمد قطعی وصول‌شده</dt>
<dd>{{ formatToman(classReport.revenue.actualReceivedRevenue) }}</dd>
</div>
<div class="detail-row">
<dt>مطالبات معوق</dt>
<dd>{{ formatToman(classReport.revenue.pendingReceivables) }}</dd>
</div>
<div class="detail-row" v-if="classReport.revenue.overpaidAmount > 0">
<dt>اضافه‌پرداختی دانشجویان</dt>
<dd>{{ formatToman(classReport.revenue.overpaidAmount) }}</dd>
</div>
<div class="detail-row">
<dt>تعداد دانشجویان</dt>
<dd>{{ toPersianDigits(classReport.class.studentsCount) }} نفر</dd>
</div>
</dl>
</div>
</div>
<div class="col-12 lg:col-6">
<div class="surface-100 border-round p-3 h-full">
<h4 class="text-sm font-bold text-color mb-3">سهم استاد و جلسات</h4>
<dl class="detail-list">
<div class="detail-row">
<dt>مدل محاسبه سهم</dt>
<dd>{{ classReport.class.payoutType === 'hourly' ? 'ساعتی' : 'درصدی' }}</dd>
</div>
<div class="detail-row">
<dt>سهم پایه استاد</dt>
<dd>{{ formatToman(classReport.professorPayout.baseShare) }}</dd>
</div>
<div class="detail-row">
<dt>هزینه‌های جانبی (جمع)</dt>
<dd>{{ formatToman(classReport.professorPayout.extraExpenses) }}</dd>
</div>
<div class="detail-row">
<dt>جمع پرداختی به استاد</dt>
<dd class="font-bold">{{ formatToman(classReport.professorPayout.totalPayout) }}</dd>
</div>
<div class="detail-row">
<dt>جلسات برگزارشده / برنامه‌ریزی‌شده</dt>
<dd>{{ toPersianDigits(classReport.sessions.held) }} / {{ toPersianDigits(classReport.sessions.planned) }}</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Per-session breakdown -->
<div>
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">گزارش هر جلسه</h3>
</div>
<TableSkeleton v-if="isSessionsLoading" :rows="4" :columns="5" />
<DataTable v-else :value="classSessions" class="p-datatable-sm text-sm" emptyMessage="جلسهای برای این کلاس ثبت نشده است">
<Column header="موضوع">
<template #body="{ data }">{{ data.topic || '—' }}</template>
</Column>
<Column header="تاریخ">
<template #body="{ data }">{{ formatJalali(data.day || data.date) }}</template>
</Column>
<Column header="وضعیت">
<template #body="{ data }"><StatusTag :status="data.status || 'scheduled'" type="session" /></template>
</Column>
<Column header="عملیات" style="width: 140px">
<template #body="{ data }">
<Button label="گزارش جلسه" icon="pi pi-chart-bar" text size="small" @click="openSessionReport(data._id || data.id)" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</TabPanel>
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
<TabPanel value="1">
<div class="p-3">
<div class="flex flex-column md:flex-row gap-3 mb-4 align-items-end">
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">از تاریخ</label>
<DatePicker v-model="rangeForm.startDate" class="w-full text-sm" placeholder="1403/01/01" />
</div>
<div class="flex flex-column gap-2">
<label class="font-semibold text-sm">تا تاریخ</label>
<DatePicker v-model="rangeForm.endDate" class="w-full text-sm" placeholder="1403/01/30" />
</div>
<Button label="این ماه" text size="small" @click="setThisMonth" />
<Button label="نمایش گزارش" icon="pi pi-search" :loading="isRangeReportLoading" @click="loadRangeReport" />
</div>
<div v-if="isRangeReportLoading" class="text-center text-muted p-5">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<EmptyState v-else-if="!rangeReport" description="بازه زمانی مورد نظر را انتخاب و گزارش را مشاهده کنید" />
<div v-else class="flex flex-column gap-4">
<div class="grid">
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="کل وجوه دریافتی"
:value="rangeReport.totals.totalReceived"
icon="pi pi-wallet"
bg-class="bg-green-100"
icon-class="text-green-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="مطالبات معوق سررسیدشده"
:value="rangeReport.totals.totalOutstanding"
icon="pi pi-clock"
bg-class="bg-orange-100"
icon-class="text-orange-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="سهم/هزینه اساتید"
:value="rangeReport.totals.totalProfessorPayouts"
icon="pi pi-id-card"
bg-class="bg-blue-100"
icon-class="text-blue-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="هزینههای عمومی موسسه"
:value="rangeReport.totals.generalExpenses"
icon="pi pi-building"
bg-class="bg-purple-100"
icon-class="text-purple-600"
/>
</div>
</div>
<div class="surface-100 border-round p-4 flex align-items-center justify-content-between">
<span class="font-bold text-color">سود خالص عملیاتی بازه انتخاب‌شده</span>
<span
class="text-xl font-bold"
:class="rangeReport.totals.netProfit >= 0 ? 'text-teal-600' : 'text-red-600'"
>
{{ formatToman(rangeReport.totals.netProfit) }}
</span>
</div>
<div>
<h3 class="text-base font-bold text-color mb-3">تفکیک به ازای هر کلاس</h3>
<DataTable :value="rangeReport.classes" class="p-datatable-sm text-sm" emptyMessage="در این بازه زمانی جلسهای برگزار نشده است">
<Column header="کلاس">
<template #body="{ data }">{{ data.class?.name || '—' }}</template>
</Column>
<Column header="استاد">
<template #body="{ data }">
{{ data.class?.professor ? `${data.class.professor.name || ''} ${data.class.professor.surname || ''}`.trim() : '—' }}
</template>
</Column>
<Column header="جلسات برگزارشده">
<template #body="{ data }">{{ toPersianDigits(data.sessionsHeldInRange) }}</template>
</Column>
<Column header="دریافتی بازه">
<template #body="{ data }">{{ formatToman(data.receivedInRange) }}</template>
</Column>
<Column header="معوق بازه">
<template #body="{ data }">{{ formatToman(data.outstandingInRange) }}</template>
</Column>
<Column header="پرداختی به استاد">
<template #body="{ data }">{{ formatToman(data.professorPayout.totalPayout) }}</template>
</Column>
</DataTable>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Per-session report dialog -->
<Dialog v-model:visible="sessionDialogVisible" header="گزارش مالی جلسه" modal :style="{ width: '480px' }">
<div v-if="isSessionReportLoading" class="text-center p-4">
<ProgressSpinner style="width: 32px; height: 32px;" />
</div>
<dl v-else-if="sessionReport" class="detail-list">
<div class="detail-row">
<dt>موضوع جلسه</dt>
<dd>{{ sessionReport.session.topic || '—' }}</dd>
</div>
<div class="detail-row">
<dt>تاریخ</dt>
<dd>{{ formatJalali(sessionReport.session.day) }}</dd>
</div>
<div class="detail-row">
<dt>درآمد این جلسه (جمع دانشجویان)</dt>
<dd>{{ formatToman(sessionReport.financials.sessionIncome) }}</dd>
</div>
<div class="detail-row">
<dt>سهم استاد از این جلسه</dt>
<dd>{{ formatToman(sessionReport.financials.professorSessionShare) }}</dd>
</div>
<div class="detail-row">
<dt>هزینه جانبی این جلسه</dt>
<dd>{{ formatToman(sessionReport.financials.sessionExtraExpense) }}</dd>
</div>
<div class="detail-row">
<dt>جمع پرداختی این جلسه</dt>
<dd class="font-bold">{{ formatToman(sessionReport.financials.sessionTotalPayout) }}</dd>
</div>
<div class="detail-row">
<dt>حاشیه سود خالص جلسه</dt>
<dd :class="sessionReport.financials.sessionNetMargin >= 0 ? 'text-teal-600 font-bold' : 'text-red-600 font-bold'">
{{ formatToman(sessionReport.financials.sessionNetMargin) }}
</dd>
</div>
</dl>
<template #footer>
<Button label="بستن" text @click="sessionDialogVisible = false" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { financialReportApi } from '@/api/financialReportApi';
import { classApi } from '@/api/classApi';
import { sessionApi } from '@/api/sessionApi';
import PageHeader from '@/components/common/PageHeader.vue';
import EmptyState from '@/components/common/EmptyState.vue';
import TableSkeleton from '@/components/common/TableSkeleton.vue';
import StatusTag from '@/components/common/StatusTag.vue';
import PermissionGate from '@/components/common/PermissionGate.vue';
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/select';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import ProgressSpinner from 'primevue/progressspinner';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
import DatePicker from 'vue3-persian-datetime-picker';
const route = useRoute();
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue } = usePersianDate();
const { showError } = useToast();
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
// ── Per-class report ──────────────────────────────────────────────────────
const classes = ref([]);
const selectedClassId = ref(null);
const classReport = ref(null);
const isClassReportLoading = ref(false);
const classSessions = ref([]);
const isSessionsLoading = ref(false);
const loadClasses = async () => {
try {
const res = await classApi.getAll({ limit: 200 });
const data = res.data || res;
classes.value = Array.isArray(data) ? data : (data.data || data.items || []);
} catch (err) {
showError(err);
}
};
const loadClassSessions = async (classId) => {
isSessionsLoading.value = true;
try {
const res = await sessionApi.getAll({ class: classId, limit: 200, sortBy: 'day', sortOrder: 'asc' });
const data = res.data || res;
classSessions.value = Array.isArray(data) ? data : (data.data || data.items || []);
} catch (err) {
classSessions.value = [];
} finally {
isSessionsLoading.value = false;
}
};
const loadClassReport = async () => {
if (!selectedClassId.value) return;
isClassReportLoading.value = true;
try {
const res = await financialReportApi.getClassReport(selectedClassId.value);
classReport.value = res.data || res;
await loadClassSessions(selectedClassId.value);
} catch (err) {
showError(err);
classReport.value = null;
} finally {
isClassReportLoading.value = false;
}
};
// ── Per-session report dialog ────────────────────────────────────────────
const sessionDialogVisible = ref(false);
const sessionReport = ref(null);
const isSessionReportLoading = ref(false);
const openSessionReport = async (sessionId) => {
sessionDialogVisible.value = true;
isSessionReportLoading.value = true;
sessionReport.value = null;
try {
const res = await financialReportApi.getSessionReport(sessionId);
sessionReport.value = res.data || res;
} catch (err) {
showError(err);
} finally {
isSessionReportLoading.value = false;
}
};
// ── Date-range / monthly report ──────────────────────────────────────────
const rangeForm = reactive({ startDate: '', endDate: '' });
const rangeReport = ref(null);
const isRangeReportLoading = ref(false);
const setThisMonth = () => {
const now = new Date();
const first = new Date(now.getFullYear(), now.getMonth(), 1);
rangeForm.startDate = toJalaliPickerValue(first);
rangeForm.endDate = toJalaliPickerValue(now);
};
const loadRangeReport = async () => {
isRangeReportLoading.value = true;
try {
const params = {};
if (rangeForm.startDate) params.startDate = toGregorianIso(rangeForm.startDate);
if (rangeForm.endDate) params.endDate = toGregorianIso(rangeForm.endDate);
const res = await financialReportApi.getRangeReport(params);
rangeReport.value = res.data || res;
} catch (err) {
showError(err);
rangeReport.value = null;
} finally {
isRangeReportLoading.value = false;
}
};
onMounted(async () => {
await loadClasses();
setThisMonth();
if (route.query.classId) {
selectedClassId.value = String(route.query.classId);
loadClassReport();
}
});
</script>
<style scoped lang="scss">
.detail-list {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
dt {
color: var(--text-secondary);
}
dd {
margin: 0;
color: var(--text-primary);
}
}
</style>