feat(treasury): separate receipts from income and fit finance on a phone

A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-10 12:28:27 +03:30
co-authored by Cursor
parent 49459707cb
commit d12d7b91cb
16 changed files with 626 additions and 537 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
@@ -14,11 +14,13 @@ import {
Input as AInput,
List as AList,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
@@ -26,18 +28,20 @@ import type {
FinancialPartyKind,
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 { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const isMobile = useTreasuryMobile();
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
@@ -58,10 +62,17 @@ const form = ref({
settleAmount: '',
method: 'transfer',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
});
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'پرداخت', value: 'settlement' },
{ label: 'هزینه', value: 'accrual' },
];
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'جزئی',
@@ -134,46 +145,61 @@ async function createDoc() {
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
error.value = settlement ? 'مبلغ پرداخت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
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) {
if (settlement && form.value.partyKind === 'other') {
error.value = 'برای پرداخت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ پرداخت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const openBalance = partyKey
? Number(
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
const payable = partyKey
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance =
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0;
const receivable = partyKey
? Number(receivableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (payable > 0 || receivable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
settlement,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
categoryId: settlement ? null : form.value.categoryId || 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,
settleAmount: settlement ? amount : settleAmount,
method: form.value.method,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
settleMirror.reset();
form.value = {
@@ -186,6 +212,7 @@ async function createDoc() {
settleAmount: '',
method: 'transfer',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
ok.value = collectsBalance
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
@@ -204,6 +231,9 @@ const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای پرداخت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
@@ -211,6 +241,33 @@ const partyRules = [
},
];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
@@ -244,7 +301,10 @@ onMounted(() => {
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form
layout="vertical"
:model="form"
@@ -256,7 +316,12 @@ onMounted(() => {
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
<TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
@@ -273,7 +338,7 @@ onMounted(() => {
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item
label="مبلغ کل"
name="amount"
@@ -283,11 +348,25 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
<a-form-item
:label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
@@ -302,7 +381,7 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-form-item label="حساب" name="accountId" :rules="accountRules">
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
@@ -324,8 +403,18 @@ onMounted(() => {
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :span="24">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
@@ -349,10 +438,10 @@ onMounted(() => {
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
@@ -363,7 +452,29 @@ onMounted(() => {
ابطال
</a-button>
</template>
<a-list-item-meta>
<a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>