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