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.
340 lines
16 KiB
Vue
340 lines
16 KiB
Vue
<!-- /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>
|