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>
498 lines
19 KiB
Vue
498 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import axios from 'axios';
|
|
import {
|
|
Alert as AAlert,
|
|
Button as AButton,
|
|
Card as ACard,
|
|
Col as ACol,
|
|
Divider as ADivider,
|
|
Empty as AEmpty,
|
|
Flex as AFlex,
|
|
Form as AForm,
|
|
FormItem as AFormItem,
|
|
Grid,
|
|
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,
|
|
FinancialDocumentDto,
|
|
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';
|
|
|
|
const { Text: ATypographyText } = Typography;
|
|
const AListItem = AList.Item;
|
|
const AListItemMeta = AList.Item.Meta;
|
|
const ASelectOption = ASelect.Option;
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = computed(() => !screens.value.md);
|
|
|
|
const kind = 'income' as const;
|
|
const rows = ref<FinancialDocumentDto[]>([]);
|
|
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
|
const payableBalances = ref<TreasuryPartyBalanceDto[]>([]);
|
|
const categories = ref<FinancialCategoryDto[]>([]);
|
|
const accounts = ref<FinancialAccountDto[]>([]);
|
|
const error = ref('');
|
|
const ok = ref('');
|
|
const saving = ref(false);
|
|
const q = ref('');
|
|
|
|
function emptyForm() {
|
|
return {
|
|
title: '',
|
|
amount: '',
|
|
categoryId: undefined as string | undefined,
|
|
partyKind: undefined as FinancialPartyKind | undefined,
|
|
partyId: undefined as string | undefined,
|
|
accountId: undefined as string | undefined,
|
|
settleAmount: '',
|
|
method: 'transfer',
|
|
notes: '',
|
|
bookedAt: formatLocalDateInput(new Date()),
|
|
};
|
|
}
|
|
|
|
const form = ref(emptyForm());
|
|
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: 'جزئی',
|
|
settled: 'دریافتشده',
|
|
void: 'باطلشده',
|
|
};
|
|
|
|
const methodOptions = [
|
|
{ value: 'cash', label: 'نقدی' },
|
|
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
|
{ value: 'pos', label: 'کارتخوان' },
|
|
{ value: 'transfer', label: 'حواله' },
|
|
{ value: 'other', label: 'سایر' },
|
|
];
|
|
|
|
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('طرف حساب را انتخاب کنید');
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
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 partyPosition = computed(() => {
|
|
const partyKind = form.value.partyKind;
|
|
const id = form.value.partyId;
|
|
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);
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
|
|
function onFinishFailed() {
|
|
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
|
}
|
|
|
|
function apiMessage(e: unknown) {
|
|
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
|
}
|
|
|
|
function docLine(item: FinancialDocumentDto) {
|
|
const parts = [
|
|
item.reference ? `پیگیری ${item.reference}` : '',
|
|
item.partyName || 'بدون طرف حساب',
|
|
statusLabel[item.status] ?? item.status,
|
|
`مانده ${toman(item.remaining)}`,
|
|
].filter(Boolean);
|
|
if (item.categoryName) parts.push(item.categoryName);
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
async function load() {
|
|
const [docs, cats, acc, bals, payables] = await Promise.all([
|
|
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
|
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
|
|
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
|
|
return { data: [] as FinancialCategoryDto[] };
|
|
}
|
|
throw e;
|
|
}),
|
|
api.get<FinancialAccountDto[]>('/treasury/accounts').catch((e: unknown) => {
|
|
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
|
|
return { data: [] as FinancialAccountDto[] };
|
|
}
|
|
throw e;
|
|
}),
|
|
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;
|
|
}),
|
|
api.get<TreasuryPartyBalanceDto[]>('/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);
|
|
}
|
|
|
|
function resetForm() {
|
|
settleMirror.reset();
|
|
form.value = emptyForm();
|
|
error.value = '';
|
|
ok.value = '';
|
|
}
|
|
|
|
async function createDoc() {
|
|
saving.value = true;
|
|
error.value = '';
|
|
ok.value = '';
|
|
try {
|
|
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 = settlement ? 'مبلغ دریافتشده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
|
|
return;
|
|
}
|
|
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
|
error.value = 'طرف حساب را انتخاب کنید.';
|
|
return;
|
|
}
|
|
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 receivable = partyKey
|
|
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
|
|
: 0;
|
|
const payable = partyKey
|
|
? Number(payableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
|
|
: 0;
|
|
const collectsBalance = settlement && (receivable > 0 || payable > 0);
|
|
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
|
kind,
|
|
settlement,
|
|
title: form.value.title,
|
|
amount,
|
|
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: settlement ? amount : settleAmount,
|
|
method: form.value.method,
|
|
notes: form.value.notes || null,
|
|
bookedAt: form.value.bookedAt || undefined,
|
|
});
|
|
resetForm();
|
|
ok.value = collectsBalance
|
|
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
|
|
: data.reference
|
|
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
|
: 'سند ثبت شد.';
|
|
await load();
|
|
} catch (e) {
|
|
error.value = apiMessage(e);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function voidDoc(id: string) {
|
|
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
|
try {
|
|
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
|
await load();
|
|
} catch (e) {
|
|
error.value = apiMessage(e);
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void load().catch(() => {
|
|
error.value = 'بارگذاری ناموفق بود.';
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<a-flex vertical :gap="16">
|
|
<a-alert v-if="error" type="error" show-icon :message="error" />
|
|
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
|
|
|
<a-card :bordered="false">
|
|
<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"
|
|
@finish="createDoc"
|
|
@finishFailed="onFinishFailed"
|
|
>
|
|
<a-row :gutter="[16, 0]">
|
|
<a-col :xs="24" :md="12">
|
|
<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"
|
|
: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>
|
|
</a-col>
|
|
</a-row>
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="12">
|
|
<a-form-item
|
|
label="عنوان"
|
|
name="title"
|
|
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
|
>
|
|
<a-input v-model:value="form.title" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col v-if="!isSettlement" :xs="24" :md="8">
|
|
<a-form-item
|
|
label="مبلغ کل"
|
|
name="amount"
|
|
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
|
>
|
|
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<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 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"
|
|
allow-clear
|
|
style="width: 100%"
|
|
placeholder="بدون دسته"
|
|
>
|
|
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
|
{{ cat.name }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item label="حساب" name="accountId" :rules="accountRules">
|
|
<a-select show-search option-filter-prop="label"
|
|
v-model:value="form.accountId"
|
|
allow-clear
|
|
style="width: 100%"
|
|
placeholder="انتخاب حساب"
|
|
>
|
|
<a-select-option
|
|
v-for="account in accounts"
|
|
:key="account.id"
|
|
:value="account.id"
|
|
:label="account.name"
|
|
>
|
|
{{ account.name }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item label="روش" name="method">
|
|
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
|
|
<a-select-option
|
|
v-for="opt in methodOptions"
|
|
:key="opt.value"
|
|
:value="opt.value"
|
|
:label="opt.label"
|
|
>
|
|
{{ opt.label }}
|
|
</a-select-option>
|
|
</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" :xs="24" :md="16">
|
|
<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" :block="isMobile" :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]">
|
|
<a-col :xs="24" :md="12">
|
|
<a-form-item label="جستجو">
|
|
<a-input
|
|
v-model:value="q"
|
|
allow-clear
|
|
placeholder="عنوان یا شماره پیگیری"
|
|
@press-enter="load"
|
|
@change="load"
|
|
/>
|
|
</a-form-item>
|
|
</a-col>
|
|
</a-row>
|
|
</a-form>
|
|
|
|
<a-empty v-if="!rows.length" description="سندی نیست." />
|
|
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
|
|
<template #renderItem="{ item }">
|
|
<a-list-item>
|
|
<template v-if="!isMobile" #actions>
|
|
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
|
<a-button
|
|
v-if="item.status !== 'void'"
|
|
type="link"
|
|
danger
|
|
@click="voidDoc(item.id)"
|
|
>
|
|
ابطال
|
|
</a-button>
|
|
</template>
|
|
<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>
|
|
</a-list-item>
|
|
</template>
|
|
</a-list>
|
|
</a-card>
|
|
</a-flex>
|
|
</template>
|