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>
288 lines
10 KiB
Vue
288 lines
10 KiB
Vue
<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>
|