Let cheques be received or paid without an invoice, and remind staff three days before due date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 11:34:49 +03:30
co-authored by Cursor
parent 9a991fc1f1
commit 9beaed96e8
18 changed files with 982 additions and 61 deletions
+298 -30
View File
@@ -1,12 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliNumeric } from '@/lib/jalali';
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 type { ChequeDto, ChequeStatus } from '@mento/shared';
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('');
@@ -14,27 +26,69 @@ const items = ref<ChequeDto[]>([]);
const status = ref('');
const q = ref('');
const savingId = ref('');
const tab = ref<ChequeDirection>('in');
const statusLabel: Record<ChequeStatus, string> = {
pending: 'در جریان',
cleared: 'وصول شد',
bounced: 'برگشت',
};
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('');
const filtered = computed(() => items.value);
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 },
params: {
status: status.value || undefined,
q: q.value || undefined,
direction: tab.value,
},
});
items.value = data;
} catch (e) {
error.value = axios.isAxiosError(e)
? 'بارگذاری چک‌ها ناموفق بود.'
: 'بارگذاری چک‌ها ناموفق بود.';
error.value = axiosMessage(e, 'بارگذاری چک‌ها ناموفق بود.');
} finally {
loading.value = false;
}
@@ -55,6 +109,81 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) {
}
}
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>
@@ -63,19 +192,33 @@ onMounted(load);
<header class="head">
<div>
<h1>چک‌ها</h1>
<p class="sub">دفتر چک‌های دریافتی وصل به فاکتور؛ وضعیت فقط برای پیگیری است</p>
<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>
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</UiButton>
</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" />
<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">وصول شد</option>
<option value="cleared">{{ tab === 'out' ? 'پرداخت شد' : 'وصول شد' }}</option>
<option value="bounced">برگشت</option>
</select>
<UiButton type="button" @click="load">اعمال</UiButton>
@@ -83,7 +226,13 @@ onMounted(load);
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !items.length" class="muted">در حال بارگذاری…</p>
<p v-else-if="!filtered.length" class="muted">چکی ثبت نشده.</p>
<p v-else-if="!items.length" class="muted">
{{
tab === 'out'
? 'چک پرداختی ثبت نشده. از «پرداخت چک» می‌توانید به شخص دیگری چک بدهید.'
: 'چک دریافتی ثبت نشده. از «دریافت چک» می‌توانید بدون فاکتور ثبت کنید.'
}}
</p>
<div v-else class="table-wrap">
<table>
<thead>
@@ -92,47 +241,145 @@ onMounted(load);
<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 filtered" :key="row.id">
<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>
<RouterLink :to="{ name: 'invoice-detail', params: { id: row.invoiceId } }">
<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>{{ row.personName || '—' }}</td>
<td>
<select
:value="row.status"
:disabled="savingId === row.id"
@change="setStatus(row, ($event.target as HTMLSelectElement).value as ChequeStatus)"
>
<option value="pending">{{ statusLabel.pending }}</option>
<option value="cleared">{{ statusLabel.cleared }}</option>
<option value="bounced">{{ statusLabel.bounced }}</option>
<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="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; }
.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;
}
@@ -149,4 +396,25 @@ select {
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>