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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user