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