feat(financial-reports): add charts, trends and forecasts overview tab

Adds a new "نمای کلی و نمودارها" tab to the financial reports page with
KPI cards, daily/weekly income-trend and monthly breakdown charts, a
payment-status doughnut, a due-date cash-flow forecast chart, and full
per-class income and upcoming-payments tables — backed by chart.js /
vue-chartjs and the new /financial-reports/admin/analytics endpoint.
This commit is contained in:
2026-08-23 18:24:35 +03:30
parent 7cb2b957a7
commit 39921b548e
12 changed files with 893 additions and 8 deletions
+30
View File
@@ -11,6 +11,7 @@
"@fontsource/vazirmatn": "^5.3.0",
"@primevue/themes": "4.0.7",
"axios": "^1.19.0",
"chart.js": "^4.5.1",
"filepond": "^4.32.12",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-preview": "^4.6.12",
@@ -24,6 +25,7 @@
"sass": "^1.102.0",
"vee-validate": "^4.15.1",
"vue": "^3.5.40",
"vue-chartjs": "^5.3.4",
"vue-filepond": "^8.0.0",
"vue-i18n": "^11.4.8",
"vue-router": "^5.2.0",
@@ -280,6 +282,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@oxc-project/types": {
"version": "0.143.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
@@ -1277,6 +1285,18 @@
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -3265,6 +3285,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-filepond": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz",
+2
View File
@@ -13,6 +13,7 @@
"@fontsource/vazirmatn": "^5.3.0",
"@primevue/themes": "4.0.7",
"axios": "^1.19.0",
"chart.js": "^4.5.1",
"filepond": "^4.32.12",
"filepond-plugin-file-validate-type": "^1.2.9",
"filepond-plugin-image-preview": "^4.6.12",
@@ -26,6 +27,7 @@
"sass": "^1.102.0",
"vee-validate": "^4.15.1",
"vue": "^3.5.40",
"vue-chartjs": "^5.3.4",
"vue-filepond": "^8.0.0",
"vue-i18n": "^11.4.8",
"vue-router": "^5.2.0",
+2 -1
View File
@@ -4,5 +4,6 @@ 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 })
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params }),
getAnalytics: (params) => axiosInstance.get('/financial-reports/admin/analytics', { params })
};
@@ -4,6 +4,7 @@
<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>
<span v-if="hint" class="text-xs text-muted block mt-1">{{ hint }}</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>
@@ -20,10 +21,19 @@ const props = defineProps({
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' }
iconClass: { type: String, default: 'text-blue-600' },
/** 'currency' (default, appends تومان) | 'number' (plain count) | 'text' (uses displayValue verbatim) */
format: { type: String, default: 'currency' },
/** Overrides the computed value entirely, e.g. a "12 / 20" ratio string. */
displayValue: { type: String, default: '' },
hint: { type: String, default: '' }
});
const { toPersianDigits } = usePersianDate();
const formattedValue = computed(() => `${toPersianDigits(Math.round(props.value || 0).toLocaleString())} تومان`);
const formattedValue = computed(() => {
if (props.displayValue) return props.displayValue;
if (props.format === 'number') return toPersianDigits(Math.round(props.value || 0).toLocaleString());
return `${toPersianDigits(Math.round(props.value || 0).toLocaleString())} تومان`;
});
</script>
@@ -0,0 +1,70 @@
<!-- /src/components/financialReports/charts/ForecastChart.vue -->
<!-- Bar chart of expected future cash inflow, bucketed by the upcoming due-date week. -->
<template>
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
<Bar :data="chartData" :options="options" />
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import { usePersianDate } from '@/composables/usePersianDate';
import { CHART_COLORS } from '@/utils/chartTheme';
const props = defineProps({
labels: { type: Array, default: () => [] },
amounts: { type: Array, default: () => [] },
counts: { type: Array, default: () => [] },
height: { type: Number, default: 240 }
});
const { toPersianDigits } = usePersianDate();
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: 'دریافتی پیش‌بینی‌شده',
data: props.amounts,
backgroundColor: CHART_COLORS.sessionIncome,
borderRadius: 6,
maxBarThickness: 40
}
]
}));
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
rtl: true,
textDirection: 'rtl',
callbacks: {
label: (ctx) => `${formatToman(ctx.parsed.y)}`,
afterLabel: (ctx) => {
const count = props.counts[ctx.dataIndex];
return count ? `${toPersianDigits(count)} قسط در انتظار` : '';
}
}
}
},
scales: {
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted } },
y: {
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
ticks: { color: CHART_COLORS.muted, callback: (v) => toPersianDigits(Number(v).toLocaleString()) }
}
}
}));
</script>
<style scoped>
.chart-wrap {
width: 100%;
position: relative;
}
</style>
@@ -0,0 +1,86 @@
<!-- /src/components/financialReports/charts/IncomeByClassChart.vue -->
<!-- Horizontal bar chart ranking classes by received revenue vs. outstanding receivables. -->
<template>
<div class="chart-wrap" dir="ltr" :style="{ height: computedHeight + 'px' }">
<Bar v-if="rows.length" :data="chartData" :options="options" />
<div v-else class="flex align-items-center justify-content-center h-full text-muted text-sm">
دادهای برای نمایش وجود ندارد
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import { usePersianDate } from '@/composables/usePersianDate';
import { CHART_COLORS } from '@/utils/chartTheme';
const props = defineProps({
rows: { type: Array, default: () => [] }, // [{ className, actualReceivedRevenue, pendingReceivables }]
limit: { type: Number, default: 8 }
});
const { toPersianDigits } = usePersianDate();
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
const topRows = computed(() => props.rows.slice(0, props.limit));
const computedHeight = computed(() => Math.max(220, topRows.value.length * 46));
const chartData = computed(() => ({
labels: topRows.value.map((r) => r.className),
datasets: [
{
label: 'وصول‌شده',
data: topRows.value.map((r) => r.actualReceivedRevenue),
backgroundColor: CHART_COLORS.received,
borderRadius: 5,
maxBarThickness: 18
},
{
label: 'مطالبات معوق',
data: topRows.value.map((r) => r.pendingReceivables),
backgroundColor: CHART_COLORS.outstanding,
borderRadius: 5,
maxBarThickness: 18
}
]
}));
const options = computed(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
align: 'end',
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
},
tooltip: {
rtl: true,
textDirection: 'rtl',
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.x)}`
}
}
},
scales: {
x: {
stacked: false,
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
ticks: { color: CHART_COLORS.muted, callback: (v) => toPersianDigits(Number(v).toLocaleString()) }
},
y: {
grid: { display: false },
ticks: { color: CHART_COLORS.muted }
}
}
}));
</script>
<style scoped>
.chart-wrap {
width: 100%;
position: relative;
}
</style>
@@ -0,0 +1,90 @@
<!-- /src/components/financialReports/charts/IncomeTrendChart.vue -->
<!-- Line chart comparing cash actually received against accrued income earned from held sessions. -->
<template>
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
<Line :data="chartData" :options="options" />
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import { usePersianDate } from '@/composables/usePersianDate';
import { CHART_COLORS } from '@/utils/chartTheme';
const props = defineProps({
labels: { type: Array, default: () => [] },
received: { type: Array, default: () => [] },
sessionIncome: { type: Array, default: () => [] },
height: { type: Number, default: 260 }
});
const { toPersianDigits } = usePersianDate();
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: 'وجوه دریافتی (نقدی)',
data: props.received,
borderColor: CHART_COLORS.received,
backgroundColor: CHART_COLORS.receivedSoft,
tension: 0.35,
fill: true,
pointRadius: 2,
pointHoverRadius: 5,
borderWidth: 2
},
{
label: 'درآمد تعهدی (بر اساس جلسات برگزارشده)',
data: props.sessionIncome,
borderColor: CHART_COLORS.sessionIncome,
backgroundColor: CHART_COLORS.sessionIncomeSoft,
tension: 0.35,
fill: true,
pointRadius: 2,
pointHoverRadius: 5,
borderWidth: 2,
borderDash: [5, 4]
}
]
}));
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
align: 'end',
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
},
tooltip: {
rtl: true,
textDirection: 'rtl',
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.y)}`
}
}
},
scales: {
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted, maxRotation: 0, autoSkip: true } },
y: {
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
ticks: {
color: CHART_COLORS.muted,
callback: (v) => toPersianDigits(Number(v).toLocaleString())
}
}
}
}));
</script>
<style scoped>
.chart-wrap {
width: 100%;
position: relative;
}
</style>
@@ -0,0 +1,106 @@
<!-- /src/components/financialReports/charts/MonthlyBreakdownChart.vue -->
<!-- Mixed bar+line chart: monthly received / professor payouts / general expenses (bars) vs. net profit (line). -->
<template>
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
<Chart type="bar" :data="chartData" :options="options" />
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Chart } from 'vue-chartjs';
import { usePersianDate } from '@/composables/usePersianDate';
import { CHART_COLORS } from '@/utils/chartTheme';
const props = defineProps({
labels: { type: Array, default: () => [] },
received: { type: Array, default: () => [] },
professorPayouts: { type: Array, default: () => [] },
generalExpenses: { type: Array, default: () => [] },
netProfit: { type: Array, default: () => [] },
height: { type: Number, default: 280 }
});
const { toPersianDigits } = usePersianDate();
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
type: 'bar',
label: 'وجوه دریافتی',
data: props.received,
backgroundColor: CHART_COLORS.received,
borderRadius: 6,
order: 2
},
{
type: 'bar',
label: 'سهم اساتید',
data: props.professorPayouts,
backgroundColor: CHART_COLORS.payout,
borderRadius: 6,
order: 2
},
{
type: 'bar',
label: 'هزینه‌های عمومی',
data: props.generalExpenses,
backgroundColor: CHART_COLORS.expense,
borderRadius: 6,
order: 2
},
{
type: 'line',
label: 'سود خالص',
data: props.netProfit,
borderColor: CHART_COLORS.netProfit,
backgroundColor: CHART_COLORS.netProfit,
borderWidth: 3,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
fill: false,
order: 1
}
]
}));
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
align: 'end',
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
},
tooltip: {
rtl: true,
textDirection: 'rtl',
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.y)}`
}
}
},
scales: {
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted } },
y: {
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
ticks: {
color: CHART_COLORS.muted,
callback: (v) => toPersianDigits(Number(v).toLocaleString())
}
}
}
}));
</script>
<style scoped>
.chart-wrap {
width: 100%;
position: relative;
}
</style>
@@ -0,0 +1,69 @@
<!-- /src/components/financialReports/charts/PaymentStatusChart.vue -->
<!-- Doughnut chart of the institute's payment records grouped by status (amount-weighted). -->
<template>
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
<Doughnut v-if="hasData" :data="chartData" :options="options" />
<div v-else class="flex align-items-center justify-content-center h-full text-muted text-sm">
داده‌ای برای نمایش وجود ندارد
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Doughnut } from 'vue-chartjs';
import { usePersianDate } from '@/composables/usePersianDate';
import { STATUS_COLORS, STATUS_LABELS_FA } from '@/utils/chartTheme';
const props = defineProps({
breakdown: { type: Array, default: () => [] }, // [{ status, count, amount }]
height: { type: Number, default: 240 }
});
const { toPersianDigits } = usePersianDate();
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
const hasData = computed(() => props.breakdown.some((b) => b.amount > 0));
const chartData = computed(() => ({
labels: props.breakdown.map((b) => STATUS_LABELS_FA[b.status] || b.status),
datasets: [
{
data: props.breakdown.map((b) => b.amount),
backgroundColor: props.breakdown.map((b) => STATUS_COLORS[b.status] || '#94a3b8'),
borderWidth: 0,
hoverOffset: 6
}
]
}));
const options = computed(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
},
tooltip: {
rtl: true,
textDirection: 'rtl',
callbacks: {
label: (ctx) => {
const item = props.breakdown[ctx.dataIndex];
const count = item ? toPersianDigits(item.count) : '';
return `${ctx.label}: ${formatToman(ctx.parsed)} (${count} پرداخت)`;
}
}
}
}
}));
</script>
<style scoped>
.chart-wrap {
width: 100%;
position: relative;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
// /src/utils/chartTheme.js
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Tooltip,
Legend,
Filler
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Tooltip,
Legend,
Filler
);
ChartJS.defaults.font.family = "'Vazirmatn', sans-serif";
ChartJS.defaults.font.size = 12;
ChartJS.defaults.color = '#94a3b8';
/** Shared semantic palette — kept consistent with the stat-card icon colors used across the dashboard. */
export const CHART_COLORS = {
received: '#10b981',
receivedSoft: 'rgba(16, 185, 129, 0.15)',
sessionIncome: '#3b82f6',
sessionIncomeSoft: 'rgba(59, 130, 246, 0.15)',
outstanding: '#f59e0b',
outstandingSoft: 'rgba(245, 158, 11, 0.15)',
overdue: '#ef4444',
overdueSoft: 'rgba(239, 68, 68, 0.15)',
payout: '#8b5cf6',
payoutSoft: 'rgba(139, 92, 246, 0.15)',
expense: '#f43f5e',
expenseSoft: 'rgba(244, 63, 94, 0.15)',
netProfit: '#14b8a6',
netProfitSoft: 'rgba(20, 184, 166, 0.15)',
gridLine: 'rgba(148, 163, 184, 0.15)',
muted: '#94a3b8'
};
export const STATUS_COLORS = {
paid: '#10b981',
partial: '#3b82f6',
pending: '#f59e0b',
overdue: '#ef4444'
};
export const STATUS_LABELS_FA = {
paid: 'تسویه‌شده',
partial: 'پرداخت جزئی',
pending: 'در انتظار پرداخت',
overdue: 'معوق'
};
export const baseGridOptions = () => ({
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
ticks: { color: CHART_COLORS.muted, font: { family: "'Vazirmatn', sans-serif" } }
});
export default ChartJS;
@@ -0,0 +1,339 @@
<!-- /src/views/financialReports/FinancialOverviewTab.vue -->
<!-- Institute-wide financial overview: KPI cards, income trends, monthly breakdown,
payment-status distribution, per-class leaderboard and a due-date driven cash-flow forecast. -->
<template>
<div class="financial-overview-tab">
<div v-if="isLoading && !analytics" class="text-center text-muted p-5">
<ProgressSpinner style="width: 40px; height: 40px;" />
</div>
<EmptyState v-else-if="!analytics" description="گزارش مالی مؤسسه در دسترس نیست" />
<div v-else class="flex flex-column gap-4">
<!-- KPI cards -->
<div class="grid">
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="کل وجوه وصول‌شده"
:value="analytics.overview.totalReceived"
icon="pi pi-check-circle"
bg-class="bg-green-100"
icon-class="text-green-600"
:hint="`از ${formatToman(analytics.overview.totalExpectedRevenue)} مورد انتظار`"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="مطالبات معوق (سررسیدگذشته)"
:value="analytics.overview.overdueAmount"
icon="pi pi-exclamation-triangle"
bg-class="bg-red-100"
icon-class="text-red-600"
:hint="`${toPersianDigits(analytics.overview.overdueCount)} قسط معوق`"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="در انتظار وصول (کل)"
:value="analytics.overview.totalPendingReceivables"
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="analytics.overview.netProfitAllTime"
icon="pi pi-chart-line"
:bg-class="analytics.overview.netProfitAllTime >= 0 ? 'bg-teal-100' : 'bg-red-100'"
:icon-class="analytics.overview.netProfitAllTime >= 0 ? 'text-teal-600' : 'text-red-600'"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="میانگین درآمد هر جلسه برگزارشده"
:value="analytics.overview.avgIncomePerHeldSession"
icon="pi pi-calculator"
bg-class="bg-blue-100"
icon-class="text-blue-600"
hint="بر اساس وجوه وصولشده"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="میانگین درآمد مورد انتظار هر جلسه"
:value="analytics.overview.avgExpectedIncomePerPlannedSession"
icon="pi pi-percentage"
bg-class="bg-indigo-100"
icon-class="text-indigo-600"
hint="بر اساس شهریههای ثبتشده"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="جلسات برگزارشده / برنامهریزیشده"
format="text"
:display-value="`${toPersianDigits(analytics.overview.totalSessionsHeld)} / ${toPersianDigits(analytics.overview.totalSessionsPlanned)}`"
icon="pi pi-calendar-plus"
bg-class="bg-purple-100"
icon-class="text-purple-600"
/>
</div>
<div class="col-12 sm:col-6 lg:col-3">
<FinancialStatCard
label="کلاسهای فعال / کل کلاسها"
format="text"
:display-value="`${toPersianDigits(analytics.overview.totalActiveClasses)} / ${toPersianDigits(analytics.overview.totalClasses)}`"
icon="pi pi-building"
bg-class="bg-cyan-100"
icon-class="text-cyan-600"
:hint="`${toPersianDigits(analytics.overview.totalEnrollments)} ثبت‌نام فعال`"
/>
</div>
</div>
<!-- ── Income trend (daily/weekly, cash vs. session-based accrual) ──── -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<div class="flex flex-column md:flex-row align-items-start md:align-items-center justify-content-between gap-3 mb-4">
<div>
<h3 class="text-base font-bold text-color m-0">روند درآمد بر اساس جلسات</h3>
<p class="text-xs text-muted m-0 mt-1">مقایسه وجوه نقداً دریافتی با درآمد تعهدی حاصل از جلسات برگزارشده</p>
</div>
<SelectButton
v-model="granularity"
:options="granularityOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
/>
</div>
<IncomeTrendChart :labels="trendLabels" :received="trendReceived" :session-income="trendSessionIncome" />
</div>
<!-- ── Monthly breakdown ─────────────────────────────────────────── -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<div class="flex align-items-center justify-content-between mb-4">
<div>
<h3 class="text-base font-bold text-color m-0">گزارش کامل درآمد مؤسسه ({{ toPersianDigits(analytics.monthly.length) }} ماه اخیر)</h3>
<p class="text-xs text-muted m-0 mt-1">دریافتی، سهم اساتید، هزینه‌های عمومی و سود خالص در هر ماه</p>
</div>
</div>
<MonthlyBreakdownChart
:labels="monthlyLabels"
:received="monthlyReceived"
:professor-payouts="monthlyPayouts"
:general-expenses="monthlyExpenses"
:net-profit="monthlyNetProfit"
/>
</div>
<!-- ── Payment status + Forecast ────────────────────────────────── -->
<div class="grid">
<div class="col-12 lg:col-5">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت مطالبات (بر اساس مبلغ)</h3>
<PaymentStatusChart :breakdown="analytics.paymentStatusBreakdown" />
</div>
</div>
<div class="col-12 lg:col-7">
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
<h3 class="text-base font-bold text-color m-0 mb-1">پیش‌بینی وصولی بر اساس سررسید اقساط</h3>
<p class="text-xs text-muted m-0 mb-4">۸ هفته پیش‌رو — بر اساس تاریخ سررسید تراکنش‌های در انتظار پرداخت</p>
<div class="grid mb-3">
<div class="col-4">
<div class="surface-100 border-round p-2 text-center">
<div class="text-xs text-muted mb-1">۷ روز آینده</div>
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next7DaysAmount) }}</div>
</div>
</div>
<div class="col-4">
<div class="surface-100 border-round p-2 text-center">
<div class="text-xs text-muted mb-1">۳۰ روز آینده</div>
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next30DaysAmount) }}</div>
</div>
</div>
<div class="col-4">
<div class="surface-100 border-round p-2 text-center">
<div class="text-xs text-muted mb-1">۶۰ روز آینده</div>
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next60DaysAmount) }}</div>
</div>
</div>
</div>
<ForecastChart :labels="forecastLabels" :amounts="forecastAmounts" :counts="forecastCounts" />
</div>
</div>
</div>
<!-- ── Per-class leaderboard: chart + table ─────────────────────── -->
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
<h3 class="text-base font-bold text-color m-0 mb-4">عملکرد مالی به تفکیک کلاس (درآمد هر جلسه)</h3>
<IncomeByClassChart :rows="analytics.incomeByClass" />
</div>
<div>
<h3 class="text-base font-bold text-color mb-3">جدول کامل درآمد به تفکیک کلاس</h3>
<DataTable
:value="analytics.incomeByClass"
class="p-datatable-sm text-sm"
emptyMessage="هنوز کلاسی با گزارش مالی ثبت نشده است"
:paginator="analytics.incomeByClass.length > 10"
:rows="10"
>
<Column header="کلاس">
<template #body="{ data }">
<span class="font-semibold text-color">{{ data.className }}</span>
</template>
</Column>
<Column header="استاد">
<template #body="{ data }">{{ data.professorName }}</template>
</Column>
<Column header="دانشجویان">
<template #body="{ data }">{{ toPersianDigits(data.studentsCount) }}</template>
</Column>
<Column header="جلسات (برگزار/برنامه)">
<template #body="{ data }">{{ toPersianDigits(data.sessionsHeld) }} / {{ toPersianDigits(data.sessionsPlanned) }}</template>
</Column>
<Column header="وصولشده">
<template #body="{ data }">{{ formatToman(data.actualReceivedRevenue) }}</template>
</Column>
<Column header="معوق">
<template #body="{ data }">{{ formatToman(data.pendingReceivables) }}</template>
</Column>
<Column header="درآمد هر جلسه (واقعی)">
<template #body="{ data }"><span class="font-semibold text-green-600">{{ formatToman(data.incomePerSessionActual) }}</span></template>
</Column>
<Column header="درآمد هر جلسه (مورد انتظار)">
<template #body="{ data }">{{ formatToman(data.incomePerSessionExpected) }}</template>
</Column>
</DataTable>
</div>
<!-- ── Upcoming / overdue due-date schedule ─────────────────────── -->
<div>
<div class="flex align-items-center justify-content-between mb-3">
<h3 class="text-base font-bold text-color m-0">جدول سررسید پرداخت‌ها (اقساط دانشجویان)</h3>
<Tag :value="`${toPersianDigits(analytics.upcomingDue.length)} قسط`" severity="secondary" />
</div>
<DataTable
:value="analytics.upcomingDue"
class="p-datatable-sm text-sm"
emptyMessage="قسط در انتظار پرداختی یافت نشد"
:paginator="analytics.upcomingDue.length > 10"
:rows="10"
>
<Column header="دانشجو">
<template #body="{ data }">{{ data.studentName }}</template>
</Column>
<Column header="کلاس">
<template #body="{ data }">{{ data.className }}</template>
</Column>
<Column header="مبلغ قسط">
<template #body="{ data }">{{ formatToman(data.amount) }}</template>
</Column>
<Column header="تاریخ سررسید">
<template #body="{ data }">{{ formatJalali(data.dueDate) }}</template>
</Column>
<Column header="وضعیت">
<template #body="{ data }">
<Tag
:value="dueStatusLabel(data)"
:severity="dueStatusSeverity(data)"
class="text-xs font-semibold"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { usePersianDate } from '@/composables/usePersianDate';
import { useToast } from '@/composables/useToast';
import { financialReportApi } from '@/api/financialReportApi';
import EmptyState from '@/components/common/EmptyState.vue';
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
import IncomeTrendChart from '@/components/financialReports/charts/IncomeTrendChart.vue';
import MonthlyBreakdownChart from '@/components/financialReports/charts/MonthlyBreakdownChart.vue';
import PaymentStatusChart from '@/components/financialReports/charts/PaymentStatusChart.vue';
import IncomeByClassChart from '@/components/financialReports/charts/IncomeByClassChart.vue';
import ForecastChart from '@/components/financialReports/charts/ForecastChart.vue';
import ProgressSpinner from 'primevue/progressspinner';
import SelectButton from 'primevue/selectbutton';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Tag from 'primevue/tag';
const { toPersianDigits, formatJalali } = usePersianDate();
const { showError } = useToast();
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
const analytics = ref(null);
const isLoading = ref(false);
const granularity = ref('daily');
const granularityOptions = [
{ label: 'روزانه', value: 'daily' },
{ label: 'هفتگی', value: 'weekly' }
];
const trendLabels = computed(() => {
if (!analytics.value) return [];
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
const dateField = granularity.value === 'daily' ? 'date' : 'weekStart';
return rows.map((r) => formatJalali(r[dateField], 'jD jMMMM'));
});
const trendReceived = computed(() => {
if (!analytics.value) return [];
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
return rows.map((r) => r.received);
});
const trendSessionIncome = computed(() => {
if (!analytics.value) return [];
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
return rows.map((r) => r.sessionIncome);
});
const monthlyLabels = computed(() => (analytics.value ? analytics.value.monthly.map((m) => formatJalali(m.monthStart, 'jMMMM jYY')) : []));
const monthlyReceived = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.received) : []));
const monthlyPayouts = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.professorPayouts) : []));
const monthlyExpenses = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.generalExpenses) : []));
const monthlyNetProfit = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.netProfit) : []));
const forecastLabels = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => formatJalali(b.weekStart, 'jD jMMMM')) : []));
const forecastAmounts = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => b.expectedAmount) : []));
const forecastCounts = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => b.count) : []));
const dueStatusLabel = (row) => {
if (row.isOverdue) return `${toPersianDigits(Math.abs(row.daysUntilDue))} روز معوق`;
if (row.daysUntilDue === 0) return 'سررسید امروز';
if (row.daysUntilDue <= 7) return `${toPersianDigits(row.daysUntilDue)} روز دیگر`;
return `${toPersianDigits(row.daysUntilDue)} روز دیگر`;
};
const dueStatusSeverity = (row) => {
if (row.isOverdue) return 'danger';
if (row.daysUntilDue <= 7) return 'warn';
return 'info';
};
const loadAnalytics = async () => {
isLoading.value = true;
try {
const res = await financialReportApi.getAnalytics();
analytics.value = res.data || res;
} catch (err) {
showError(err);
analytics.value = null;
} finally {
isLoading.value = false;
}
};
onMounted(loadAnalytics);
defineExpose({ reload: loadAnalytics });
</script>
@@ -7,14 +7,22 @@
</PermissionGate>
</PageHeader>
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
<Tabs v-model:value="activeTab" class="surface-card border-round border-1 border-color shadow-sm">
<TabList>
<Tab value="0">گزارش کلاسها</Tab>
<Tab value="1">گزارش بازه زمانی / ماهانه</Tab>
<Tab value="0">نمای کلی و نمودارها</Tab>
<Tab value="1">گزارش کلاسها</Tab>
<Tab value="2">گزارش بازه زمانی / ماهانه</Tab>
</TabList>
<TabPanels>
<!-- Per-Class (Lifetime) Report -->
<!-- Institute-wide Overview: charts, trends & forecasts -->
<TabPanel value="0">
<div class="p-3">
<FinancialOverviewTab />
</div>
</TabPanel>
<!-- Per-Class (Lifetime) Report -->
<TabPanel value="1">
<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">
@@ -166,7 +174,7 @@
</TabPanel>
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
<TabPanel value="1">
<TabPanel value="2">
<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">
@@ -326,6 +334,7 @@ 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 FinancialOverviewTab from './FinancialOverviewTab.vue';
import Button from 'primevue/button';
import Dropdown from 'primevue/select';
import DataTable from 'primevue/datatable';
@@ -343,6 +352,8 @@ const route = useRoute();
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate();
const { showError } = useToast();
const activeTab = ref('0');
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
// ── Per-class report ──────────────────────────────────────────────────────
@@ -441,6 +452,7 @@ onMounted(async () => {
await loadClasses();
setThisMonth();
if (route.query.classId) {
activeTab.value = '1';
selectedClassId.value = String(route.query.classId);
loadClassReport();
}