Files
CRM_Montzar/apps/web/src/views/InvoiceFormView.vue
T

800 lines
20 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 } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import { formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
CompanyDto,
CreateInvoiceRequest,
CreatePaymentRequest,
DealLineItemInput,
InvoiceDto,
InvoiceInstallmentInput,
InvoiceStatus,
OrganizationDto,
PaginatedResponse,
PaymentMethod,
PersonDto,
ProductDto,
} from '@mento/shared';
type LineDraft = {
key: string;
productId: string;
name: string;
quantity: string;
unitPrice: string;
};
type InstallmentDraft = {
key: string;
dueDate: string;
amount: string;
};
const route = useRoute();
const router = useRouter();
const id = computed(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(() => route.name === 'invoice-new');
const loading = ref(false);
const saving = ref(false);
const paying = ref(false);
const error = ref('');
const number = ref('');
const title = ref('فاکتور');
const status = ref<InvoiceStatus>('draft');
const notes = ref('');
const dueDate = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const quoteId = ref('');
const people = ref<PersonDto[]>([]);
const companies = ref<CompanyDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const installments = ref<InstallmentDraft[]>([]);
const invoice = ref<InvoiceDto | null>(null);
const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
const payInstallmentId = ref('');
const payDate = ref(formatLocalDateInput(new Date()));
const statusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
issued: 'صادرشده',
partially_paid: 'پرداخت جزئی',
paid: 'تسویه‌شده',
canceled: 'لغوشده',
};
const methodLabel: Record<PaymentMethod, string> = {
cash: 'نقد',
transfer: 'کارت‌به‌کارت / حواله',
card: 'کارتخوان',
check: 'چک',
other: 'سایر',
};
const totalAmount = computed(() =>
toLineInputs().reduce((sum, l) => sum + l.quantity * l.unitPrice, 0),
);
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
function newLine(): LineDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
productId: '',
name: '',
quantity: '1',
unitPrice: '',
};
}
function newInstallment(): InstallmentDraft {
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
dueDate: '',
amount: '',
};
}
function addLine() {
lines.value.push(newLine());
}
function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
function addInstallment() {
installments.value.push(newInstallment());
}
function removeInstallment(key: string) {
installments.value = installments.value.filter((l) => l.key !== key);
}
function splitInstallments(count: number) {
const total = totalAmount.value;
if (!total || count < 1) return;
const base = Math.floor(total / count);
const remainder = total - base * count;
const today = new Date();
installments.value = Array.from({ length: count }, (_, i) => {
const d = new Date(today);
d.setMonth(d.getMonth() + i + 1);
return {
key: `split-${i}-${Date.now()}`,
dueDate: formatLocalDateInput(d),
amount: String(base + (i === count - 1 ? remainder : 0)),
};
});
}
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
.map((l) => ({
productId: l.productId || null,
name: l.name.trim(),
quantity: Number(l.quantity),
unitPrice: Number(l.unitPrice),
currency: currency.value || 'IRR',
}));
}
function toInstallmentInputs(): InvoiceInstallmentInput[] {
return installments.value
.filter((i) => i.dueDate && i.amount !== '')
.map((i) => ({
dueDate: new Date(i.dueDate).toISOString(),
amount: Number(i.amount),
currency: currency.value || 'IRR',
}));
}
async function load() {
loading.value = true;
error.value = '';
try {
const fromDealId =
typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '';
const fromQuoteId =
typeof route.query.fromQuoteId === 'string'
? route.query.fromQuoteId
: '';
const [peopleRes, companiesRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { limit: 100 },
}),
api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { limit: 100 },
}),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
companies.value = companiesRes.data.items;
products.value = productsRes.data;
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
const { data } = await api.get<InvoiceDto>(`/invoices/${id.value}`);
invoice.value = data;
number.value = data.number;
title.value = data.title;
status.value = data.status as InvoiceStatus;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
dueDate.value = data.dueDate ? data.dueDate.slice(0, 10) : '';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
quoteId.value = data.quote?.id ?? '';
lines.value = (data.lineItems ?? []).map((item) => ({
key: item.id,
productId: item.productId ?? '',
name: item.name,
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
}));
installments.value = (data.installments ?? []).map((item) => ({
key: item.id,
dueDate: item.dueDate.slice(0, 10),
amount: String(item.amount),
}));
if (data.remainingAmount > 0) {
payAmount.value = String(data.remainingAmount);
}
} else if (fromQuoteId) {
quoteId.value = fromQuoteId;
title.value = 'فاکتور';
} else if (fromDealId) {
dealId.value = fromDealId;
title.value = 'فاکتور';
} else if (!lines.value.length) {
lines.value = [newLine()];
}
} catch {
error.value = 'بارگذاری فرم فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateInvoiceRequest {
const fromDealId =
isNew.value && typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: undefined;
const fromQuoteId =
isNew.value && typeof route.query.fromQuoteId === 'string'
? route.query.fromQuoteId
: undefined;
return {
title: title.value.trim(),
status: status.value,
notes: notes.value || undefined,
dueDate: dueDate.value ? new Date(dueDate.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
quoteId: quoteId.value || null,
fromDealId: fromDealId || null,
fromQuoteId: fromQuoteId || null,
lineItems: toLineInputs(),
installments: toInstallmentInputs(),
currency: currency.value || 'IRR',
};
}
async function save() {
if (!title.value.trim()) {
error.value = 'عنوان را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<InvoiceDto>('/invoices', payload());
await router.replace({ name: 'invoice-detail', params: { id: data.id } });
} else {
await api.patch(`/invoices/${id.value}`, payload());
await load();
}
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ذخیره ناموفق بود');
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این فاکتور حذف شود؟')) return;
await api.delete(`/invoices/${id.value}`);
await router.push({ name: 'invoices' });
}
async function submitPayment() {
if (!id.value || !payAmount.value) {
error.value = 'مبلغ پرداخت را وارد کنید.';
return;
}
paying.value = true;
error.value = '';
try {
const body: CreatePaymentRequest = {
amount: Number(payAmount.value),
method: payMethod.value,
notes: payNotes.value || undefined,
paidAt: payDate.value
? new Date(payDate.value).toISOString()
: undefined,
installmentId: payInstallmentId.value || null,
};
const { data } = await api.post<InvoiceDto>(
`/invoices/${id.value}/payments`,
body,
);
invoice.value = data;
payNotes.value = '';
payInstallmentId.value = '';
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ثبت پرداخت ناموفق بود');
} else {
error.value = 'ثبت پرداخت ناموفق بود';
}
} finally {
paying.value = false;
}
}
async function removePayment(paymentId: string) {
if (!id.value || !confirm('این پرداخت حذف شود؟')) return;
await api.delete(`/invoices/${id.value}/payments/${paymentId}`);
await load();
}
function printInvoice() {
window.print();
}
const pendingInstallments = computed(
() =>
invoice.value?.installments.filter((i) => i.status === 'pending') ?? [],
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head no-print">
<div>
<RouterLink class="back" :to="{ name: 'invoices' }"
>← بازگشت به لیست</RouterLink
>
<h1>{{ isNew ? 'فاکتور جدید' : number || 'فاکتور' }}</h1>
</div>
<button v-if="!isNew" class="btn btn-ghost" type="button" @click="printInvoice">
چاپ
</button>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<strong>منتو</strong>
<span v-if="number">{{ number }}</span>
</div>
<div v-if="invoice && !isNew" class="summary no-print">
<span>جمع: {{ formatMoney(invoice.totalAmount) }}</span>
<span>وصول: {{ formatMoney(invoice.paidAmount) }}</span>
<span>مانده: {{ formatMoney(invoice.remainingAmount) }}</span>
</div>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>وضعیت</label>
<select v-model="status">
<option
v-for="(label, key) in statusLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
<div class="field">
<label>سررسید</label>
<JalaliDateInput v-model="dueDate" />
</div>
<div class="field">
<label>ارز</label>
<select v-model="currency">
<option v-for="code in enabledCurrencies" :key="code" :value="code">
{{ code }}
</option>
</select>
</div>
<div class="field">
<label>شخص</label>
<select v-model="personId">
<option value="">بدون شخص</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ p.fullName }}
</option>
</select>
</div>
<div class="field">
<label>شرکت</label>
<select v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">
{{ c.name }}
</option>
</select>
</div>
<div class="field wide">
<label>یادداشت</label>
<textarea v-model="notes" rows="3" />
</div>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>ردیف‌های فاکتور</h3>
<button class="btn btn-ghost no-print" type="button" @click="addLine">
افزودن ردیف
</button>
</div>
<div v-for="line in lines" :key="line.key" class="line-row">
<select
v-model="line.productId"
class="no-print"
@change="onPickProduct(line)"
>
<option value="">محصول از کاتالوگ…</option>
<option v-for="p in products" :key="p.id" :value="p.id">
{{ p.name }}
</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<input
v-model="line.quantity"
type="number"
min="0.01"
step="0.01"
placeholder="تعداد"
/>
<input
v-model="line.unitPrice"
type="number"
min="0"
step="1"
placeholder="قیمت واحد"
/>
<button
class="btn btn-ghost no-print"
type="button"
@click="removeLine(line.key)"
>
حذف
</button>
</div>
<p class="total"><strong>جمع:</strong> {{ formatMoney(totalAmount) }}</p>
</div>
<div class="lines-block">
<div class="lines-head">
<h3>اقساط</h3>
<div class="split no-print">
<button class="btn btn-ghost" type="button" @click="splitInstallments(2)">
۲ قسط
</button>
<button class="btn btn-ghost" type="button" @click="splitInstallments(3)">
۳ قسط
</button>
<button class="btn btn-ghost" type="button" @click="addInstallment">
افزودن قسط
</button>
</div>
</div>
<div
v-for="item in installments"
:key="item.key"
class="installment-row"
>
<JalaliDateInput v-model="item.dueDate" />
<input
v-model="item.amount"
type="number"
min="0.01"
step="1"
placeholder="مبلغ قسط"
/>
<button
class="btn btn-ghost no-print"
type="button"
@click="removeInstallment(item.key)"
>
حذف
</button>
</div>
<p v-if="!installments.length" class="muted">بدون برنامه اقساط.</p>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="form-actions no-print">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button
v-if="!isNew"
class="btn btn-danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</form>
<section v-if="!isNew && invoice && !loading" class="pay-block no-print">
<h3>ثبت پرداخت</h3>
<div class="pay-form">
<input v-model="payAmount" type="number" min="0.01" placeholder="مبلغ" />
<select v-model="payMethod">
<option
v-for="(label, key) in methodLabel"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
<JalaliDateInput v-model="payDate" />
<select v-model="payInstallmentId">
<option value="">بدون اتصال به قسط</option>
<option
v-for="inst in pendingInstallments"
:key="inst.id"
:value="inst.id"
>
قسط {{ inst.position + 1 }} — {{ formatMoney(inst.amount) }}
</option>
</select>
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
<button
class="btn"
type="button"
:disabled="paying"
@click="submitPayment"
>
{{ paying ? '…' : 'ثبت پرداخت' }}
</button>
</div>
<div v-if="invoice.payments.length" class="pay-list">
<div
v-for="p in invoice.payments"
:key="p.id"
class="pay-row"
>
<div>
<strong>{{ formatMoney(p.amount) }}</strong>
<span class="muted">
· {{ methodLabel[p.method as PaymentMethod] || p.method }}
· {{ p.paidAt.slice(0, 10) }}
</span>
</div>
<button
class="btn btn-ghost"
type="button"
@click="removePayment(p.id)"
>
حذف
</button>
</div>
</div>
<p v-else class="muted">هنوز پرداختی ثبت نشده.</p>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1.1rem;
max-width: 860px;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.back {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
h1 {
margin: 0.25rem 0 0;
}
.form,
.lines-block,
.pay-block {
display: grid;
gap: 0.85rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-weight: 600;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
}
.lines-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.lines-head h3,
.pay-block h3 {
margin: 0;
font-size: 1rem;
}
.split {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.line-row {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.7fr 0.9fr auto;
gap: 0.45rem;
}
.installment-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.45rem;
}
.total {
margin: 0;
text-align: start;
font-size: 1.05rem;
}
.form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.error {
color: #b42318;
}
.muted {
color: var(--muted);
}
.pay-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.pay-list {
display: grid;
gap: 0.45rem;
}
.pay-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--surface);
}
.print-banner {
display: none;
}
@media (max-width: 700px) {
.grid,
.line-row,
.installment-row,
.pay-form {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.print-banner {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
input,
select,
textarea {
border: 0;
padding: 0.2rem 0;
}
}
</style>