Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryHomeView.vue
T
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30

578 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
Alert as AAlert,
Avatar as AAvatar,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Row as ARow,
Spin as ASpin,
Typography,
theme,
} from 'ant-design-vue';
import type { Component } from 'vue';
import {
AccountBookOutlined,
ArrowDownOutlined,
ArrowUpOutlined,
BankOutlined,
CalendarOutlined,
FallOutlined,
RiseOutlined,
WalletOutlined,
} from '@ant-design/icons-vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart, PieChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
import type { BarSeriesOption, PieSeriesOption } from 'echarts/charts';
import type {
GridComponentOption,
LegendComponentOption,
TooltipComponentOption,
} from 'echarts/components';
import VChart from 'vue-echarts';
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import {
TREASURY_DASHBOARD_MOCK,
isTreasuryDashboardEmpty,
} from './treasury-dashboard-mock';
import { toman } from './treasury-format';
use([CanvasRenderer, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
type ChartOption = ComposeOption<
| BarSeriesOption
| PieSeriesOption
| GridComponentOption
| TooltipComponentOption
| LegendComponentOption
>;
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
function num(value: string | number | null | undefined) {
const n = Number(value ?? 0);
return Number.isFinite(n) ? n : 0;
}
type MetricKey = keyof Pick<
TreasuryDashboardDto,
| 'bookedIncome'
| 'actualReceipts'
| 'bookedExpense'
| 'actualPayments'
| 'operatingProfit'
| 'receivables'
| 'payables'
| 'totalBalance'
>;
type MetricCard = {
key: MetricKey;
label: string;
hint: string;
icon: Component;
};
const data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const loading = ref(true);
const usingMock = ref(false);
const heroCards: MetricCard[] = [
{
key: 'totalBalance',
label: 'موجودی حساب‌ها',
hint: 'صندوق و بانک',
icon: WalletOutlined,
},
{
key: 'operatingProfit',
label: 'سود یا زیان عملیاتی',
hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده',
icon: RiseOutlined,
},
{
key: 'receivables',
label: 'مطالبات وصول‌نشده',
hint: 'درآمد بدون دریافت کامل',
icon: CalendarOutlined,
},
];
const secondaryCards: MetricCard[] = [
{
key: 'bookedIncome',
label: 'درآمد ثبت‌شده',
hint: 'تعهدی',
icon: ArrowDownOutlined,
},
{
key: 'actualReceipts',
label: 'دریافت واقعی',
hint: 'ورود به حساب',
icon: BankOutlined,
},
{
key: 'bookedExpense',
label: 'هزینه ثبت‌شده',
hint: 'تعهدی',
icon: FallOutlined,
},
{
key: 'actualPayments',
label: 'پرداخت واقعی',
hint: 'خروج از حساب',
icon: ArrowUpOutlined,
},
{
key: 'payables',
label: 'تعهدات پرداخت',
hint: 'هزینه بدون پرداخت کامل',
icon: AccountBookOutlined,
},
];
const softCardStyle = computed(() => ({
background: token.value.colorPrimaryBg,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadow,
height: '100%',
}));
/** کارت سفید بدون بردر (KPI فرعی و چارت‌ها) */
const secondaryCardStyle = computed(() => ({
background: token.value.colorBgContainer,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: token.value.boxShadow,
height: '100%',
}));
/** Soft teal family — primary + muted companions (not loud rainbow) */
const chartColors = computed(() => {
const primary = token.value.colorPrimary;
return {
primary,
primarySoft: token.value.colorPrimaryBg,
muted: token.value.colorTextQuaternary || '#a7f3d0',
ink: token.value.colorTextSecondary,
axis: token.value.colorBorderSecondary,
tooltipBg: token.value.colorBgElevated,
font: token.value.fontFamily,
};
});
const flowChartOption = computed<ChartOption>(() => {
if (!data.value) return {};
const c = chartColors.value;
const categories = ['درآمد تعهدی', 'دریافت واقعی', 'هزینه تعهدی', 'پرداخت واقعی'];
const values = [
num(data.value.bookedIncome),
num(data.value.actualReceipts),
num(data.value.bookedExpense),
num(data.value.actualPayments),
];
return {
color: [c.primary, '#34d399', '#6ee7b7', '#a7f3d0'],
textStyle: { fontFamily: c.font, color: c.ink },
tooltip: {
trigger: 'axis',
backgroundColor: c.tooltipBg,
borderColor: c.axis,
textStyle: { fontFamily: c.font, color: c.ink },
valueFormatter: (v) => toman(Number(v)),
},
grid: { left: 8, right: 12, top: 24, bottom: 8, containLabel: true },
xAxis: {
type: 'category',
data: categories,
axisLine: { lineStyle: { color: c.axis } },
axisTick: { show: false },
axisLabel: {
fontFamily: c.font,
color: c.ink,
interval: 0,
rotate: 0,
fontSize: 11,
},
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: c.axis, type: 'dashed' } },
axisLabel: {
fontFamily: c.font,
color: c.ink,
formatter: (v: number) =>
new Intl.NumberFormat('fa-IR', { notation: 'compact' }).format(v),
},
},
series: [
{
type: 'bar',
data: values.map((value, index) => ({
value,
itemStyle: {
color: [c.primary, '#34d399', '#6ee7b7', '#a7f3d0'][index],
borderRadius: [8, 8, 0, 0],
},
})),
barMaxWidth: 36,
},
],
};
});
const mixChartOption = computed<ChartOption>(() => {
if (!data.value) return {};
const c = chartColors.value;
const slices = [
{ name: 'موجودی حساب‌ها', value: Math.max(0, num(data.value.totalBalance)) },
{ name: 'مطالبات', value: Math.max(0, num(data.value.receivables)) },
{ name: 'تعهدات', value: Math.max(0, num(data.value.payables)) },
];
const hasValue = slices.some((s) => s.value > 0);
return {
color: [c.primary, '#34d399', '#a7f3d0'],
textStyle: { fontFamily: c.font, color: c.ink },
tooltip: {
trigger: 'item',
backgroundColor: c.tooltipBg,
borderColor: c.axis,
textStyle: { fontFamily: c.font, color: c.ink },
formatter: (params: unknown) => {
const p = params as { name?: string; value?: number; percent?: number };
return `${p.name}<br/>${toman(p.value)} (${p.percent ?? 0}٪)`;
},
},
legend: {
bottom: 0,
icon: 'circle',
itemWidth: 8,
itemHeight: 8,
textStyle: { fontFamily: c.font, color: c.ink, fontSize: 12 },
},
series: [
{
type: 'pie',
radius: ['52%', '72%'],
center: ['50%', '46%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 8,
borderColor: token.value.colorBgContainer,
borderWidth: 3,
},
label: { show: false },
data: hasValue
? slices
: [{ name: 'بدون داده', value: 1, itemStyle: { color: c.primarySoft } }],
},
],
};
});
onMounted(async () => {
loading.value = true;
usingMock.value = false;
try {
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
if (isTreasuryDashboardEmpty(res.data)) {
data.value = TREASURY_DASHBOARD_MOCK;
usingMock.value = true;
} else {
data.value = res.data;
}
} catch {
data.value = TREASURY_DASHBOARD_MOCK;
usingMock.value = true;
error.value = '';
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<a-flex vertical :gap="token.margin">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
<a-typography-text type="secondary">
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
</a-typography-text>
</div>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert
v-if="usingMock"
type="info"
show-icon
message="در حال نمایش دادهٔ نمونه (ماک) برای پیش‌نمایش داشبورد هستید."
description="وقتی سند و حساب واقعی ثبت شود، همین صفحه دادهٔ واقعی API را نشان می‌دهد."
/>
<a-spin :spinning="loading">
<template v-if="data">
<a-row :gutter="[token.marginSM, token.marginSM]">
<a-col
v-for="card in heroCards"
:key="card.key"
:xs="24"
:sm="12"
:lg="8"
>
<a-card
:bordered="false"
:body-style="{ padding: `${token.padding}px` }"
:style="softCardStyle"
>
<a-flex align="center" justify="space-between" :gap="token.marginSM">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
<div>
<a-typography-text
strong
:style="{
fontSize: '22px',
lineHeight: 1.3,
color: token.colorPrimary,
}"
>
{{ toman(data[card.key]) }}
</a-typography-text>
</div>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
{{ card.hint }}
</a-typography-text>
</div>
<a-avatar
:size="48"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><component :is="card.icon" /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
<a-col
v-for="card in secondaryCards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:lg="8"
>
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="secondaryCardStyle"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
{{ card.label }}
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(data[card.key]) }}
</a-typography-text>
</div>
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
{{ card.hint }}
</a-typography-text>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimaryBg,
color: token.colorPrimary,
flexShrink: 0,
}"
>
<template #icon><component :is="card.icon" /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
<a-col :xs="24" :lg="14">
<a-card
:bordered="false"
:style="secondaryCardStyle"
>
<a-typography-text strong>جریان تعهدی و نقدی</a-typography-text>
<a-typography-text
type="secondary"
:style="{ display: 'block', marginBottom: `${token.marginSM}px`, fontSize: '12px' }"
>
مقایسه درآمد/هزینه ثبت‌شده با دریافت و پرداخت واقعی
</a-typography-text>
<VChart
:option="flowChartOption"
autoresize
:style="{ height: '260px', width: '100%' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :lg="10">
<a-card
:bordered="false"
:style="secondaryCardStyle"
>
<a-typography-text strong>ترکیب وضعیت مالی</a-typography-text>
<a-typography-text
type="secondary"
:style="{ display: 'block', marginBottom: `${token.marginSM}px`, fontSize: '12px' }"
>
سهم موجودی، مطالبات و تعهدات
</a-typography-text>
<VChart
:option="mixChartOption"
autoresize
:style="{ height: '260px', width: '100%' }"
/>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[token.marginSM, token.marginSM]" :style="{ marginTop: `${token.margin}px` }">
<a-col :xs="24" :lg="8">
<a-card
:bordered="false"
title="اقساط نزدیک یا معوق"
:style="secondaryCardStyle"
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
>
<a-empty
v-if="!data.upcomingInstallments.length"
description="قسط بازی نیست."
:image-style="{ height: '48px' }"
/>
<a-flex v-else vertical :gap="token.marginXS">
<a-flex
v-for="item in data.upcomingInstallments"
:key="item.id"
justify="space-between"
align="flex-start"
:gap="8"
>
<div :style="{ minWidth: 0 }">
<a-typography-text>قسط {{ item.position }}</a-typography-text>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
{{ item.status === 'overdue' ? 'معوق' : item.status === 'partial' ? 'جزئی' : 'باز' }}
· مانده {{ toman(item.remaining) }}
</a-typography-text>
</div>
</div>
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
{{ toman(item.amount) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card
:bordered="false"
title="پرداخت‌های آینده"
:style="secondaryCardStyle"
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
>
<a-empty
v-if="!data.upcomingPayables.length"
description="تعهد پرداخت بازی نیست."
:image-style="{ height: '48px' }"
/>
<a-flex v-else vertical :gap="token.marginXS">
<a-flex
v-for="item in data.upcomingPayables"
:key="item.id"
justify="space-between"
align="flex-start"
:gap="8"
>
<div :style="{ minWidth: 0 }">
<a-typography-text>{{ item.title }}</a-typography-text>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
مانده {{ toman(item.remaining) }}
</a-typography-text>
</div>
</div>
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
{{ toman(item.amount) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card
:bordered="false"
title="آخرین اسناد"
:style="secondaryCardStyle"
:body-style="{ paddingBlock: `${token.paddingSM}px` }"
>
<a-empty
v-if="!data.recent.length"
description="هنوز سندی ثبت نشده است."
:image-style="{ height: '48px' }"
/>
<a-flex v-else vertical :gap="token.marginXS">
<a-flex
v-for="item in data.recent"
:key="item.id"
justify="space-between"
align="flex-start"
:gap="8"
>
<div :style="{ minWidth: 0 }">
<a-typography-text>{{ item.title }}</a-typography-text>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
</a-typography-text>
</div>
</div>
<a-typography-text strong :style="{ color: token.colorPrimary, whiteSpace: 'nowrap' }">
{{ toman(item.amount) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</a-col>
</a-row>
</template>
</a-spin>
</a-flex>
</div>
</template>