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:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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: 'مالی و ارتباطات',
|
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 }
|
||||||
|
|||||||
@@ -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: 'مشاهده گزارشهای مالی' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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