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

841 lines
21 KiB
Vue
Raw Normal View History

<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 MoneyInput from '@/components/MoneyInput.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 class="head-copy">
<p class="crumb">
<RouterLink :to="{ name: 'invoices' }">فاکتورها</RouterLink>
<span>›</span>
<strong>{{ isNew ? 'فاکتور جدید' : number || 'فاکتور' }}</strong>
</p>
<h1>{{ isNew ? 'فاکتور جدید' : number || 'فاکتور' }}</h1>
</div>
<button v-if="!isNew" class="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="تعداد"
/>
<MoneyInput
v-model="line.unitPrice"
:decimals="0"
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" />
<MoneyInput
v-model="item.amount"
:decimals="0"
placeholder="مبلغ قسط"
required
/>
<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">
<MoneyInput v-model="payAmount" :decimals="0" 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: 1rem;
max-width: 860px;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.crumb {
margin: 0 0 0.25rem;
display: flex;
align-items: center;
gap: 0.35rem;
color: #94a3b8;
font-size: 0.82rem;
}
.crumb a {
color: #0f766e;
font-weight: 700;
text-decoration: none;
}
h1 {
margin: 0;
font-size: clamp(1.25rem, 2.5vw, 1.55rem);
font-weight: 800;
}
.btn.ghost {
background: #fff;
color: #0f766e;
border: 1px solid #99f6e4;
border-radius: 12px;
min-height: 42px;
padding: 0.55rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.form,
.lines-block,
.pay-block {
display: grid;
gap: 0.85rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.85rem 1rem;
background: #fff;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
font-weight: 600;
}
.form {
background: #fff;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.lines-block,
.pay-block {
background: #fff;
border-radius: 18px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
}
.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>