Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryHomeView.vue
T

578 lines
19 KiB
Vue
Raw Normal View History

<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>