diff --git a/package.json b/package.json index 2d58419..e2bcd89 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "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": { "@fontsource/vazirmatn": "^5.3.0", diff --git a/src/api/expenseApi.js b/src/api/expenseApi.js new file mode 100644 index 0000000..767606e --- /dev/null +++ b/src/api/expenseApi.js @@ -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}`) +}; diff --git a/src/api/financialReportApi.js b/src/api/financialReportApi.js new file mode 100644 index 0000000..ca329b9 --- /dev/null +++ b/src/api/financialReportApi.js @@ -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 }) +}; diff --git a/src/components/financialReports/FinancialStatCard.vue b/src/components/financialReports/FinancialStatCard.vue new file mode 100644 index 0000000..7b6e546 --- /dev/null +++ b/src/components/financialReports/FinancialStatCard.vue @@ -0,0 +1,29 @@ + + + + diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index a331f3a..a19912c 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -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 } diff --git a/src/constants/permissions.js b/src/constants/permissions.js index fc9967f..8b6f19d 100644 --- a/src/constants/permissions.js +++ b/src/constants/permissions.js @@ -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: 'مشاهده گزارش‌های مالی' } + ] } ]; diff --git a/src/router/routes.js b/src/router/routes.js index b2ad4f3..d797906 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -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', diff --git a/src/utils/professorShare.js b/src/utils/professorShare.js new file mode 100644 index 0000000..02d258e --- /dev/null +++ b/src/utils/professorShare.js @@ -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)); +} diff --git a/src/utils/professorShare.test.js b/src/utils/professorShare.test.js new file mode 100644 index 0000000..e4ff0a8 --- /dev/null +++ b/src/utils/professorShare.test.js @@ -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); + }); +}); diff --git a/src/views/classes/ClassDetailView.vue b/src/views/classes/ClassDetailView.vue index 720aac0..13ae775 100644 --- a/src/views/classes/ClassDetailView.vue +++ b/src/views/classes/ClassDetailView.vue @@ -2,6 +2,15 @@ diff --git a/src/views/expenses/ExpenseListView.vue b/src/views/expenses/ExpenseListView.vue new file mode 100644 index 0000000..b8ddb69 --- /dev/null +++ b/src/views/expenses/ExpenseListView.vue @@ -0,0 +1,245 @@ + +