diff --git a/package-lock.json b/package-lock.json index 60f8d2b..894eacb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/vazirmatn": "^5.3.0", "@primevue/themes": "4.0.7", "axios": "^1.19.0", + "chart.js": "^4.5.1", "filepond": "^4.32.12", "filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-image-preview": "^4.6.12", @@ -24,6 +25,7 @@ "sass": "^1.102.0", "vee-validate": "^4.15.1", "vue": "^3.5.40", + "vue-chartjs": "^5.3.4", "vue-filepond": "^8.0.0", "vue-i18n": "^11.4.8", "vue-router": "^5.2.0", @@ -280,6 +282,12 @@ "@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": { "version": "0.143.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", @@ -1277,6 +1285,18 @@ "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "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": { "version": "5.0.0", "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": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz", diff --git a/package.json b/package.json index 950f385..1fbe860 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@fontsource/vazirmatn": "^5.3.0", "@primevue/themes": "4.0.7", "axios": "^1.19.0", + "chart.js": "^4.5.1", "filepond": "^4.32.12", "filepond-plugin-file-validate-type": "^1.2.9", "filepond-plugin-image-preview": "^4.6.12", @@ -26,6 +27,7 @@ "sass": "^1.102.0", "vee-validate": "^4.15.1", "vue": "^3.5.40", + "vue-chartjs": "^5.3.4", "vue-filepond": "^8.0.0", "vue-i18n": "^11.4.8", "vue-router": "^5.2.0", diff --git a/src/api/financialReportApi.js b/src/api/financialReportApi.js index ca329b9..41a4a67 100644 --- a/src/api/financialReportApi.js +++ b/src/api/financialReportApi.js @@ -4,5 +4,6 @@ import axiosInstance from './axiosInstance'; export const financialReportApi = { getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`), 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 }) }; diff --git a/src/components/financialReports/FinancialStatCard.vue b/src/components/financialReports/FinancialStatCard.vue index 7b6e546..c13d5c7 100644 --- a/src/components/financialReports/FinancialStatCard.vue +++ b/src/components/financialReports/FinancialStatCard.vue @@ -4,6 +4,7 @@
{{ label }} {{ formattedValue }} + {{ hint }}
@@ -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())} تومان`; +}); diff --git a/src/components/financialReports/charts/ForecastChart.vue b/src/components/financialReports/charts/ForecastChart.vue new file mode 100644 index 0000000..bffb0c8 --- /dev/null +++ b/src/components/financialReports/charts/ForecastChart.vue @@ -0,0 +1,70 @@ + + + + + + + diff --git a/src/components/financialReports/charts/IncomeByClassChart.vue b/src/components/financialReports/charts/IncomeByClassChart.vue new file mode 100644 index 0000000..73de680 --- /dev/null +++ b/src/components/financialReports/charts/IncomeByClassChart.vue @@ -0,0 +1,86 @@ + + + + + + + diff --git a/src/components/financialReports/charts/IncomeTrendChart.vue b/src/components/financialReports/charts/IncomeTrendChart.vue new file mode 100644 index 0000000..d8ade22 --- /dev/null +++ b/src/components/financialReports/charts/IncomeTrendChart.vue @@ -0,0 +1,90 @@ + + + + + + + diff --git a/src/components/financialReports/charts/MonthlyBreakdownChart.vue b/src/components/financialReports/charts/MonthlyBreakdownChart.vue new file mode 100644 index 0000000..3d281ed --- /dev/null +++ b/src/components/financialReports/charts/MonthlyBreakdownChart.vue @@ -0,0 +1,106 @@ + + + + + + + diff --git a/src/components/financialReports/charts/PaymentStatusChart.vue b/src/components/financialReports/charts/PaymentStatusChart.vue new file mode 100644 index 0000000..d771f4b --- /dev/null +++ b/src/components/financialReports/charts/PaymentStatusChart.vue @@ -0,0 +1,69 @@ + + + + + + + diff --git a/src/utils/chartTheme.js b/src/utils/chartTheme.js new file mode 100644 index 0000000..adf66a4 --- /dev/null +++ b/src/utils/chartTheme.js @@ -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; diff --git a/src/views/financialReports/FinancialOverviewTab.vue b/src/views/financialReports/FinancialOverviewTab.vue new file mode 100644 index 0000000..eabd9a5 --- /dev/null +++ b/src/views/financialReports/FinancialOverviewTab.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/src/views/financialReports/FinancialReportsView.vue b/src/views/financialReports/FinancialReportsView.vue index f88f04f..43c3512 100644 --- a/src/views/financialReports/FinancialReportsView.vue +++ b/src/views/financialReports/FinancialReportsView.vue @@ -7,14 +7,22 @@ - + - گزارش کلاس‌ها - گزارش بازه زمانی / ماهانه + نمای کلی و نمودارها + گزارش کلاس‌ها + گزارش بازه زمانی / ماهانه - + +
+ +
+
+ + +
@@ -166,7 +174,7 @@ - +
@@ -326,6 +334,7 @@ import TableSkeleton from '@/components/common/TableSkeleton.vue'; import StatusTag from '@/components/common/StatusTag.vue'; import PermissionGate from '@/components/common/PermissionGate.vue'; import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue'; +import FinancialOverviewTab from './FinancialOverviewTab.vue'; import Button from 'primevue/button'; import Dropdown from 'primevue/select'; import DataTable from 'primevue/datatable'; @@ -343,6 +352,8 @@ const route = useRoute(); const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate(); const { showError } = useToast(); +const activeTab = ref('0'); + const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`; // ── Per-class report ────────────────────────────────────────────────────── @@ -441,6 +452,7 @@ onMounted(async () => { await loadClasses(); setThisMonth(); if (route.query.classId) { + activeTab.value = '1'; selectedClassId.value = String(route.query.classId); loadClassReport(); }