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.
This commit is contained in:
2026-11-03 16:51:51 +03:30
parent 631a3267d1
commit 26792f9a37
80 changed files with 19982 additions and 24020 deletions
+528 -113
View File
@@ -1,53 +1,311 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
Alert as AAlert,
Avatar as AAvatar,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Row as ARow,
Statistic as AStatistic,
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 AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
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 cards = [
{ key: 'bookedIncome', label: 'درآمد ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const;
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');
data.value = res.data;
if (isTreasuryDashboardEmpty(res.data)) {
data.value = TREASURY_DASHBOARD_MOCK;
usingMock.value = true;
} else {
data.value = res.data;
}
} catch {
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
data.value = TREASURY_DASHBOARD_MOCK;
usingMock.value = true;
error.value = '';
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<a-flex vertical :gap="16">
<a-flex vertical :gap="token.margin">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
<a-typography-text type="secondary">
@@ -58,105 +316,262 @@ onMounted(async () => {
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert
v-if="usingMock"
type="info"
show-icon
message="در حال نمایش دادهٔ نمونه (ماک) برای پیش‌نمایش داشبورد هستید."
description="وقتی سند و حساب واقعی ثبت شود، همین صفحه دادهٔ واقعی API را نشان می‌دهد."
/>
<template v-if="data">
<a-row :gutter="[16, 16]">
<a-col
v-for="card in cards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:xl="6"
>
<a-card :bordered="false">
<a-statistic :title="card.label" :value="toman(data[card.key])" />
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
<a-empty
v-if="!data.upcomingInstallments.length"
description="قسط بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingInstallments"
<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"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>قسط {{ item.position }}</template>
<template #description>
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
<a-col :xs="24" :lg="12">
<a-divider orientation="right">پرداخت‌های آینده</a-divider>
<a-empty
v-if="!data.upcomingPayables.length"
description="تعهد پرداخت بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingPayables"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>مانده {{ toman(item.remaining) }}</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
<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-divider orientation="right">آخرین اسناد</a-divider>
<a-empty
v-if="!data.recent.length"
description="هنوز سندی ثبت نشده است."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.recent"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</template>
<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>
@@ -0,0 +1,187 @@
import type {
FinancialDocumentDto,
FinancialInstallmentDto,
TreasuryDashboardDto,
} from '@mento/shared';
function daysFromNow(days: number) {
const d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() + days);
return d.toISOString();
}
function doc(
partial: Pick<
FinancialDocumentDto,
'id' | 'kind' | 'title' | 'amount' | 'settledAmount' | 'remaining' | 'status' | 'categoryName' | 'partyName'
> &
Partial<FinancialDocumentDto>,
): FinancialDocumentDto {
return {
bookedAt: daysFromNow(-3),
dueAt: daysFromNow(10),
categoryId: null,
personId: null,
accountId: null,
accountName: 'صندوق اصلی',
method: 'transfer',
reference: null,
notes: null,
createdAt: daysFromNow(-2),
...partial,
};
}
function installment(
partial: FinancialInstallmentDto,
): FinancialInstallmentDto {
return partial;
}
/** دیتای نمایشی برای پیش‌نمایش داشبورد وقتی API خالی است */
export const TREASURY_DASHBOARD_MOCK: TreasuryDashboardDto = {
bookedIncome: '185000000',
actualReceipts: '142500000',
bookedExpense: '98000000',
actualPayments: '87500000',
operatingProfit: '87000000',
receivables: '42500000',
payables: '18500000',
totalBalance: '256000000',
upcomingInstallments: [
installment({
id: 'mock-inst-1',
position: 2,
dueDate: daysFromNow(-2),
amount: '12000000',
paidAmount: '0',
remaining: '12000000',
status: 'overdue',
}),
installment({
id: 'mock-inst-2',
position: 3,
dueDate: daysFromNow(5),
amount: '12000000',
paidAmount: '4000000',
remaining: '8000000',
status: 'partial',
}),
installment({
id: 'mock-inst-3',
position: 4,
dueDate: daysFromNow(35),
amount: '12000000',
paidAmount: '0',
remaining: '12000000',
status: 'open',
}),
],
upcomingPayables: [
doc({
id: 'mock-pay-1',
kind: 'expense',
title: 'اجاره دفتر — مهر',
amount: '45000000',
settledAmount: '30000000',
remaining: '15000000',
status: 'partial',
categoryName: 'اجاره',
partyName: 'موجر ساختمان',
}),
doc({
id: 'mock-pay-2',
kind: 'expense',
title: 'بیمه تکمیلی کارکنان',
amount: '8500000',
settledAmount: '5000000',
remaining: '3500000',
status: 'partial',
categoryName: 'بیمه',
partyName: 'بیمه ایران',
}),
],
recent: [
doc({
id: 'mock-doc-1',
kind: 'income',
title: 'پیش‌پرداخت پروژه آلفا',
amount: '75000000',
settledAmount: '75000000',
remaining: '0',
status: 'settled',
categoryName: 'فروش خدمات',
partyName: 'شرکت آریا',
bookedAt: daysFromNow(-1),
}),
doc({
id: 'mock-doc-2',
kind: 'expense',
title: 'خرید تجهیزات شبکه',
amount: '18500000',
settledAmount: '18500000',
remaining: '0',
status: 'settled',
categoryName: 'تجهیزات',
partyName: 'فروشگاه سخت‌افزار',
bookedAt: daysFromNow(-2),
}),
doc({
id: 'mock-doc-3',
kind: 'income',
title: 'قسط قرارداد بتا',
amount: '12000000',
settledAmount: '0',
remaining: '12000000',
status: 'open',
categoryName: 'فروش خدمات',
partyName: 'گروه سپهر',
bookedAt: daysFromNow(-4),
}),
doc({
id: 'mock-doc-4',
kind: 'expense',
title: 'حقوق مهر — تیم فروش',
amount: '42000000',
settledAmount: '42000000',
remaining: '0',
status: 'settled',
categoryName: 'حقوق',
partyName: null,
bookedAt: daysFromNow(-5),
}),
doc({
id: 'mock-doc-5',
kind: 'income',
title: 'فروش پکیج پشتیبانی',
amount: '28000000',
settledAmount: '10000000',
remaining: '18000000',
status: 'partial',
categoryName: 'پشتیبانی',
partyName: 'کارخانه نوین',
bookedAt: daysFromNow(-6),
}),
],
};
export function isTreasuryDashboardEmpty(data: TreasuryDashboardDto | null | undefined) {
if (!data) return true;
const keys = [
data.bookedIncome,
data.actualReceipts,
data.bookedExpense,
data.actualPayments,
data.totalBalance,
data.receivables,
data.payables,
] as const;
const allZero = keys.every((v) => !Number(v));
return (
allZero &&
data.recent.length === 0 &&
data.upcomingInstallments.length === 0 &&
data.upcomingPayables.length === 0
);
}