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:
Generated
+30
@@ -11,6 +11,7 @@
|
|||||||
"@fontsource/vazirmatn": "^5.3.0",
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
"@primevue/themes": "4.0.7",
|
"@primevue/themes": "4.0.7",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"filepond": "^4.32.12",
|
"filepond": "^4.32.12",
|
||||||
"filepond-plugin-file-validate-type": "^1.2.9",
|
"filepond-plugin-file-validate-type": "^1.2.9",
|
||||||
"filepond-plugin-image-preview": "^4.6.12",
|
"filepond-plugin-image-preview": "^4.6.12",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-filepond": "^8.0.0",
|
"vue-filepond": "^8.0.0",
|
||||||
"vue-i18n": "^11.4.8",
|
"vue-i18n": "^11.4.8",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
@@ -280,6 +282,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.143.0",
|
"version": "0.143.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
||||||
@@ -1277,6 +1285,18 @@
|
|||||||
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
|
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -3265,6 +3285,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-filepond": {
|
"node_modules/vue-filepond": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@fontsource/vazirmatn": "^5.3.0",
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
"@primevue/themes": "4.0.7",
|
"@primevue/themes": "4.0.7",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"filepond": "^4.32.12",
|
"filepond": "^4.32.12",
|
||||||
"filepond-plugin-file-validate-type": "^1.2.9",
|
"filepond-plugin-file-validate-type": "^1.2.9",
|
||||||
"filepond-plugin-image-preview": "^4.6.12",
|
"filepond-plugin-image-preview": "^4.6.12",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-filepond": "^8.0.0",
|
"vue-filepond": "^8.0.0",
|
||||||
"vue-i18n": "^11.4.8",
|
"vue-i18n": "^11.4.8",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ import axiosInstance from './axiosInstance';
|
|||||||
export const financialReportApi = {
|
export const financialReportApi = {
|
||||||
getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`),
|
getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`),
|
||||||
getSessionReport: (sessionId) => axiosInstance.get(`/financial-reports/admin/sessions/${sessionId}`),
|
getSessionReport: (sessionId) => axiosInstance.get(`/financial-reports/admin/sessions/${sessionId}`),
|
||||||
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params })
|
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params }),
|
||||||
|
getAnalytics: (params) => axiosInstance.get('/financial-reports/admin/analytics', { params })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<span class="text-muted text-xs block mb-1">{{ label }}</span>
|
<span class="text-muted text-xs block mb-1">{{ label }}</span>
|
||||||
<span class="text-xl font-bold text-color">{{ formattedValue }}</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>
|
||||||
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="bgClass">
|
<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>
|
<i :class="[icon, 'text-xl', iconClass]"></i>
|
||||||
@@ -20,10 +21,19 @@ const props = defineProps({
|
|||||||
value: { type: Number, default: 0 },
|
value: { type: Number, default: 0 },
|
||||||
icon: { type: String, default: 'pi pi-wallet' },
|
icon: { type: String, default: 'pi pi-wallet' },
|
||||||
bgClass: { type: String, default: 'bg-blue-100' },
|
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 { 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>
|
</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>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// /src/utils/chartTheme.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
ChartJS.defaults.font.family = "'Vazirmatn', sans-serif";
|
||||||
|
ChartJS.defaults.font.size = 12;
|
||||||
|
ChartJS.defaults.color = '#94a3b8';
|
||||||
|
|
||||||
|
/** Shared semantic palette — kept consistent with the stat-card icon colors used across the dashboard. */
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
received: '#10b981',
|
||||||
|
receivedSoft: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
sessionIncome: '#3b82f6',
|
||||||
|
sessionIncomeSoft: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
outstanding: '#f59e0b',
|
||||||
|
outstandingSoft: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
overdue: '#ef4444',
|
||||||
|
overdueSoft: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
payout: '#8b5cf6',
|
||||||
|
payoutSoft: 'rgba(139, 92, 246, 0.15)',
|
||||||
|
expense: '#f43f5e',
|
||||||
|
expenseSoft: 'rgba(244, 63, 94, 0.15)',
|
||||||
|
netProfit: '#14b8a6',
|
||||||
|
netProfitSoft: 'rgba(20, 184, 166, 0.15)',
|
||||||
|
gridLine: 'rgba(148, 163, 184, 0.15)',
|
||||||
|
muted: '#94a3b8'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
paid: '#10b981',
|
||||||
|
partial: '#3b82f6',
|
||||||
|
pending: '#f59e0b',
|
||||||
|
overdue: '#ef4444'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS_FA = {
|
||||||
|
paid: 'تسویهشده',
|
||||||
|
partial: 'پرداخت جزئی',
|
||||||
|
pending: 'در انتظار پرداخت',
|
||||||
|
overdue: 'معوق'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const baseGridOptions = () => ({
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: { color: CHART_COLORS.muted, font: { family: "'Vazirmatn', sans-serif" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChartJS;
|
||||||
@@ -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>
|
||||||
@@ -7,14 +7,22 @@
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
|
<Tabs v-model:value="activeTab" class="surface-card border-round border-1 border-color shadow-sm">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">گزارش کلاسها</Tab>
|
<Tab value="0">نمای کلی و نمودارها</Tab>
|
||||||
<Tab value="1">گزارش بازه زمانی / ماهانه</Tab>
|
<Tab value="1">گزارش کلاسها</Tab>
|
||||||
|
<Tab value="2">گزارش بازه زمانی / ماهانه</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- ── Per-Class (Lifetime) Report ────────────────────────────────── -->
|
<!-- ── Institute-wide Overview: charts, trends & forecasts ─────────── -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
|
<div class="p-3">
|
||||||
|
<FinancialOverviewTab />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── Per-Class (Lifetime) Report ────────────────────────────────── -->
|
||||||
|
<TabPanel value="1">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex flex-column md:flex-row gap-2 mb-4 align-items-end">
|
<div class="flex flex-column md:flex-row gap-2 mb-4 align-items-end">
|
||||||
<div class="flex flex-column gap-2 flex-grow-1 md:max-w-25rem">
|
<div class="flex flex-column gap-2 flex-grow-1 md:max-w-25rem">
|
||||||
@@ -166,7 +174,7 @@
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
|
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="2">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex flex-column md:flex-row gap-3 mb-4 align-items-end">
|
<div class="flex flex-column md:flex-row gap-3 mb-4 align-items-end">
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
@@ -326,6 +334,7 @@ import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
|||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
|
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
|
||||||
|
import FinancialOverviewTab from './FinancialOverviewTab.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
@@ -343,6 +352,8 @@ const route = useRoute();
|
|||||||
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate();
|
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate();
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const activeTab = ref('0');
|
||||||
|
|
||||||
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
|
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
// ── Per-class report ──────────────────────────────────────────────────────
|
// ── Per-class report ──────────────────────────────────────────────────────
|
||||||
@@ -441,6 +452,7 @@ onMounted(async () => {
|
|||||||
await loadClasses();
|
await loadClasses();
|
||||||
setThisMonth();
|
setThisMonth();
|
||||||
if (route.query.classId) {
|
if (route.query.classId) {
|
||||||
|
activeTab.value = '1';
|
||||||
selectedClassId.value = String(route.query.classId);
|
selectedClassId.value = String(route.query.classId);
|
||||||
loadClassReport();
|
loadClassReport();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user