Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
578 lines
19 KiB
Vue
578 lines
19 KiB
Vue
<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>
|