From 49459707cb72a1c2c79e63193d23d01b122695e5 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 10 Nov 2026 11:09:51 +0330 Subject: [PATCH] fix(treasury): show a party's net balance as debtor or creditor Income and expense forms now use the same receivable-minus-payable figure beside the party. Co-authored-by: Cursor --- .../views/treasury/TreasuryExpensesView.vue | 28 +++++++++------ .../views/treasury/TreasuryIncomesView.vue | 34 ++++++++++++------- .../web/src/views/treasury/treasury-format.ts | 8 +++++ 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 2f75786..847a96d 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -30,7 +30,7 @@ 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 { partyNetPosition, toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; const { Text: ATypographyText } = Typography; @@ -41,6 +41,7 @@ const ASelectOption = ASelect.Option; const kind = 'expense' as const; const rows = ref([]); const balances = ref([]); +const receivableBalances = ref([]); const categories = ref([]); const accounts = ref([]); const error = ref(''); @@ -94,7 +95,7 @@ function docLine(item: FinancialDocumentDto) { } async function load() { - const [docs, cats, acc, bals] = await Promise.all([ + const [docs, cats, acc, bals, receivables] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }).catch((e: unknown) => { if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) { @@ -112,9 +113,14 @@ async function load() { if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; throw e; }), + api.get('/treasury/party-balances', { params: { kind: 'income' } }).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; + receivableBalances.value = receivables.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); } @@ -205,12 +211,14 @@ const partyRules = [ }, ]; -const partyRemainingLabel = computed(() => { - const kind = form.value.partyKind; +const partyPosition = computed(() => { + const partyKind = form.value.partyKind; const id = form.value.partyId; - if (!kind || kind === 'other' || !id) return ''; - const row = balances.value.find((item) => item.key === `${kind}:${id}`); - return `مانده ${toman(row?.remaining || 0)}`; + if (!partyKind || partyKind === 'other' || !id) return null; + const key = `${partyKind}:${id}`; + const receivable = Number(receivableBalances.value.find((item) => item.key === key)?.remaining || 0); + const payable = Number(balances.value.find((item) => item.key === key)?.remaining || 0); + return partyNetPosition(receivable, payable); }); async function voidDoc(id: string) { @@ -247,11 +255,11 @@ onMounted(() => { - + - - {{ partyRemainingLabel }} + + {{ partyPosition.text }} diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 385639f..9e4bade 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -50,7 +50,7 @@ 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 { partyNetPosition, toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; const { Text: ATypographyText } = Typography; @@ -64,6 +64,7 @@ const isMobile = computed(() => !screens.value.md); const kind = 'income' as const; const rows = ref([]); const balances = ref([]); +const payableBalances = ref([]); const categories = ref([]); const accounts = ref([]); const dashboard = ref(null); @@ -132,12 +133,14 @@ const partyRules = [ }, ]; -const partyRemainingLabel = computed(() => { - const kind = form.value.partyKind; +const partyPosition = computed(() => { + const partyKind = form.value.partyKind; const id = form.value.partyId; - if (!kind || kind === 'other' || !id) return ''; - const row = balances.value.find((item) => item.key === `${kind}:${id}`); - return `مانده ${toman(row?.remaining || 0)}`; + if (!partyKind || partyKind === 'other' || !id) return null; + const key = `${partyKind}:${id}`; + const receivable = Number(balances.value.find((item) => item.key === key)?.remaining || 0); + const payable = Number(payableBalances.value.find((item) => item.key === key)?.remaining || 0); + return partyNetPosition(receivable, payable); }); const remainingAmount = computed(() => { @@ -168,7 +171,7 @@ function docLine(item: FinancialDocumentDto) { } async function load() { - const [docs, cats, acc, dash, bals] = await Promise.all([ + const [docs, cats, acc, dash, bals, payables] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get('/treasury/categories', { params: { kind } }).catch((e: unknown) => { if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) { @@ -187,9 +190,14 @@ async function load() { if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; throw e; }), + api.get('/treasury/party-balances', { params: { kind: 'expense' } }).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; + payableBalances.value = payables.data; categories.value = cats.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive); if (dash) dashboard.value = dash.data; @@ -394,11 +402,11 @@ onMounted(() => {
- + - - {{ partyRemainingLabel }} + + {{ partyPosition.text }} @@ -641,11 +649,11 @@ onMounted(() => { - + - - {{ partyRemainingLabel }} + + {{ partyPosition.text }} diff --git a/apps/web/src/views/treasury/treasury-format.ts b/apps/web/src/views/treasury/treasury-format.ts index 8ed8d11..7c347b6 100644 --- a/apps/web/src/views/treasury/treasury-format.ts +++ b/apps/web/src/views/treasury/treasury-format.ts @@ -3,3 +3,11 @@ export function toman(value: string | number | null | undefined) { if (!Number.isFinite(n)) return '۰'; return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`; } + +/** ماندهٔ دریافتنی منهای ماندهٔ پرداختنی: بد قرمز، بس سبز، صفر خاکستری. */ +export function partyNetPosition(receivable: number, payable: number) { + const net = receivable - payable; + if (net > 0) return { text: `بد ${toman(net)}`, type: 'danger' as const }; + if (net < 0) return { text: `بس ${toman(-net)}`, type: 'success' as const }; + return { text: `مانده ${toman(0)}`, type: 'secondary' as const }; +}