Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryReceivablesView.vue
T
MahdiandCursor 1ccbefa7a3 fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:02:31 +03:30

396 lines
14 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 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="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 show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly" label="هفتگی">هفتگی</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 show-search option-filter-prop="label"
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"
:label="account.name"
>
{{ 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>