Files
gameno-dashboard/src/views/financialReports/FinancialOverviewTab.vue
T
kavehhn 39921b548e 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.
2026-08-23 18:24:35 +03:30

340 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- /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>