Files
CRM_Montzar/apps/web/src/views/ChequesView.vue
T
MahdiandCursor 96befd3867 feat: add treasury audit, attachment download, and installment SMS
Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:59:24 +03:30

490 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { CURRENCY_OPTIONS, formatMoney, parseMoneyNumber } from '@/lib/money';
import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import type {
ChequeDirection,
ChequeDto,
ChequeStatus,
CreateChequeRequest,
OrganizationDto,
PaginatedResponse,
PersonDto,
} from '@mento/shared';
const loading = ref(false);
const error = ref('');
const items = ref<ChequeDto[]>([]);
const status = ref('');
const q = ref('');
const savingId = ref('');
const clearOpen = ref(false);
const clearRow = ref<ChequeDto | null>(null);
const clearAccountId = ref('');
const clearAccounts = ref<Array<{ id: string; name: string }>>([]);
const tab = ref<ChequeDirection>('in');
const formOpen = ref(false);
const formDirection = ref<ChequeDirection>('in');
const saving = ref(false);
const formError = ref('');
const people = ref<PersonDto[]>([]);
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const amount = ref('');
const currency = ref('IRR');
const chequeNumber = ref('');
const chequeBank = ref('');
const dueDate = ref(formatLocalDateInput(new Date()));
const formStatus = ref<ChequeStatus>('pending');
const notes = ref('');
function statusLabelFor(direction: string): Record<ChequeStatus, string> {
return {
pending: 'در جریان',
cleared: direction === 'out' ? 'پرداخت شد' : 'وصول شد',
bounced: 'برگشت',
};
}
const formStatusLabel = computed(() => statusLabelFor(formDirection.value));
const isIncomingForm = computed(() => formDirection.value !== 'out');
const currencyOptions = computed(() => {
const codes = enabledCurrencies.value.length ? [...enabledCurrencies.value] : [currency.value];
if (currency.value && !codes.includes(currency.value)) codes.push(currency.value);
return codes.map((code) => {
const opt = CURRENCY_OPTIONS.find((c) => c.code === code);
return { code, label: opt ? `${opt.suffix} - ${opt.code}` : code };
});
});
function axiosMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function personLabel(p: PersonDto) {
return p.company?.name ? `${p.fullName} — ${p.company.name}` : p.fullName;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<ChequeDto[]>('/cheques', {
params: {
status: status.value || undefined,
q: q.value || undefined,
direction: tab.value,
},
});
items.value = data;
} catch (e) {
error.value = axiosMessage(e, 'بارگذاری چک‌ها ناموفق بود.');
} finally {
loading.value = false;
}
}
async function confirmClear() {
if (!clearRow.value || !clearAccountId.value) return;
savingId.value = clearRow.value.id;
error.value = '';
try {
const { data } = await api.patch<ChequeDto>(`/cheques/${clearRow.value.id}`, {
status: 'cleared',
financialAccountId: clearAccountId.value,
});
items.value = items.value.map((item) => (item.id === data.id ? data : item));
clearOpen.value = false;
} catch {
error.value = 'وصول چک ناموفق بود.';
} finally {
savingId.value = '';
}
}
async function setStatus(row: ChequeDto, next: ChequeStatus) {
if (row.status === next) return;
if (next === 'cleared' && row.status !== 'cleared') {
const accountsRes = await api
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
.catch(() => null);
if (!accountsRes) {
savingId.value = row.id;
try {
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
items.value = items.value.map((item) => (item.id === row.id ? data : item));
} catch {
error.value = 'تغییر وضعیت چک ناموفق بود.';
} finally {
savingId.value = '';
}
return;
}
const active = accountsRes.data.filter((item) => item.isActive);
if (!active.length) {
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
return;
}
clearAccounts.value = active.map((item) => ({ id: item.id, name: item.name }));
clearAccountId.value = active[0]!.id;
clearRow.value = row;
clearOpen.value = true;
return;
}
savingId.value = row.id;
try {
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
items.value = items.value.map((item) => (item.id === row.id ? data : item));
} catch {
error.value = 'تغییر وضعیت چک ناموفق بود.';
} finally {
savingId.value = '';
}
}
async function loadFormOptions() {
const [peopleRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', { params: { limit: 100 } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
if (!currency.value || !enabledCurrencies.value.includes(currency.value)) {
currency.value = orgRes.data.baseCurrency || 'IRR';
}
}
async function openForm(direction: ChequeDirection) {
formDirection.value = direction;
formError.value = '';
personId.value = '';
amount.value = '';
chequeNumber.value = '';
chequeBank.value = '';
dueDate.value = formatLocalDateInput(new Date());
formStatus.value = 'pending';
notes.value = '';
formOpen.value = true;
try {
await loadFormOptions();
} catch {
formError.value = 'بارگذاری اشخاص ناموفق بود.';
}
}
async function submitForm() {
const parsedAmount = parseMoneyNumber(String(amount.value ?? ''), 0);
if (!personId.value) {
formError.value = 'شخص را انتخاب کنید.';
return;
}
if (parsedAmount == null || parsedAmount <= 0) {
formError.value = 'مبلغ چک را وارد کنید.';
return;
}
if (!chequeNumber.value.trim() || !chequeBank.value.trim() || !dueDate.value) {
formError.value = 'شماره، بانک و سررسید چک لازم است.';
return;
}
saving.value = true;
formError.value = '';
try {
const body: CreateChequeRequest = {
personId: personId.value,
amount: parsedAmount,
currency: currency.value,
number: chequeNumber.value.trim(),
bank: chequeBank.value.trim(),
dueDate: new Date(dueDate.value).toISOString(),
status: formStatus.value,
direction: formDirection.value,
notes: notes.value.trim() || undefined,
};
await api.post<ChequeDto>('/cheques', body);
formOpen.value = false;
tab.value = formDirection.value;
await load();
} catch (e) {
formError.value = axiosMessage(e, 'ثبت چک ناموفق بود.');
} finally {
saving.value = false;
}
}
watch(tab, () => {
void load();
});
onMounted(load);
</script>
<template>
<section class="page" dir="rtl">
<header class="head">
<div>
<h1>چک‌ها</h1>
<p class="sub">دفتر چک‌های دریافتی و پرداختی. وضعیت فقط برای پیگیری است</p>
</div>
<div class="head-actions">
<UiButton type="button" @click="openForm('in')">دریافت چک</UiButton>
<UiButton variant="ghost" type="button" @click="openForm('out')">پرداخت چک</UiButton>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</div>
</header>
<div class="tabs">
<button type="button" :class="{ on: tab === 'in' }" @click="tab = 'in'">دریافتی</button>
<button type="button" :class="{ on: tab === 'out' }" @click="tab = 'out'">پرداختی</button>
</div>
<div class="toolbar">
<input
v-model="q"
type="search"
placeholder="شماره چک، بانک، فاکتور یا شخص"
@keyup.enter="load"
/>
<select v-model="status" @change="load">
<option value="">همه وضعیت‌ها</option>
<option value="pending">در جریان</option>
<option value="cleared">{{ tab === 'out' ? 'پرداخت شد' : 'وصول شد' }}</option>
<option value="bounced">برگشت</option>
</select>
<UiButton type="button" @click="load">اعمال</UiButton>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">
{{
tab === 'out'
? 'چک پرداختی ثبت نشده. از «پرداخت چک» می‌توانید به شخص دیگری چک بدهید.'
: 'چک دریافتی ثبت نشده. از «دریافت چک» می‌توانید بدون فاکتور ثبت کنید.'
}}
</p>
<div v-else class="table-wrap">
<div class="tc-row">
<TableColumnsMenu table-key="cheques" align="end" />
</div>
<table v-table-cols="'cheques'">
<thead>
<tr>
<th>شماره</th>
<th>بانک</th>
<th>سررسید</th>
<th>مبلغ</th>
<th v-if="tab === 'in'">فاکتور</th>
<th>طرف‌حساب</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ row.number }}</td>
<td>{{ row.bank }}</td>
<td>{{ formatJalaliNumeric(new Date(row.dueDate)) }}</td>
<td>{{ formatMoney(row.amount, row.currency) }}</td>
<td v-if="tab === 'in'">
<RouterLink
v-if="row.invoiceId"
:to="{ name: 'invoice-detail', params: { id: row.invoiceId } }"
>
{{ row.invoiceNumber }}
</RouterLink>
<span v-else class="muted">بدون فاکتور</span>
</td>
<td>
<RouterLink
v-if="row.personId"
:to="{ name: 'person-detail', params: { id: row.personId } }"
>
{{ row.personName || '—' }}
</RouterLink>
<span v-else>{{ row.personName || '—' }}</span>
</td>
<td>
<select
:value="row.status"
:disabled="savingId === row.id"
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
>
<option value="pending">{{ statusLabelFor(row.direction).pending }}</option>
<option value="cleared">{{ statusLabelFor(row.direction).cleared }}</option>
<option value="bounced">{{ statusLabelFor(row.direction).bounced }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<UiModal :open="clearOpen" title="حساب وصول چک" @close="clearOpen = false">
<p class="hint">چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟</p>
<label class="field">
<span>حساب</span>
<select v-model="clearAccountId">
<option v-for="account in clearAccounts" :key="account.id" :value="account.id">
{{ account.name }}
</option>
</select>
</label>
<UiButton
:loading="savingId === clearRow?.id"
@click="confirmClear"
>
ثبت وصول
</UiButton>
</UiModal>
<UiModal
:open="formOpen"
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
@close="formOpen = false"
>
<form class="receive-form" @submit.prevent="submitForm">
<p class="hint">
{{
isIncomingForm
? 'چک را از یک شخص بگیرید؛ لازم نیست به فاکتور وصل باشد.'
: 'چکی که به شخص دیگری می‌پردازید را ثبت کنید.'
}}
</p>
<p v-if="formError" class="error">{{ formError }}</p>
<label class="field">
<span>{{ isIncomingForm ? 'شخص' : 'گیرنده' }} <em>*</em></span>
<select v-model="personId" required>
<option value="">انتخاب شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ personLabel(p) }}
</option>
</select>
</label>
<div class="row-2">
<label class="field">
<span>مبلغ <em>*</em></span>
<MoneyInput v-model="amount" :decimals="0" placeholder="مبلغ چک" />
</label>
<label v-if="currencyOptions.length > 1" class="field">
<span>ارز</span>
<select v-model="currency">
<option v-for="opt in currencyOptions" :key="opt.code" :value="opt.code">
{{ opt.label }}
</option>
</select>
</label>
</div>
<div class="row-2">
<label class="field">
<span>شماره چک <em>*</em></span>
<UiInput v-model="chequeNumber" placeholder="شماره چک" :maxlength="80" required />
</label>
<label class="field">
<span>بانک <em>*</em></span>
<UiInput v-model="chequeBank" placeholder="نام بانک" :maxlength="120" required />
</label>
</div>
<div class="row-2">
<label class="field">
<span>سررسید <em>*</em></span>
<JalaliDateInput v-model="dueDate" required placeholder="سررسید چک" />
</label>
<label class="field">
<span>وضعیت</span>
<select v-model="formStatus">
<option value="pending">{{ formStatusLabel.pending }}</option>
<option value="cleared">{{ formStatusLabel.cleared }}</option>
<option value="bounced">{{ formStatusLabel.bounced }}</option>
</select>
</label>
</div>
<label class="field">
<span>یادداشت</span>
<textarea v-model="notes" rows="3" maxlength="2000" placeholder="اختیاری" />
</label>
</form>
<template #footer>
<UiButton type="button" :disabled="saving" @click="submitForm">
{{ saving ? 'در حال ثبت…' : isIncomingForm ? 'ثبت دریافت' : 'ثبت پرداخت' }}
</UiButton>
<UiButton variant="ghost" type="button" :disabled="saving" @click="formOpen = false">
انصراف
</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
.head-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
h1 { margin: 0; font-size: 1.25rem; }
.sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.88rem; }
.error { color: var(--danger, #dc2626); font-weight: 600; }
.hint { margin: 0 0 0.75rem; color: var(--muted); font-size: 0.88rem; }
.tabs {
display: flex; gap: 0.35rem; overflow-x: auto; border-bottom: 1px solid var(--line);
}
.tabs button {
border: 0; background: transparent; padding: 0.65rem 0.85rem; cursor: pointer;
color: #64748b; font-weight: 700; border-bottom: 2px solid transparent; white-space: nowrap;
}
.tabs button.on { color: var(--teal); border-bottom-color: var(--teal); }
.toolbar {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
}
.toolbar input, .toolbar select {
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
padding: 0 0.75rem; background: #fff;
}
.table-wrap { overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { padding: 0.7rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; }
th { background: #f8fafc; color: #64748b; font-size: 0.82rem; }
select {
min-height: var(--control-h); border: 1px solid var(--line); border-radius: var(--radius);
padding: 0 0.55rem; background: #fff;
}
a { color: #0f766e; font-weight: 700; }
.receive-form { display: grid; gap: 0.85rem; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.field { display: grid; gap: 0.35rem; }
.field > span { color: var(--muted); font-size: 0.86rem; font-weight: 600; }
.field em { color: #ef4444; font-style: normal; }
.field select,
.field textarea,
.field :deep(.money-input) {
width: 100%;
min-height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.75rem;
background: #fff;
font: inherit;
box-sizing: border-box;
}
.field textarea { min-height: 5rem; resize: vertical; }
@media (max-width: 640px) {
.row-2 { grid-template-columns: 1fr; }
}
</style>