Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryReportsView.vue
T
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:20:00 +03:30

288 lines
10 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 axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Table as ATable,
Tabs as ATabs,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import * as XLSX from 'xlsx';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane;
type Period = {
bookedIncome: string;
invoiceSales: string;
invoiceCredits: string;
bookedExpense: string;
operatingProfit: string;
cashIn: string;
cashOut: string;
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
type MetricRow = {
key: string;
label: string;
current: string;
previous: string;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
const reportTab = ref('periods');
const error = ref('');
const balanceError = ref('');
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
user: 'کاربر',
};
const lines = [
['درآمد ثبت‌شده', 'bookedIncome'],
['فروش فاکتور', 'invoiceSales'],
['فاکتور برگشتی', 'invoiceCredits'],
['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'],
['خروج وجه', 'cashOut'],
['خالص جریان نقدی', 'cashNet'],
] as const;
const metricColumns: TableColumnsType<MetricRow> = [
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
];
const metricRows = computed<MetricRow[]>(() => {
if (!current.value || !previous.value) return [];
return lines.map(([label, key]) => ({
key,
label,
current: toman(current.value![key] || '0'),
previous: toman(previous.value![key] || '0'),
}));
});
async function load() {
const { data } = await api.get<{
current: Period;
previous: Period;
payablesByMonth: Array<{ month: string; amount: string }>;
receivablesByParty: Array<{ name: string; amount: string }>;
}>('/treasury/reports');
current.value = data.current;
previous.value = data.previous;
payablesByMonth.value = data.payablesByMonth;
receivablesByParty.value = data.receivablesByParty;
}
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
}
function exportParties(
rows: TreasuryPartyBalanceDto[],
title: string,
settledLabel: string,
filename: string,
) {
const body = rows.map((row) => [
row.partyName,
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
Number(row.amount),
Number(row.settledAmount),
Number(row.remaining),
]);
const totals = rows.reduce(
(sum, row) => ({
amount: sum.amount + Number(row.amount || 0),
settled: sum.settled + Number(row.settledAmount || 0),
remaining: sum.remaining + Number(row.remaining || 0),
}),
{ amount: 0, settled: 0, remaining: 0 },
);
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
const sheet = XLSX.utils.aoa_to_sheet([
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
...body,
]);
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, title);
XLSX.writeFile(book, filename);
}
async function loadBalances() {
const [income, expense] = await Promise.all([
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
]);
debtors.value = income.data;
creditors.value = expense.data;
}
async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'treasury-report.xlsx';
link.click();
URL.revokeObjectURL(url);
}
onMounted(() => {
void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.';
});
void loadBalances().catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
debtors.value = [];
creditors.value = [];
return;
}
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
});
});
</script>
<template>
<a-flex vertical :gap="16">
<a-flex justify="flex-end">
<a-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
<a-card :bordered="false">
<a-tabs v-model:activeKey="reportTab">
<a-tab-pane key="periods" tab="مقایسه دوره‌ها">
<a-flex vertical :gap="16">
<a-table
v-if="metricRows.length"
:columns="metricColumns"
:data-source="metricRows"
:pagination="false"
row-key="key"
/>
<a-empty v-else description="داده‌ای برای نمایش نیست." />
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.month }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>
{{ toman(item.amount) }} · {{ item.share }}٪
</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</a-flex>
</a-tab-pane>
<a-tab-pane key="debtors" tab="بدهکاران">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور صادرشده و اسناد دریافتِ باز. صدور فاکتور سند درآمد جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
</a-typography-text>
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافت‌شده', 'debtors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافت‌شده" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="creditors" tab="بستانکاران">
<a-flex vertical :gap="16">
<a-typography-text type="secondary">
ماندهٔ فاکتور برگشتی و اسناد هزینهٔ باز. برگشتی سند هزینهٔ جدا نمی‌سازد.
</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text type="secondary">
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
</a-typography-text>
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداخت‌شده', 'creditors.xlsx')">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداخت‌شده" />
</a-flex>
</a-tab-pane>
</a-tabs>
</a-card>
</a-flex>
</template>