Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryReceivablesView.vue
T
MahdiandCursor cb5944e767 fix(finance): shared treasury layout so tab switches stop jumping
Keep title and nav mounted in TreasuryLayout with stable header height, and finish Ant form chrome (MoneyInput, Jalali) on finance pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 21:47:00 +03:30

395 lines
13 KiB
Vue

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Tag as ATag,
Typography,
message,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
const rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const form = ref({
title: '',
partyName: '',
totalAmount: '',
prepaidAmount: '',
accountId: '',
count: '3',
interval: 'monthly',
startDate: '',
});
const collect = ref({ installmentId: '', receivableId: '', amount: '', accountId: '' });
const custom = ref(false);
const customLines = ref([{ amount: '', dueDate: '' }]);
const statusLabel: Record<string, string> = {
open: 'باز',
partial: 'جزئی',
settled: 'تسویه‌شده',
overdue: 'معوق',
void: 'باطل',
};
const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: 'قسط', dataIndex: 'position', key: 'position' },
{
title: 'سررسید',
key: 'dueDate',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
},
{
title: 'مبلغ',
key: 'amount',
customRender: ({ record }) => toman(record.amount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }) => toman(record.remaining),
},
{
title: 'وضعیت',
key: 'status',
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
},
{ title: '', key: 'actions' },
];
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 || 'عملیات ناموفق بود.';
}
async function load() {
const [list, acc] = await Promise.all([
api.get<FinancialReceivableDto[]>('/treasury/receivables'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = list.data;
accounts.value = acc.data.filter((row) => row.isActive);
}
async function downloadExcel() {
const { data } = await api.get<Blob>('/treasury/receivables/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'installments.xlsx';
link.click();
URL.revokeObjectURL(url);
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createContract() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const totalAmount = parseMoneyNumber(String(form.value.totalAmount ?? ''), 0);
if (totalAmount == null || totalAmount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
const prepaidAmount = parseMoneyNumber(String(form.value.prepaidAmount ?? ''), 0) ?? 0;
const installments = custom.value
? customLines.value.map((line) => ({
amount: parseMoneyNumber(String(line.amount ?? ''), 0) ?? 0,
dueDate: line.dueDate,
}))
: undefined;
await api.post('/treasury/receivables', {
title: form.value.title,
partyName: form.value.partyName || null,
totalAmount,
prepaidAmount,
accountId: form.value.accountId || null,
...(custom.value
? { installments }
: {
count: Number(form.value.count),
interval: form.value.interval,
startDate: form.value.startDate || undefined,
}),
});
ok.value = 'قرارداد و برنامه اقساط ثبت شد.';
form.value.title = '';
form.value.totalAmount = '';
form.value.prepaidAmount = '';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function collectInstallment() {
if (!collect.value.installmentId) return;
saving.value = true;
error.value = '';
try {
const amount = parseMoneyNumber(String(collect.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ دریافت معتبر نیست.';
return;
}
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
amount,
accountId: collect.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'دریافت قسط در همان حساب ثبت شد.';
collect.value = { installmentId: '', receivableId: '', amount: '', accountId: '' };
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
collect.value = {
installmentId: item.id,
receivableId: row.id,
amount: item.remaining,
accountId: accounts.value[0]?.id || '',
};
}
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-divider orientation="right">قرارداد جدید</a-divider>
<a-form
layout="vertical"
:model="form"
@finish="createContract"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<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 :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item
label="مبلغ کل"
name="totalAmount"
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
>
<MoneyInput v-model="form.totalAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<MoneyInput v-model="form.prepaidAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select
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">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تعداد قسط" name="count">
<a-input v-model:value="form.count" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<JalaliDateInput v-model="form.startDate" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24">
<a-form-item>
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
</a-form-item>
</a-col>
</a-row>
<template v-if="custom">
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`">
<MoneyInput v-model="line.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سررسید">
<JalaliDateInput v-model="line.dueDate" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
</a-form-item>
</template>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
<a-button @click="downloadExcel">خروجی اکسل</a-button>
</a-space>
</a-form>
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form
layout="vertical"
:model="collect"
@finish="collectInstallment"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="collect.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item
label="حساب مقصد"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="collect.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
</a-form>
</template>
<a-divider orientation="right">قراردادها</a-divider>
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
<template v-else>
<div v-for="row in rows" :key="row.id">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
<div>
<a-typography-text strong>{{ row.title }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
</a-typography-text>
</div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex>
<a-table
:columns="installmentColumns"
:data-source="row.installments"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="record.status !== 'settled'"
type="link"
@click="startCollect(row, record as InstallmentRow)"
>
دریافت
</a-button>
</template>
</template>
</a-table>
<a-divider />
</div>
</template>
</a-card>
</a-flex>
</template>