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:
@@ -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}`)
|
||||
};
|
||||
@@ -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>
|
||||
@@ -60,6 +60,8 @@ const menuGroups = computed(() => {
|
||||
label: 'مالی و ارتباطات',
|
||||
items: [
|
||||
{ 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-comments', to: '/contact-inquiries' },
|
||||
{ label: 'ثبتنامهای در انتظار', icon: 'pi pi-user-plus', to: '/pending-students', permission: PERMISSIONS.PENDING_STUDENTS_READ }
|
||||
|
||||
@@ -77,7 +77,14 @@ export const PERMISSIONS = {
|
||||
CONTACT_INQUIRIES_UPDATE: 'contact_inquiries:update',
|
||||
|
||||
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 = [
|
||||
@@ -241,6 +248,25 @@ export const PERMISSION_GROUPS = [
|
||||
{ key: PERMISSIONS.PENDING_STUDENTS_READ, 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: 'مشاهده گزارشهای مالی' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -162,6 +162,20 @@ export const routes = [
|
||||
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
|
||||
{
|
||||
path: 'roles',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user