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
@@ -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>