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>
396 lines
14 KiB
Vue
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>
|