221 lines
8.4 KiB
Vue
221 lines
8.4 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { onMounted, ref } from 'vue';
|
||
|
|
import axios from 'axios';
|
||
|
|
import { api } from '@/lib/api';
|
||
|
|
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||
|
|
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||
|
|
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||
|
|
import { toman } from './treasury-format';
|
||
|
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
||
|
|
|
||
|
|
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: 'باطل',
|
||
|
|
};
|
||
|
|
|
||
|
|
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 createContract() {
|
||
|
|
saving.value = true;
|
||
|
|
error.value = '';
|
||
|
|
ok.value = '';
|
||
|
|
try {
|
||
|
|
await api.post('/treasury/receivables', {
|
||
|
|
title: form.value.title,
|
||
|
|
partyName: form.value.partyName || null,
|
||
|
|
totalAmount: form.value.totalAmount,
|
||
|
|
prepaidAmount: form.value.prepaidAmount || 0,
|
||
|
|
accountId: form.value.accountId || null,
|
||
|
|
...(custom.value
|
||
|
|
? { installments: customLines.value }
|
||
|
|
: {
|
||
|
|
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 {
|
||
|
|
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
|
||
|
|
amount: collect.value.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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
void load().catch(() => {
|
||
|
|
error.value = 'بارگذاری مطالبات ناموفق بود.';
|
||
|
|
});
|
||
|
|
});
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<section class="page">
|
||
|
|
<UiPageHeader
|
||
|
|
title="اقساط و مطالبات"
|
||
|
|
description="دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند."
|
||
|
|
/>
|
||
|
|
<TreasuryNav />
|
||
|
|
<p v-if="error" class="err">{{ error }}</p>
|
||
|
|
<p v-if="ok" class="ok">{{ ok }}</p>
|
||
|
|
<UiCard title="قرارداد جدید">
|
||
|
|
<div class="grid">
|
||
|
|
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||
|
|
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||
|
|
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
|
||
|
|
<UiField label="پیشپرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
|
||
|
|
<UiField label="حساب پیشپرداخت">
|
||
|
|
<select v-model="form.accountId" class="select">
|
||
|
|
<option value="">انتخاب</option>
|
||
|
|
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||
|
|
</select>
|
||
|
|
</UiField>
|
||
|
|
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
|
||
|
|
<UiField label="دوره">
|
||
|
|
<select v-model="form.interval" class="select">
|
||
|
|
<option value="monthly">ماهانه</option>
|
||
|
|
<option value="weekly">هفتگی</option>
|
||
|
|
</select>
|
||
|
|
</UiField>
|
||
|
|
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||
|
|
</div>
|
||
|
|
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||
|
|
<div v-if="custom" class="custom">
|
||
|
|
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||
|
|
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
|
||
|
|
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||
|
|
</div>
|
||
|
|
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||
|
|
</div>
|
||
|
|
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||
|
|
</UiCard>
|
||
|
|
|
||
|
|
<article v-for="row in rows" :key="row.id" class="contract">
|
||
|
|
<header>
|
||
|
|
<div>
|
||
|
|
<strong>{{ row.title }}</strong>
|
||
|
|
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
|
||
|
|
</div>
|
||
|
|
<b>{{ toman(row.totalAmount) }}</b>
|
||
|
|
</header>
|
||
|
|
<table>
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>قسط</th>
|
||
|
|
<th>سررسید</th>
|
||
|
|
<th>مبلغ</th>
|
||
|
|
<th>مانده</th>
|
||
|
|
<th>وضعیت</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
<tr v-for="item in row.installments" :key="item.id">
|
||
|
|
<td>{{ item.position }}</td>
|
||
|
|
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
|
||
|
|
<td>{{ toman(item.amount) }}</td>
|
||
|
|
<td>{{ toman(item.remaining) }}</td>
|
||
|
|
<td>{{ statusLabel[item.status] }}</td>
|
||
|
|
<td>
|
||
|
|
<button
|
||
|
|
v-if="item.status !== 'settled'"
|
||
|
|
type="button"
|
||
|
|
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
|
||
|
|
>
|
||
|
|
دریافت
|
||
|
|
</button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</article>
|
||
|
|
|
||
|
|
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||
|
|
<div class="grid">
|
||
|
|
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||
|
|
<UiField label="حساب مقصد">
|
||
|
|
<select v-model="collect.accountId" class="select">
|
||
|
|
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||
|
|
</select>
|
||
|
|
</UiField>
|
||
|
|
</div>
|
||
|
|
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
|
||
|
|
</UiCard>
|
||
|
|
</section>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.page { display: grid; gap: 1rem; }
|
||
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||
|
|
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||
|
|
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
|
||
|
|
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||
|
|
.contract small { display: block; color: #64748b; }
|
||
|
|
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
|
||
|
|
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
|
||
|
|
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||
|
|
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
|
||
|
|
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
|
||
|
|
.err { color: #b91c1c; font-weight: 700; }
|
||
|
|
.ok { color: #0f766e; font-weight: 700; }
|
||
|
|
</style>
|