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:
@@ -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>
|
||||
Reference in New Issue
Block a user