feat(treasury): show who still owes on the finance pages

Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 11:36:55 +03:30
co-authored by Cursor
parent 1ccbefa7a3
commit d71c2dbef4
11 changed files with 755 additions and 38 deletions
@@ -19,8 +19,17 @@ import {
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -41,13 +51,15 @@ const form = ref({
title: '',
amount: '',
categoryId: '',
partyName: '',
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = {
@@ -71,13 +83,28 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
function docLine(item: FinancialDocumentDto) {
const parts = [
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
];
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc] = await Promise.all([
const [docs, cats, acc, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
@@ -96,24 +123,37 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
settleMirror.reset();
form.value = {
title: '',
amount: '',
categoryId: '',
partyName: '',
partyKind: undefined,
partyId: undefined,
accountId: '',
settleAmount: '',
method: 'transfer',
@@ -180,7 +220,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" />
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -198,8 +238,8 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -234,6 +274,9 @@ onMounted(() => {
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداخت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
@@ -268,11 +311,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -37,7 +37,6 @@ import {
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
@@ -45,10 +44,15 @@ import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
@@ -62,6 +66,7 @@ const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
@@ -80,7 +85,8 @@ function emptyForm() {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyName: '',
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
@@ -91,6 +97,7 @@ function emptyForm() {
}
const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
@@ -137,20 +144,36 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
function docLine(item: FinancialDocumentDto) {
const parts = [
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
];
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc, dash] = await Promise.all([
const [docs, cats, acc, dash, bals] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
settleMirror.reset();
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
@@ -167,13 +190,24 @@ async function createDoc() {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
@@ -358,10 +392,8 @@ onMounted(() => {
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -426,7 +458,12 @@ onMounted(() => {
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
<MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
@@ -511,6 +548,8 @@ onMounted(() => {
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
@@ -540,11 +579,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
@@ -614,7 +649,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput v-model="form.settleAmount" :decimals="0" />
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -632,8 +667,8 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -688,6 +723,8 @@ onMounted(() => {
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
@@ -722,11 +759,7 @@ onMounted(() => {
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>