Files
CRM_Montzar/apps/web/src/views/InvoiceFormView.vue
T
MahdiandCursor 36eda8653e merge: bring Ant Design UI from dev into main
Keep the design refresh together with DISC, the sales copilot, and treasury behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 21:33:56 +03:30

1584 lines
54 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.
This file contains Unicode characters that might be confused with other characters. 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 { matchesSearch } from '@/lib/search';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import {
CURRENCY_OPTIONS,
currencySuffix,
formatMoney as formatMoneyLib,
} from '@/lib/money';
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
import {
Affix as AAffix,
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
EyeOutlined,
FileTextOutlined,
PlusOutlined,
UserAddOutlined,
} from '@ant-design/icons-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import InvoiceDocPreview from '@/components/InvoiceDocPreview.vue';
import { useAuthStore } from '@/stores/auth';
import type {
CreateInvoiceRequest,
CreatePaymentRequest,
CreateQuoteRequest,
DealDto,
DealLineItemInput,
FinancialAccountDto,
InvoiceDto,
InvoiceInstallmentInput,
InvoiceStatus,
OrganizationDto,
OrgUserDto,
PaginatedResponse,
PaymentMethod,
PersonDto,
ProductDto,
QuoteDto,
QuoteStatus,
} from '@mento/shared';
type DocMode = 'invoice' | 'quote' | 'credit';
type LineDraft = {
key: string;
productId: string;
name: string;
description: string;
sku: string;
kind: 'product' | 'service' | '';
quantity: string;
unitPrice: string;
};
type InstallmentDraft = {
key: string;
dueDate: string;
amount: string;
};
const props = withDefaults(
defineProps<{
embedded?: boolean;
entityId?: string;
initialKind?: 'invoice' | 'credit' | 'quote';
initialFromDealId?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: []; cancel: [] }>();
const route = useRoute();
const router = useRouter();
const loadedInvoiceKind = ref<'invoice' | 'credit' | null>(null);
const docMode = computed<DocMode>(() => {
if (props.embedded && props.initialKind) return props.initialKind;
const name = String(route.name || '');
if (name.includes('quote')) return 'quote';
if (route.query.kind === 'credit' || route.query.doc === 'credit') return 'credit';
if (route.query.doc === 'quote') return 'quote';
if (loadedInvoiceKind.value === 'credit') return 'credit';
return 'invoice';
});
const id = computed(() => {
if (props.embedded) return props.entityId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
});
const isNew = computed(() => {
if (props.embedded) return !props.entityId;
return route.name === 'invoice-new' || route.name === 'quote-new';
});
const isInvoiceDoc = computed(() => docMode.value !== 'quote');
const listRouteName = 'invoices';
const loading = ref(false);
const saving = ref(false);
const paying = ref(false);
const error = ref('');
const mobileStep = ref<1 | 2>(1);
const showPayments = ref(false);
const productSearch = ref('');
const validityMode = ref<'days' | 'date'>('days');
const validityDays = ref<number>(30);
const number = ref('');
const title = ref('فاکتور');
const status = ref<string>('draft');
const notes = ref('');
const issuedAt = ref(formatLocalDateInput(new Date()));
const dueDate = ref('');
const validUntil = ref('');
const currency = ref('IRR');
const enabledCurrencies = ref<string[]>(['IRR']);
const discountAmount = ref('0');
const taxPercent = ref('0');
const personId = ref('');
const companyId = ref('');
const dealId = ref('');
const quoteId = ref('');
const people = ref<PersonDto[]>([]);
const products = ref<ProductDto[]>([]);
const lines = ref<LineDraft[]>([]);
const installments = ref<InstallmentDraft[]>([]);
const invoice = ref<InvoiceDto | null>(null);
const orgName = ref('');
const previewOpen = ref(false);
const payAccountId = ref('');
const financeAccounts = ref<FinancialAccountDto[]>([]);
const payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
const payInstallmentId = ref('');
const payDate = ref(formatLocalDateInput(new Date()));
const chequeNumber = ref('');
const chequeBank = ref('');
const chequeDue = ref('');
const chequeStatus = ref<'pending' | 'cleared' | 'bounced'>('pending');
const commissionMode = ref<'percent' | 'amount'>('percent');
const commissionPercent = ref('');
const commissionFixedAmount = ref('');
const defaultCommissionPercent = ref(0);
const auth = useAuthStore();
const invoiceStatusLabel: Record<InvoiceStatus, string> = {
draft: 'پیش‌نویس',
issued: 'صادرشده',
partially_paid: 'پرداخت جزئی',
paid: 'پرداخت شده',
canceled: 'لغو شده',
};
const quoteStatusLabel: Record<QuoteStatus, string> = {
draft: 'پیش‌نویس',
sent: 'ارسال‌شده',
accepted: 'تأییدشده',
rejected: 'ردشده',
expired: 'منقضی',
};
const methodLabel: Record<PaymentMethod, string> = {
cash: 'نقد',
transfer: 'کارت‌به‌کارت / حواله',
card: 'کارتخوان',
check: 'چک',
other: 'سایر',
};
const statusOptions = computed(() =>
docMode.value === 'quote' ? quoteStatusLabel : invoiceStatusLabel,
);
const pageTitle = computed(() => {
if (!isNew.value) {
return (
number.value ||
(docMode.value === 'quote'
? 'پیش فاکتور'
: docMode.value === 'credit'
? 'فاکتور برگشتی'
: 'فاکتور')
);
}
if (docMode.value === 'quote') return 'افزودن پیش فاکتور';
if (docMode.value === 'credit') return 'افزودن فاکتور برگشتی';
return 'افزودن فاکتور';
});
const pageSubtitle = computed(() => {
if (mobileStep.value === 2) return 'محصولات و خدمات را اضافه کنید';
if (docMode.value === 'quote') return 'اطلاعات پیش فاکتور را وارد کنید';
if (docMode.value === 'credit') return 'اطلاعات فاکتور برگشتی را وارد کنید';
return 'اطلاعات فاکتور را وارد کنید';
});
const defaultTitle = computed(() => {
if (docMode.value === 'quote') return 'پیش فاکتور';
if (docMode.value === 'credit') return 'فاکتور برگشتی';
return 'فاکتور';
});
const saveLabel = computed(() => {
if (docMode.value === 'quote') return 'ذخیره پیش فاکتور';
if (docMode.value === 'credit') return 'ذخیره فاکتور برگشتی';
return 'ذخیره فاکتور';
});
const currencyLabel = computed(() => currencySuffix(currency.value));
const currencySelectOptions = computed(() => {
const codes = enabledCurrencies.value.length
? [...enabledCurrencies.value]
: [currency.value || 'IRR'];
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,
};
});
});
const filteredProducts = computed(() => {
const q = productSearch.value.trim();
if (!q) return products.value;
return products.value.filter((p) =>
matchesSearch(q, [p.name, p.sku, p.description, p.category]),
);
});
function roundMoney(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function lineTotal(line: LineDraft): number {
const qty = Number(line.quantity) || 0;
const price = Number(line.unitPrice) || 0;
return qty * price;
}
const subtotal = computed(() =>
lines.value.reduce((sum, line) => sum + lineTotal(line), 0),
);
const discountNum = computed(() => Number(discountAmount.value) || 0);
const taxPercentNum = computed(() => Number(taxPercent.value) || 0);
const taxAmount = computed(() =>
roundMoney(
Math.max(0, subtotal.value - discountNum.value) * (taxPercentNum.value / 100),
),
);
const payableSigned = computed(() =>
roundMoney(subtotal.value - discountNum.value + taxAmount.value),
);
const payableAmount = computed(() =>
docMode.value === 'credit' ? -payableSigned.value : payableSigned.value,
);
const commissionPreview = computed(() => {
const base = Math.abs(payableAmount.value);
if (commissionMode.value === 'amount') {
const n = Number(commissionFixedAmount.value);
return Number.isFinite(n) ? n : 0;
}
const n = Number(commissionPercent.value || defaultCommissionPercent.value);
return Number.isFinite(n) ? Math.round((base * n) / 100) : 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: '',
description: '',
sku: '',
kind: '',
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 onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
line.name = product.name;
line.description = product.description ?? '';
line.sku = product.sku ?? '';
line.kind = product.kind;
if (product.unitPrice != null) line.unitPrice = String(product.unitPrice);
}
function onPersonChange() {
const person = people.value.find((p) => p.id === personId.value);
companyId.value = person?.company?.id ?? '';
}
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',
}));
}
function applyValidityDays(days: number) {
validityMode.value = 'days';
validityDays.value = days;
const base = issuedAt.value ? new Date(issuedAt.value) : new Date();
const d = new Date(base);
d.setDate(d.getDate() + days);
validUntil.value = formatLocalDateInput(d);
}
function keepPrefillQuery() {
const q: Record<string, string> = {};
for (const key of ['fromDealId', 'fromQuoteId', 'personId', 'dealId'] as const) {
const v = route.query[key];
if (typeof v === 'string' && v) q[key] = v;
}
return q;
}
function switchDocMode(mode: DocMode) {
if (!isNew.value || mode === docMode.value) return;
const q = keepPrefillQuery();
if (mode === 'quote') {
void router.push({ name: 'quote-new', query: q });
return;
}
if (mode === 'credit') {
void router.push({ name: 'invoice-new', query: { ...q, kind: 'credit' } });
return;
}
void router.push({ name: 'invoice-new', query: q });
}
function mapLinesFromItems(
items: Array<{
id?: string;
productId?: string | null;
name: string;
quantity: number;
unitPrice: number;
}>,
): LineDraft[] {
return items.map((item, index) => {
const product = products.value.find((p) => p.id === item.productId);
return {
key: item.id || `line-${index}-${Date.now()}`,
productId: item.productId ?? '',
name: item.name,
description: product?.description ?? '',
sku: product?.sku ?? '',
kind: product?.kind ?? '',
quantity: String(item.quantity),
unitPrice: String(item.unitPrice),
};
});
}
async function prefillFromSources() {
const fromQuoteId =
typeof route.query.fromQuoteId === 'string' ? route.query.fromQuoteId : '';
const fromDealId =
props.initialFromDealId ||
(typeof route.query.fromDealId === 'string'
? route.query.fromDealId
: typeof route.query.dealId === 'string'
? route.query.dealId
: '');
const qPersonId =
typeof route.query.personId === 'string' ? route.query.personId : '';
if (fromQuoteId) {
quoteId.value = fromQuoteId;
const { data } = await api.get<QuoteDto>(`/quotes/${fromQuoteId}`);
title.value =
docMode.value === 'credit'
? `فاکتور برگشتی — ${data.title}`
: `فاکتور — ${data.title}`;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
discountAmount.value = String(data.discountAmount ?? 0);
taxPercent.value = String(data.taxPercent ?? 0);
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
if (data.issuedAt) issuedAt.value = data.issuedAt.slice(0, 10);
lines.value = mapLinesFromItems(data.lineItems ?? []);
if (!lines.value.length) lines.value = [newLine()];
return;
}
if (fromDealId) {
dealId.value = fromDealId;
const { data } = await api.get<DealDto>(`/deals/${fromDealId}`);
title.value = `${defaultTitle.value} — ${data.title}`;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
lines.value = mapLinesFromItems(data.lineItems ?? []);
if (!lines.value.length) lines.value = [newLine()];
return;
}
if (qPersonId) {
personId.value = qPersonId;
onPersonChange();
}
if (!lines.value.length) lines.value = [newLine()];
}
async function load() {
loading.value = true;
error.value = '';
mobileStep.value = 1;
try {
const accounts = await api.get<FinancialAccountDto[]>('/treasury/accounts').catch(() => null);
financeAccounts.value = accounts?.data.filter((row) => row.isActive) ?? [];
title.value = defaultTitle.value;
status.value = 'draft';
notes.value = '';
issuedAt.value = formatLocalDateInput(new Date());
dueDate.value = '';
validUntil.value = '';
discountAmount.value = '0';
taxPercent.value = '0';
personId.value = '';
companyId.value = '';
dealId.value = '';
quoteId.value = '';
number.value = '';
invoice.value = null;
loadedInvoiceKind.value = null;
installments.value = [];
lines.value = [];
commissionMode.value = 'percent';
commissionPercent.value = '';
commissionFixedAmount.value = '';
validityMode.value = 'days';
validityDays.value = 30;
const [peopleRes, productsRes, orgRes] = await Promise.all([
api.get<PaginatedResponse<PersonDto>>('/people', {
params: { limit: 100 },
}),
api.get<ProductDto[]>('/products', { params: { activeOnly: true } }),
api.get<OrganizationDto>('/organizations/current'),
]);
people.value = peopleRes.data.items;
products.value = productsRes.data;
try {
const { data: team } = await api.get<OrgUserDto[]>('/users');
const me = team.find((u) => u.id === auth.user?.id);
defaultCommissionPercent.value = Number(me?.commissionPercent ?? 0);
if (!commissionPercent.value && defaultCommissionPercent.value) {
commissionPercent.value = String(defaultCommissionPercent.value);
}
} catch {
/* optional */
}
orgName.value = orgRes.data.name || '';
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
currency.value = orgRes.data.baseCurrency || 'IRR';
if (!isNew.value && id.value) {
if (docMode.value === 'quote') {
const { data } = await api.get<QuoteDto>(`/quotes/${id.value}`);
number.value = data.number;
title.value = data.title;
status.value = data.status;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
discountAmount.value = String(data.discountAmount ?? 0);
taxPercent.value = String(data.taxPercent ?? 0);
issuedAt.value = data.issuedAt
? data.issuedAt.slice(0, 10)
: formatLocalDateInput(new Date(data.createdAt));
validUntil.value = data.validUntil ? data.validUntil.slice(0, 10) : '';
if (validUntil.value) validityMode.value = 'date';
personId.value = data.person?.id ?? '';
companyId.value = data.company?.id ?? '';
dealId.value = data.deal?.id ?? '';
lines.value = mapLinesFromItems(data.lineItems ?? []);
} else {
const { data } = await api.get<InvoiceDto>(`/invoices/${id.value}`);
invoice.value = data;
loadedInvoiceKind.value = data.kind === 'credit' ? 'credit' : 'invoice';
number.value = data.number;
title.value = data.title;
status.value = data.status;
notes.value = data.notes ?? '';
currency.value = data.currency || currency.value;
discountAmount.value = String(data.discountAmount ?? 0);
taxPercent.value = String(data.taxPercent ?? 0);
issuedAt.value = data.issuedAt
? data.issuedAt.slice(0, 10)
: formatLocalDateInput(new Date(data.createdAt));
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 = mapLinesFromItems(data.lineItems ?? []);
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);
}
if (data.commissionFixedAmount != null) {
commissionMode.value = 'amount';
commissionFixedAmount.value = String(data.commissionFixedAmount);
commissionPercent.value = String(
data.commissionPercent ?? defaultCommissionPercent.value,
);
} else {
commissionMode.value = 'percent';
commissionPercent.value = String(
data.commissionPercent ?? defaultCommissionPercent.value,
);
}
}
if (!lines.value.length) lines.value = [newLine()];
} else {
await prefillFromSources();
if (docMode.value === 'quote') applyValidityDays(validityDays.value);
}
} catch {
error.value =
docMode.value === 'quote'
? 'بارگذاری فرم پیش فاکتور ناموفق بود.'
: 'بارگذاری فرم فاکتور ناموفق بود.';
} finally {
loading.value = false;
}
}
function invoicePayload(): CreateInvoiceRequest {
const fromDealId =
isNew.value &&
(props.initialFromDealId || typeof route.query.fromDealId === 'string')
? (props.initialFromDealId ||
(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() || defaultTitle.value,
status: status.value,
kind: docMode.value === 'credit' ? 'credit' : 'invoice',
notes: notes.value || undefined,
discountAmount: discountNum.value,
taxPercent: taxPercentNum.value,
issuedAt: issuedAt.value ? new Date(issuedAt.value).toISOString() : null,
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',
commissionPercent:
commissionMode.value === 'percent'
? commissionPercent.value === ''
? null
: Number(commissionPercent.value)
: null,
commissionFixedAmount:
commissionMode.value === 'amount'
? commissionFixedAmount.value === ''
? null
: Number(commissionFixedAmount.value)
: null,
};
}
function quotePayload(): CreateQuoteRequest {
const fromDealId =
isNew.value &&
(props.initialFromDealId || typeof route.query.fromDealId === 'string')
? (props.initialFromDealId ||
(typeof route.query.fromDealId === 'string' ? route.query.fromDealId : ''))
: undefined;
if (validityMode.value === 'days') {
applyValidityDays(validityDays.value);
}
const body: CreateQuoteRequest = {
title: title.value.trim() || defaultTitle.value,
status: status.value,
notes: notes.value || undefined,
discountAmount: discountNum.value,
taxPercent: taxPercentNum.value,
issuedAt: issuedAt.value ? new Date(issuedAt.value).toISOString() : null,
personId: personId.value || null,
companyId: companyId.value || null,
dealId: dealId.value || null,
fromDealId: fromDealId || null,
lineItems: toLineInputs(),
currency: currency.value || 'IRR',
validUntil: validUntil.value
? new Date(validUntil.value).toISOString()
: null,
};
if (isNew.value && validityMode.value === 'days') {
body.validityDays = validityDays.value;
}
return body;
}
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;
}
async function save() {
if (!issuedAt.value) {
error.value = 'تاریخ را وارد کنید.';
return;
}
if (!toLineInputs().length) {
error.value = 'حداقل یک ردیف محصول وارد کنید.';
mobileStep.value = 2;
return;
}
saving.value = true;
error.value = '';
try {
if (docMode.value === 'quote') {
if (isNew.value) {
await api.post<QuoteDto>('/quotes', quotePayload());
} else {
await api.patch(`/quotes/${id.value}`, quotePayload());
}
} else if (isNew.value) {
await api.post<InvoiceDto>('/invoices', invoicePayload());
} else {
await api.patch(`/invoices/${id.value}`, invoicePayload());
}
if (props.embedded) {
emit('saved');
return;
}
await router.push({ name: listRouteName });
} catch (e) {
error.value = axiosMessage(e, 'ذخیره ناموفق بود');
} finally {
saving.value = false;
}
}
async function remove() {
const label =
docMode.value === 'quote'
? 'این پیش فاکتور حذف شود؟'
: docMode.value === 'credit'
? 'این فاکتور برگشتی حذف شود؟'
: 'این فاکتور حذف شود؟';
if (!id.value || !confirm(label)) return;
if (docMode.value === 'quote') {
await api.delete(`/quotes/${id.value}`);
} else {
await api.delete(`/invoices/${id.value}`);
}
await router.push({ name: listRouteName });
}
async function submitPayment() {
if (!id.value || !payAmount.value) {
error.value = 'مبلغ پرداخت را وارد کنید.';
return;
}
if (payMethod.value === 'check' && (!chequeNumber.value.trim() || !chequeBank.value.trim() || !chequeDue.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,
financialAccountId: payAccountId.value || null,
cheque:
payMethod.value === 'check'
? {
number: chequeNumber.value.trim(),
bank: chequeBank.value.trim(),
dueDate: new Date(chequeDue.value).toISOString(),
status: chequeStatus.value,
}
: undefined,
};
const { data } = await api.post<InvoiceDto>(
`/invoices/${id.value}/payments`,
body,
);
invoice.value = data;
payNotes.value = '';
payInstallmentId.value = '';
chequeNumber.value = '';
chequeBank.value = '';
chequeDue.value = '';
chequeStatus.value = 'pending';
await load();
} catch (e) {
error.value = axiosMessage(e, 'ثبت پرداخت ناموفق بود');
} 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 addInstallment() {
installments.value.push(newInstallment());
}
function removeInstallment(key: string) {
installments.value = installments.value.filter((l) => l.key !== key);
}
function splitInstallments(count: number) {
const total = Math.abs(payableAmount.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 lineHasContent(line: LineDraft) {
return Boolean(
line.name.trim() ||
line.productId ||
line.description.trim() ||
(line.unitPrice !== '' && Number.isFinite(Number(line.unitPrice))),
);
}
const previewLines = computed(() =>
lines.value.filter(lineHasContent).map((l) => {
const product = products.value.find((p) => p.id === l.productId);
const qty = Number(l.quantity);
const unit = Number(l.unitPrice);
const unitPrice = Number.isFinite(unit) && l.unitPrice !== ''
? unit
: Number(product?.unitPrice) || 0;
const quantity = Number.isFinite(qty) && l.quantity !== '' ? qty : 1;
return {
name:
l.name.trim() ||
product?.name ||
(l.productId ? 'محصول انتخاب‌شده' : 'بدون نام'),
description: l.description?.trim() || product?.description || undefined,
sku: l.sku || product?.sku || undefined,
quantity,
unitPrice,
total: quantity * unitPrice,
};
}),
);
const previewSubtotal = computed(() =>
previewLines.value.reduce((sum, l) => sum + l.total, 0),
);
const previewTaxAmount = computed(() =>
Math.round(
(Math.max(0, previewSubtotal.value - discountNum.value) * (taxPercentNum.value / 100) +
Number.EPSILON) *
100,
) / 100,
);
const previewPayable = computed(() => {
const signed = Math.round(
(previewSubtotal.value - discountNum.value + previewTaxAmount.value + Number.EPSILON) * 100,
) / 100;
return docMode.value === 'credit' ? -signed : signed;
});
const previewCustomer = computed(() => {
const p = people.value.find((x) => x.id === personId.value);
if (!p) return { name: '—', sub: '' };
const full = p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim();
return {
name: p.company?.name || full || '—',
sub: p.company?.name ? full : '',
};
});
const previewStatusLabel = computed(() => {
const map = statusOptions.value as Record<string, string>;
return map[status.value] || status.value || '—';
});
function printDoc() {
previewOpen.value = true;
}
function goBack() {
if (props.embedded) {
emit('cancel');
return;
}
void router.push({ name: listRouteName });
}
function onValidityDaysChange() {
applyValidityDays(validityDays.value);
}
function personLabel(p: PersonDto) {
return p.company?.name
? `${p.fullName} — ${p.company.name}`
: p.fullName;
}
const pendingInstallments = computed(
() =>
invoice.value?.installments.filter((i) => i.status === 'pending') ?? [],
);
const showPaymentBlock = computed(
() => !isNew.value && isInvoiceDoc.value && !!invoice.value,
);
watch(
() => [route.name, route.params.id, route.query.kind, route.query.doc],
() => {
void load();
},
);
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const personSelectOptions = computed(() =>
people.value.map((p) => ({ value: p.id, label: personLabel(p) })),
);
const statusSelectOptions = computed(() =>
Object.entries(statusOptions.value).map(([value, label]) => ({ value, label })),
);
const currencyAntOptions = computed(() =>
currencySelectOptions.value.map((o) => ({ value: o.code, label: o.label })),
);
const productSelectOptions = computed(() =>
filteredProducts.value.map((p) => ({ value: p.id, label: p.name })),
);
const validityDayOptions = computed(() =>
[7, 15, 30, 60].map((d) => ({ value: d, label: `${toFaDigits(d)} روز` })),
);
const methodSelectOptions = computed(() =>
(Object.entries(methodLabel) as Array<[PaymentMethod, string]>).map(
([value, label]) => ({ value, label }),
),
);
const payInstallmentOptions = computed(() => [
{ value: '', label: 'بدون اتصال به قسط' },
...pendingInstallments.value.map((inst) => ({
value: inst.id,
label: `قسط ${toFaDigits(inst.position + 1)} — ${formatMoney(inst.amount)}`,
})),
]);
const chequeStatusOptions = [
{ value: 'pending', label: 'در جریان' },
{ value: 'cleared', label: 'وصول شد' },
{ value: 'bounced', label: 'برگشت' },
];
const docModeSegOptions = computed(() => {
const opts: Array<{ value: DocMode; label: string; disabled?: boolean }> = [
{
value: 'invoice',
label: 'فاکتور',
disabled: !isNew.value && docMode.value !== 'invoice',
},
{
value: 'quote',
label: 'پیش فاکتور',
disabled: !isNew.value && docMode.value !== 'quote',
},
];
if (docMode.value === 'credit') {
opts.push({ value: 'credit', label: 'برگشتی', disabled: true });
}
return opts;
});
const sideCollapseKeys = ref<string[]>([]);
const paymentsCollapseKeys = computed({
get: () => (showPayments.value ? ['payments'] : []),
set: (keys: string[]) => {
showPayments.value = keys.includes('payments');
},
});
function setLineQty(line: LineDraft, value: number | string | null) {
const n = Number(value);
line.quantity = String(Number.isFinite(n) && n > 0 ? n : 1);
}
function setLineProduct(line: LineDraft, value: unknown) {
line.productId = value == null ? '' : String(value);
onPickProduct(line);
}
function onDocModeSeg(value: string | number) {
switchDocMode(String(value) as DocMode);
}
onMounted(load);
</script>
<template>
<div>
<a-flex
v-if="!embedded"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
class="no-print"
:style="{ marginBottom: '16px' }"
>
<a-space :size="12" align="start">
<FileTextOutlined />
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
<a-typography-text type="secondary">{{ pageSubtitle }}</a-typography-text>
</div>
</a-space>
<a-button @click="goBack">
<template #icon><ArrowLeftOutlined /></template>
بازگشت
</a-button>
</a-flex>
<a-spin :spinning="loading">
<a-form v-if="!loading" layout="vertical" @finish="save">
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
class="no-print"
:style="{ marginBottom: '16px' }"
/>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="embedded ? 24 : 17" :xl="embedded ? 24 : 18">
<a-card :bordered="false">
<a-segmented
class="no-print"
block
:value="docMode"
:options="docModeSegOptions"
:style="{ marginBottom: '16px' }"
@update:value="onDocModeSeg"
/>
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="مشتری" required>
<a-select
v-model:value="personId"
show-search
option-filter-prop="label"
:options="personSelectOptions"
placeholder="انتخاب مشتری"
style="width: 100%"
@change="onPersonChange"
/>
<RouterLink :to="{ name: 'person-new' }">
<a-button type="link" style="padding-inline: 0">
<template #icon><UserAddOutlined /></template>
افزودن مشتری جدید
</a-button>
</RouterLink>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="issuedAt" required />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت">
<a-select
v-model:value="status"
:options="statusSelectOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="ارز">
<a-select
v-model:value="currency"
:options="currencyAntOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<template v-if="docMode === 'quote'">
<a-col :xs="24" :md="8">
<a-form-item label="اعتبار تا">
<a-select
v-model:value="validityDays"
:options="validityDayOptions"
style="width: 100%"
@change="onValidityDaysChange"
/>
</a-form-item>
</a-col>
<a-col v-if="validUntil" :xs="24" :md="8">
<a-form-item label="تاریخ اعتبار">
<JalaliDateInput
v-model="validUntil"
@update:model-value="validityMode = 'date'"
/>
</a-form-item>
</a-col>
</template>
<a-col v-else :xs="24" :md="8">
<a-form-item label="مهلت پرداخت">
<JalaliDateInput v-model="dueDate" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea
v-model:value="notes"
:rows="3"
placeholder="یادداشت خود را وارد کنید..."
/>
</a-form-item>
</a-col>
</a-row>
<a-collapse
v-model:activeKey="sideCollapseKeys"
bordered
class="no-print"
:style="{ borderRadius: '8px', overflow: 'hidden', marginBottom: '16px' }"
>
<a-collapse-panel key="extra" header="اطلاعات تکمیلی">
<a-typography-text type="secondary">فیلدی برای تکمیل وجود ندارد.</a-typography-text>
</a-collapse-panel>
<a-collapse-panel key="shipping" header="آدرس و ارسال">
<a-typography-text type="secondary">فیلدی برای تکمیل وجود ندارد.</a-typography-text>
</a-collapse-panel>
</a-collapse>
<a-divider orientation="right">محصولات و خدمات</a-divider>
<a-flex
class="no-print"
justify="space-between"
align="center"
wrap="wrap"
:gap="8"
:style="{ marginBottom: '12px' }"
>
<a-space :size="8" wrap>
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><PlusOutlined /></template>
افزودن محصول
</a-button>
</RouterLink>
<a-button @click="addLine">
<template #icon><PlusOutlined /></template>
افزودن ردیف
</a-button>
</a-space>
<a-input
v-model:value="productSearch"
allow-clear
placeholder="جستجوی محصول..."
style="width: 220px; max-width: 100%"
/>
</a-flex>
<a-flex vertical :gap="12">
<a-card
v-for="(line, idx) in lines"
:key="line.key"
:bordered="true"
>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-flex justify="space-between" align="center" :style="{ marginBottom: '8px' }">
<a-typography-text type="secondary">
ردیف {{ toFaDigits(idx + 1) }}
</a-typography-text>
<a-button
type="text"
danger
aria-label="حذف ردیف"
@click="removeLine(line.key)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-flex>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="محصول">
<a-select
:value="line.productId || undefined"
show-search
allow-clear
option-filter-prop="label"
:options="productSelectOptions"
placeholder="انتخاب محصول…"
style="width: 100%"
@update:value="(v) => setLineProduct(line, v)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام ردیف" required>
<a-input v-model:value="line.name" placeholder="نام ردیف" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیح">
<a-input v-model:value="line.description" placeholder="توضیح اختیاری" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="`قیمت واحد (${currencyLabel})`">
<MoneyInput
v-model="line.unitPrice"
:decimals="0"
placeholder="قیمت"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تعداد">
<a-input-number
:value="Number(line.quantity) || 1"
:min="0.01"
:step="1"
style="width: 100%"
@update:value="(v) => setLineQty(line, v)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="`مبلغ کل (${currencyLabel})`">
<a-typography-text strong>{{ formatMoney(lineTotal(line)) }}</a-typography-text>
<div>
<a-typography-text type="secondary" dir="ltr">
{{ line.sku || '—' }}
</a-typography-text>
</div>
</a-form-item>
</a-col>
</a-row>
</a-card>
</a-flex>
<a-divider orientation="right">مبالغ</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="تخفیف">
<MoneyInput v-model="discountAmount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مالیات (%)">
<a-input-number
:value="Number(taxPercent) || 0"
:min="0"
:step="0.01"
style="width: 100%"
@update:value="(v) => (taxPercent = String(v ?? 0))"
/>
</a-form-item>
</a-col>
<a-col v-if="docMode === 'invoice'" :xs="24" :md="8">
<a-form-item label="نوع پورسانت">
<a-segmented
v-model:value="commissionMode"
block
:options="[
{ value: 'percent', label: 'درصد' },
{ value: 'amount', label: 'مبلغ ثابت' },
]"
/>
</a-form-item>
</a-col>
<a-col v-if="docMode === 'invoice' && commissionMode === 'percent'" :xs="24" :md="8">
<a-form-item label="پورسانت کارشناس (%)">
<a-input-number
:value="commissionPercent === '' ? undefined : Number(commissionPercent)"
:min="0"
:max="100"
:step="0.1"
:placeholder="String(defaultCommissionPercent || 0)"
style="width: 100%"
@update:value="(v) => (commissionPercent = v == null ? '' : String(v))"
/>
</a-form-item>
</a-col>
<a-col v-else-if="docMode === 'invoice'" :xs="24" :md="8">
<a-form-item label="مبلغ پورسانت">
<MoneyInput v-model="commissionFixedAmount" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
<a-collapse
v-if="showPaymentBlock"
v-model:activeKey="paymentsCollapseKeys"
bordered
class="no-print"
:style="{ borderRadius: '8px', overflow: 'hidden', marginTop: '8px' }"
>
<a-collapse-panel key="payments" header="اقساط و پرداخت‌ها">
<a-space wrap :size="12" :style="{ marginBottom: '12px' }">
<a-typography-text>
جمع: {{ formatMoney(invoice?.payableAmount ?? invoice?.totalAmount ?? 0) }}
</a-typography-text>
<a-typography-text>
وصول: {{ formatMoney(invoice?.paidAmount ?? 0) }}
</a-typography-text>
<a-typography-text>
مانده: {{ formatMoney(invoice?.remainingAmount ?? 0) }}
</a-typography-text>
</a-space>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-title :level="5" :style="{ margin: 0 }">اقساط</a-typography-title>
<a-space wrap :size="8">
<a-button @click="splitInstallments(2)">۲ قسط</a-button>
<a-button @click="splitInstallments(3)">۳ قسط</a-button>
<a-button @click="addInstallment">افزودن قسط</a-button>
</a-space>
</a-flex>
<a-row
v-for="item in installments"
:key="item.key"
:gutter="[16, 0]"
:style="{ marginTop: '8px' }"
>
<a-col :xs="24" :md="10">
<JalaliDateInput v-model="item.dueDate" />
</a-col>
<a-col :xs="24" :md="10">
<MoneyInput
v-model="item.amount"
:decimals="0"
placeholder="مبلغ قسط"
/>
</a-col>
<a-col :xs="24" :md="4">
<a-button danger block @click="removeInstallment(item.key)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
<a-typography-text v-if="!installments.length" type="secondary">
بدون برنامه اقساط.
</a-typography-text>
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
ثبت پرداخت
</a-typography-title>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ">
<MoneyInput v-model="payAmount" :decimals="0" placeholder="مبلغ" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش">
<a-select
v-model:value="payMethod"
:options="methodSelectOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ پرداخت">
<JalaliDateInput v-model="payDate" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="قسط">
<a-select
v-model:value="payInstallmentId"
:options="payInstallmentOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="یادداشت پرداخت">
<a-input v-model:value="payNotes" placeholder="یادداشت پرداخت" />
</a-form-item>
</a-col>
<a-col v-if="financeAccounts.length" :xs="24" :md="12">
<a-form-item label="حساب صندوق">
<a-select
v-model:value="payAccountId"
style="width: 100%"
:options="[
{ value: '', label: 'بدون اثر روی صندوق' },
...financeAccounts.map((account) => ({
value: account.id,
label: `واریز به ${account.name}`,
})),
]"
/>
</a-form-item>
</a-col>
<template v-if="payMethod === 'check'">
<a-col :xs="24" :md="8">
<a-form-item label="شماره چک">
<a-input v-model:value="chequeNumber" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="بانک">
<a-input v-model:value="chequeBank" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="سررسید چک">
<JalaliDateInput v-model="chequeDue" placeholder="سررسید چک" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="وضعیت چک">
<a-select
v-model:value="chequeStatus"
:options="chequeStatusOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
</template>
<a-col :span="24">
<a-button type="primary" :loading="paying" @click="submitPayment">
ثبت پرداخت
</a-button>
</a-col>
</a-row>
<a-flex
v-for="p in invoice?.payments ?? []"
:key="p.id"
justify="space-between"
align="center"
:style="{ marginTop: '8px' }"
>
<div>
<a-typography-text strong>{{ formatMoney(p.amount) }}</a-typography-text>
<a-typography-text type="secondary">
· {{ methodLabel[p.method as PaymentMethod] || p.method }}
<template v-if="p.cheque">
· چک {{ p.cheque.number }} / {{ p.cheque.bank }}
</template>
· {{ p.paidAt.slice(0, 10) }}
</a-typography-text>
</div>
<a-button type="link" danger @click="removePayment(p.id)">حذف</a-button>
</a-flex>
<a-typography-text
v-if="!(invoice?.payments?.length)"
type="secondary"
>
هنوز پرداختی ثبت نشده.
</a-typography-text>
</a-collapse-panel>
</a-collapse>
<a-space
v-if="!embedded"
class="no-print"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button @click="printDoc">
<template #icon><EyeOutlined /></template>
پیش‌نمایش
</a-button>
<a-button
v-if="!isNew"
danger
:disabled="saving"
@click="remove"
>
<template #icon><DeleteOutlined /></template>
حذف
</a-button>
<a-button type="primary" html-type="submit" :loading="saving">
<template #icon><CheckOutlined /></template>
{{ saveLabel }}
</a-button>
</a-space>
<a-space
v-if="embedded"
class="no-print"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" :loading="saving" @click="save">ذخیره</a-button>
<a-button :disabled="saving" @click="goBack">انصراف</a-button>
</a-space>
</a-card>
</a-col>
<a-col v-if="!embedded" :xs="24" :lg="7" :xl="6" class="no-print">
<a-affix :offset-top="24">
<a-card :bordered="false" title="خلاصه">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="جمع کل">
{{ formatMoney(subtotal) }}
</a-descriptions-item>
<a-descriptions-item label="تخفیف">
{{ formatMoney(discountNum) }}
</a-descriptions-item>
<a-descriptions-item label="مالیات">
{{ formatMoney(taxAmount) }}
</a-descriptions-item>
<a-descriptions-item label="قابل پرداخت">
{{ formatMoney(payableAmount) }}
</a-descriptions-item>
<a-descriptions-item v-if="docMode === 'invoice'" label="برآورد پورسانت">
{{ formatMoney(commissionPreview) }}
<template v-if="invoice?.commission?.status === 'posted'">
· ثبت‌شده {{ formatMoney(invoice.commission.amount) }}
</template>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-affix>
</a-col>
</a-row>
</a-form>
</a-spin>
<InvoiceDocPreview
v-model:open="previewOpen"
:doc-mode="docMode"
:org-name="orgName"
:number="number"
:title="title"
:status-label="previewStatusLabel"
:issued-at="issuedAt"
:due-or-valid="docMode === 'quote' ? validUntil : dueDate"
:due-or-valid-label="docMode === 'quote' ? 'اعتبار تا' : 'مهلت پرداخت'"
:customer-name="previewCustomer.name"
:customer-sub="previewCustomer.sub"
:notes="notes"
:currency="currency"
:lines="previewLines"
:subtotal="previewSubtotal"
:discount="discountNum"
:tax-amount="previewTaxAmount"
:tax-percent="taxPercentNum"
:payable="previewPayable"
/>
</div>
</template>