Files
CRM_Montzar/apps/web/src/views/InvoiceFormView.vue
T
MahdiandCursor 8981a552fc feat: gate treasury as an org module and group money inputs
Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization.

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

2643 lines
72 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 JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import BrandName from '@/components/brand/BrandName.vue';
import { brand } from '@mento/shared';
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 showExtra = ref(false);
const showShipping = ref(false);
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 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 bumpQty(line: LineDraft, delta: number) {
const next = Math.max(0.01, (Number(line.quantity) || 0) + delta);
line.quantity = String(Number.isInteger(next) ? next : Number(next.toFixed(2)));
}
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 productIcon(line: LineDraft): 'box' | 'briefcase' {
if (line.kind === 'service') return 'briefcase';
const product = products.value.find((p) => p.id === line.productId);
return product?.kind === 'service' ? 'briefcase' : 'box';
}
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 */
}
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 printDoc() {
window.print();
}
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();
},
);
onMounted(load);
</script>
<template>
<section class="page" :class="{ embedded }">
<header v-if="!embedded" class="page-head no-print">
<div class="head-copy">
<div class="title-row">
<span class="doc-ico">
<UiIcon name="document" :size="22" />
</span>
<div>
<h1>{{ pageTitle }}</h1>
<p class="sub">{{ pageSubtitle }}</p>
</div>
</div>
</div>
<button class="back-btn" type="button" @click="goBack">
<UiIcon name="chevron" :size="16" />
بازگشت
</button>
</header>
<p v-if="loading" class="muted center">در حال بارگذاری…</p>
<form v-else class="form" @submit.prevent="save">
<div class="print-banner">
<img :src="brand.logo" :alt="brand.name" class="print-logo" />
<BrandName />
<span v-if="number">{{ number }}</span>
</div>
<!-- Desktop layout -->
<div class="desk-layout">
<aside class="sidebar">
<div class="seg-tabs">
<button
type="button"
:class="{ on: docMode === 'invoice' }"
:disabled="!isNew && docMode !== 'invoice'"
@click="switchDocMode('invoice')"
>
فاکتور
</button>
<button
type="button"
:class="{ on: docMode === 'quote' }"
:disabled="!isNew && docMode !== 'quote'"
@click="switchDocMode('quote')"
>
پیش فاکتور
</button>
<button
v-if="docMode === 'credit'"
type="button"
:class="{ on: true }"
disabled
>
برگشتی
</button>
</div>
<section class="card">
<h2 class="card-title">
<span class="card-ico"><UiIcon name="layers" :size="16" /></span>
اطلاعات اصلی
</h2>
<label class="field">
<span>مشتری <em>*</em></span>
<div class="control">
<UiIcon name="user" :size="18" />
<select v-model="personId" required @change="onPersonChange">
<option value="">انتخاب مشتری</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ personLabel(p) }}
</option>
</select>
</div>
<RouterLink class="add-link" :to="{ name: 'person-new' }">
+ افزودن مشتری جدید
</RouterLink>
</label>
<label class="field">
<span>تاریخ <em>*</em></span>
<JalaliDateInput v-model="issuedAt" required />
</label>
<label class="field">
<span>وضعیت</span>
<div class="control">
<UiIcon name="flag" :size="18" />
<select v-model="status">
<option
v-for="(label, key) in statusOptions"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
</label>
<label class="field">
<span>ارز</span>
<div class="control">
<UiIcon name="dollar" :size="18" />
<select v-model="currency">
<option
v-for="opt in currencySelectOptions"
:key="opt.code"
:value="opt.code"
>
{{ opt.label }}
</option>
</select>
</div>
</label>
<template v-if="docMode === 'quote'">
<label class="field">
<span>اعتبار تا</span>
<select
v-model.number="validityDays"
@change="onValidityDaysChange"
>
<option :value="7">{{ toFaDigits(7) }} روز</option>
<option :value="15">{{ toFaDigits(15) }} روز</option>
<option :value="30">{{ toFaDigits(30) }} روز</option>
<option :value="60">{{ toFaDigits(60) }} روز</option>
</select>
</label>
<label v-if="validUntil" class="field">
<span>تاریخ اعتبار</span>
<JalaliDateInput
v-model="validUntil"
@update:model-value="validityMode = 'date'"
/>
</label>
</template>
<label v-else class="field">
<span>مهلت پرداخت</span>
<JalaliDateInput v-model="dueDate" />
</label>
<label class="field">
<span>یادداشت</span>
<textarea
v-model="notes"
rows="3"
placeholder="یادداشت خود را وارد کنید..."
/>
</label>
<div class="accords">
<button
class="accord"
type="button"
@click="showExtra = !showExtra"
>
<span class="accord-lead">
<UiIcon name="document" :size="16" />
اطلاعات تکمیلی
</span>
<UiIcon name="chevron" :size="16" :class="{ open: showExtra }" />
</button>
<div v-if="showExtra" class="accord-body muted">
فیلدی برای تکمیل وجود ندارد.
</div>
<button
class="accord"
type="button"
@click="showShipping = !showShipping"
>
<span class="accord-lead">
<UiIcon name="truck" :size="16" />
آدرس و ارسال
</span>
<UiIcon
name="chevron"
:size="16"
:class="{ open: showShipping }"
/>
</button>
<div v-if="showShipping" class="accord-body muted">
فیلدی برای تکمیل وجود ندارد.
</div>
</div>
<div class="side-actions">
<button class="btn primary" type="submit" :disabled="saving">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : saveLabel }}
</button>
<button
v-if="!isNew"
class="btn danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
</div>
</section>
</aside>
<main class="main">
<section class="card products-card">
<div class="products-head">
<h2 class="card-title">
<span class="card-ico"><UiIcon name="box" :size="16" /></span>
محصولات و خدمات
</h2>
<div class="products-tools">
<RouterLink class="btn primary add-product" :to="{ name: 'product-new' }">
+ افزودن محصول
</RouterLink>
<div class="search">
<UiIcon name="search" :size="16" />
<input
v-model="productSearch"
type="search"
placeholder="جستجوی محصول..."
/>
</div>
</div>
</div>
<div class="table-wrap">
<table class="lines-table">
<thead>
<tr>
<th>#</th>
<th>محصول</th>
<th>کد</th>
<th>قیمت واحد - {{ currencyLabel }}</th>
<th>تعداد</th>
<th>مبلغ کل - {{ currencyLabel }}</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(line, idx) in lines" :key="line.key">
<td class="num">{{ toFaDigits(idx + 1) }}</td>
<td>
<div class="prod-cell">
<span class="prod-thumb">
<UiIcon :name="productIcon(line)" :size="18" />
</span>
<div class="prod-meta">
<select
v-model="line.productId"
@change="onPickProduct(line)"
>
<option value="">انتخاب محصول…</option>
<option
v-for="p in filteredProducts"
:key="p.id"
:value="p.id"
>
{{ p.name }}
</option>
</select>
<input
v-model="line.name"
class="name-input"
placeholder="نام ردیف"
required
/>
<input
v-model="line.description"
class="desc-input"
placeholder="توضیح اختیاری"
/>
</div>
</div>
</td>
<td class="sku" dir="ltr">{{ line.sku || '—' }}</td>
<td>
<MoneyInput
v-model="line.unitPrice"
:decimals="0"
placeholder="قیمت"
/>
</td>
<td>
<div class="stepper">
<button
class="step-minus"
type="button"
aria-label="کاهش"
@click="bumpQty(line, -1)"
>
<UiIcon name="minus" :size="14" />
</button>
<strong>{{ toFaDigits(line.quantity || '0') }}</strong>
<button
class="step-plus"
type="button"
aria-label="افزایش"
@click="bumpQty(line, 1)"
>
<UiIcon name="plus" :size="14" />
</button>
</div>
</td>
<td class="line-sum">{{ formatMoney(lineTotal(line)) }}</td>
<td>
<button
class="icon-trash"
type="button"
aria-label="حذف ردیف"
@click="removeLine(line.key)"
>
<UiIcon name="trash" :size="16" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button class="add-row" type="button" @click="addLine">
+ افزودن ردیف جدید
</button>
<div class="summary">
<div class="sum-row">
<span>جمع کل</span>
<strong>{{ formatMoney(subtotal) }}</strong>
</div>
<div class="sum-row">
<span>تخفیف</span>
<MoneyInput v-model="discountAmount" :decimals="0" placeholder="۰" />
</div>
<div class="sum-row">
<span>مالیات (%)</span>
<input
v-model="taxPercent"
type="number"
min="0"
step="0.01"
class="tax-input"
/>
</div>
<div class="sum-row muted-sm">
<span>مبلغ مالیات</span>
<span>{{ formatMoney(taxAmount) }}</span>
</div>
<div class="sum-row payable">
<span>مبلغ قابل پرداخت</span>
<strong>{{ formatMoney(payableAmount) }}</strong>
</div>
<div v-if="docMode === 'invoice'" class="commission-box">
<div class="days-row">
<button
type="button"
:class="{ on: commissionMode === 'percent' }"
@click="commissionMode = 'percent'"
>
درصد
</button>
<button
type="button"
:class="{ on: commissionMode === 'amount' }"
@click="commissionMode = 'amount'"
>
مبلغ ثابت
</button>
</div>
<label v-if="commissionMode === 'percent'" class="field">
<span>پورسانت کارشناس (%)</span>
<input
v-model="commissionPercent"
type="number"
min="0"
max="100"
step="0.1"
:placeholder="String(defaultCommissionPercent || 0)"
/>
</label>
<label v-else class="field">
<span>مبلغ پورسانت</span>
<MoneyInput v-model="commissionFixedAmount" :decimals="0" />
</label>
<p class="muted-sm">
برآورد پورسانت: {{ formatMoney(commissionPreview) }}
<template v-if="invoice?.commission?.status === 'posted'">
· ثبت‌شده {{ formatMoney(invoice.commission.amount) }}
</template>
</p>
</div>
</div>
</section>
<section
v-if="showPaymentBlock"
class="card pay-card no-print"
>
<button
class="accord pay-toggle"
type="button"
@click="showPayments = !showPayments"
>
اقساط و پرداخت‌ها
<UiIcon name="chevron" :size="16" :class="{ open: showPayments }" />
</button>
<div v-if="showPayments" class="pay-body">
<div v-if="invoice" class="pay-summary">
<span>جمع: {{ formatMoney(invoice.payableAmount ?? invoice.totalAmount) }}</span>
<span>وصول: {{ formatMoney(invoice.paidAmount) }}</span>
<span>مانده: {{ formatMoney(invoice.remainingAmount) }}</span>
</div>
<div class="inst-head">
<h3>اقساط</h3>
<div class="split">
<button class="btn ghost sm" type="button" @click="splitInstallments(2)">
۲ قسط
</button>
<button class="btn ghost sm" type="button" @click="splitInstallments(3)">
۳ قسط
</button>
<button class="btn ghost sm" 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="مبلغ قسط"
/>
<button
class="icon-trash"
type="button"
@click="removeInstallment(item.key)"
>
<UiIcon name="trash" :size="16" />
</button>
</div>
<p v-if="!installments.length" class="muted">بدون برنامه اقساط.</p>
<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"
>
قسط {{ toFaDigits(inst.position + 1) }} — {{ formatMoney(inst.amount) }}
</option>
</select>
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
<select v-if="financeAccounts.length" v-model="payAccountId">
<option value="">بدون اثر روی صندوق</option>
<option v-for="account in financeAccounts" :key="account.id" :value="account.id">
واریز به {{ account.name }}
</option>
</select>
<div v-if="payMethod === 'check'" class="cheque-fields">
<input v-model="chequeNumber" placeholder="شماره چک" />
<input v-model="chequeBank" placeholder="بانک" />
<JalaliDateInput v-model="chequeDue" placeholder="سررسید چک" />
<select v-model="chequeStatus">
<option value="pending">در جریان</option>
<option value="cleared">وصول شد</option>
<option value="bounced">برگشت</option>
</select>
</div>
<button
class="btn primary"
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 }}
<template v-if="p.cheque">
· چک {{ p.cheque.number }} / {{ p.cheque.bank }}
</template>
· {{ p.paidAt.slice(0, 10) }}
</span>
</div>
<button
class="btn ghost sm"
type="button"
@click="removePayment(p.id)"
>
حذف
</button>
</div>
</div>
<p v-else class="muted">هنوز پرداختی ثبت نشده.</p>
</div>
</section>
</main>
</div>
<!-- Mobile layout -->
<div class="mob-layout no-print">
<div v-show="mobileStep === 1" class="mob-step">
<div class="seg-tabs">
<button
type="button"
:class="{ on: docMode === 'invoice' }"
:disabled="!isNew && docMode !== 'invoice'"
@click="switchDocMode('invoice')"
>
فاکتور
</button>
<button
type="button"
:class="{ on: docMode === 'quote' }"
:disabled="!isNew && docMode !== 'quote'"
@click="switchDocMode('quote')"
>
پیش فاکتور
</button>
<button
v-if="docMode === 'credit'"
type="button"
:class="{ on: true }"
disabled
>
برگشتی
</button>
</div>
<section class="card">
<h2 class="card-title">
<span class="card-ico"><UiIcon name="layers" :size="16" /></span>
اطلاعات
</h2>
<label class="field">
<span>مشتری <em>*</em></span>
<div class="control">
<UiIcon name="user" :size="18" />
<select v-model="personId" required @change="onPersonChange">
<option value="">انتخاب مشتری</option>
<option v-for="p in people" :key="p.id" :value="p.id">
{{ personLabel(p) }}
</option>
</select>
</div>
<RouterLink class="add-link" :to="{ name: 'person-new' }">
+ افزودن مشتری جدید
</RouterLink>
</label>
<label class="field">
<span>تاریخ <em>*</em></span>
<JalaliDateInput v-model="issuedAt" required />
</label>
<label class="field">
<span>وضعیت</span>
<div class="control">
<UiIcon name="flag" :size="18" />
<select v-model="status">
<option
v-for="(label, key) in statusOptions"
:key="key"
:value="key"
>
{{ label }}
</option>
</select>
</div>
</label>
<label class="field">
<span>ارز</span>
<div class="control">
<UiIcon name="dollar" :size="18" />
<select v-model="currency">
<option
v-for="opt in currencySelectOptions"
:key="opt.code"
:value="opt.code"
>
{{ opt.label }}
</option>
</select>
</div>
</label>
<template v-if="docMode === 'quote'">
<label class="field">
<span>اعتبار تا</span>
<JalaliDateInput
v-model="validUntil"
@update:model-value="validityMode = 'date'"
/>
</label>
<label class="field">
<span>مدت اعتبار</span>
<select
v-model.number="validityDays"
@change="onValidityDaysChange"
>
<option :value="7">{{ toFaDigits(7) }} روز</option>
<option :value="15">{{ toFaDigits(15) }} روز</option>
<option :value="30">{{ toFaDigits(30) }} روز</option>
<option :value="60">{{ toFaDigits(60) }} روز</option>
</select>
</label>
</template>
<label v-else class="field">
<span>مهلت پرداخت</span>
<JalaliDateInput v-model="dueDate" />
</label>
<label class="field">
<span>یادداشت</span>
<textarea
v-model="notes"
rows="3"
placeholder="یادداشت خود را وارد کنید..."
/>
</label>
<div class="accords">
<button
class="accord"
type="button"
@click="showExtra = !showExtra"
>
<span class="accord-lead">
<UiIcon name="document" :size="16" />
اطلاعات تکمیلی
</span>
<UiIcon name="chevron" :size="16" :class="{ open: showExtra }" />
</button>
<div v-if="showExtra" class="accord-body muted">
فیلدی برای تکمیل وجود ندارد.
</div>
<button
class="accord"
type="button"
@click="showShipping = !showShipping"
>
<span class="accord-lead">
<UiIcon name="truck" :size="16" />
آدرس و ارسال
</span>
<UiIcon
name="chevron"
:size="16"
:class="{ open: showShipping }"
/>
</button>
<div v-if="showShipping" class="accord-body muted">
فیلدی برای تکمیل وجود ندارد.
</div>
</div>
</section>
<div class="mob-bar">
<button
class="mob-back"
type="button"
aria-label="بازگشت"
@click="goBack"
>
<UiIcon name="chevron" :size="18" />
</button>
<button class="btn primary mob-continue" type="button" @click="mobileStep = 2">
ادامه به محصولات →
</button>
</div>
</div>
<div v-show="mobileStep === 2" class="mob-step">
<button
class="mob-back step2-back"
type="button"
aria-label="بازگشت به اطلاعات"
@click="mobileStep = 1"
>
<UiIcon name="chevron" :size="18" />
</button>
<section class="card">
<div class="mob-products-head">
<h2 class="card-title">
<span class="card-ico"><UiIcon name="box" :size="16" /></span>
محصولات و خدمات
<span class="count-badge">{{ toFaDigits(lines.length) }} مورد</span>
</h2>
<button
class="btn primary icon-add"
type="button"
aria-label="افزودن محصول"
@click="addLine"
>
<UiIcon name="plus" :size="18" />
</button>
</div>
<div class="search">
<UiIcon name="search" :size="16" />
<input
v-model="productSearch"
type="search"
placeholder="جستجوی محصول..."
/>
</div>
<article
v-for="(line, idx) in lines"
:key="line.key"
class="mob-line"
>
<div class="mob-line-top">
<span class="prod-thumb">
<UiIcon :name="productIcon(line)" :size="18" />
</span>
<strong>{{ toFaDigits(idx + 1) }}</strong>
<button
class="icon-trash"
type="button"
@click="removeLine(line.key)"
>
<UiIcon name="trash" :size="16" />
</button>
</div>
<select v-model="line.productId" @change="onPickProduct(line)">
<option value="">انتخاب محصول…</option>
<option
v-for="p in filteredProducts"
:key="p.id"
:value="p.id"
>
{{ p.name }}
</option>
</select>
<input v-model="line.name" placeholder="نام ردیف" required />
<div class="mob-line-grid">
<div class="mob-unit">
<span class="muted-sm">قیمت واحد - {{ currencyLabel }}</span>
<MoneyInput
v-model="line.unitPrice"
:decimals="0"
placeholder="قیمت واحد"
/>
</div>
<div class="stepper">
<button
class="step-minus"
type="button"
@click="bumpQty(line, -1)"
>
<UiIcon name="minus" :size="14" />
</button>
<strong>{{ toFaDigits(line.quantity || '0') }}</strong>
<button
class="step-plus"
type="button"
@click="bumpQty(line, 1)"
>
<UiIcon name="plus" :size="14" />
</button>
</div>
</div>
<p class="mob-total">
مبلغ کل: {{ formatMoney(lineTotal(line)) }}
</p>
</article>
<button class="add-row" type="button" @click="addLine">
+ افزودن محصول دیگر
</button>
<div class="summary">
<div class="sum-row">
<span>جمع کل</span>
<strong>{{ formatMoney(subtotal) }}</strong>
</div>
<div class="sum-row">
<span>تخفیف</span>
<MoneyInput v-model="discountAmount" :decimals="0" />
</div>
<div class="sum-row">
<span>مالیات (%)</span>
<input
v-model="taxPercent"
type="number"
min="0"
step="0.01"
class="tax-input"
/>
</div>
<div class="sum-row payable">
<span>مبلغ قابل پرداخت</span>
<strong>{{ formatMoney(payableAmount) }}</strong>
</div>
<div v-if="docMode === 'invoice'" class="commission-box">
<label class="field">
<span>پورسانت کارشناس (%)</span>
<input
v-model="commissionPercent"
type="number"
min="0"
max="100"
step="0.1"
/>
</label>
<p class="muted-sm">برآورد: {{ formatMoney(commissionPreview) }}</p>
</div>
</div>
</section>
<div class="mob-bar">
<button class="btn ghost" type="button" @click="printDoc">
<UiIcon name="eye" :size="16" />
پیش‌نمایش
</button>
<button class="btn primary mob-continue" type="submit" :disabled="saving">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : saveLabel }}
</button>
</div>
</div>
</div>
<p v-if="error" class="error no-print">{{ error }}</p>
<footer class="form-foot no-print">
<button class="btn ghost" type="button" @click="printDoc">
<UiIcon name="eye" :size="16" />
پیش‌نمایش
</button>
<button
v-if="!isNew"
class="btn danger"
type="button"
:disabled="saving"
@click="remove"
>
حذف
</button>
<button class="btn primary" type="submit" :disabled="saving">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : saveLabel }}
</button>
</footer>
</form>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions no-print">
<button class="btn primary" type="button" :disabled="saving" @click="save">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
<button class="btn cancel" type="button" :disabled="saving" @click="goBack">انصراف</button>
</div>
</section>
</template>
<style scoped>
.page {
--teal: #0d9488;
--teal-soft: #f0fdfa;
--teal-border: #99f6e4;
--ink: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--card: #fff;
--radius: 10px;
display: grid;
gap: 1rem;
min-width: 0;
width: 100%;
max-width: 100%;
}
.page.embedded {
--radius: 10px;
max-width: none;
}
.embed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.page-head {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.back-btn {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.back-btn :deep(.ico) {
transform: rotate(90deg);
}
.title-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.doc-ico {
width: 44px;
height: 44px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: var(--teal-soft);
color: var(--teal);
flex-shrink: 0;
}
h1 {
margin: 0;
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
font-weight: 600;
color: var(--ink);
}
.sub {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.form {
display: grid;
gap: 1rem;
}
.desk-layout {
display: none;
grid-template-columns: minmax(260px, 34%) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.mob-layout {
display: grid;
gap: 1rem;
}
.card {
background: var(--card);
border-radius: var(--radius);
padding: 1rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
border: 1px solid #f1f5f9;
display: grid;
gap: 0.85rem;
}
.card-title {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.card-ico {
width: 30px;
height: 30px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: var(--teal-soft);
color: var(--teal);
}
.seg-tabs {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 0.3rem;
padding: 0.2rem;
border-radius: var(--radius);
background: #f8fafc;
margin-bottom: 0.15rem;
}
.seg-tabs button {
min-height: var(--control-h);
height: var(--control-h);
border: 0;
border-radius: var(--radius);
background: transparent;
color: #64748b;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.seg-tabs button.on {
background: #0d9488;
color: #fff;
}
.seg-tabs button:disabled:not(.on) {
opacity: 0.45;
cursor: not-allowed;
}
.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;
margin-inline-start: 0.15rem;
}
.control {
position: relative;
display: flex;
align-items: center;
}
.control > :deep(.ico),
.control > .ico {
position: absolute;
inset-inline-start: 0.75rem;
color: #94a3b8;
pointer-events: none;
z-index: 1;
}
.control input,
.control select,
.field > select,
.field textarea,
.search input,
.mob-line input,
.mob-line select,
.pay-form input,
.pay-form select,
.installment-row input,
.lines-table input,
.lines-table select,
.tax-input,
.summary :deep(input) {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
color: var(--ink);
box-sizing: border-box;
}
.field textarea {
height: auto;
min-height: 5.5rem;
padding: 0.65rem 0.85rem;
}
.control input,
.control select {
padding-inline-start: 2.4rem;
}
.add-link {
color: var(--teal);
font-size: 0.82rem;
font-weight: 700;
text-decoration: none;
width: fit-content;
}
.validity-seg,
.days-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.validity-seg button,
.days-row button {
flex: 1;
min-width: 4.2rem;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
font: inherit;
font-weight: 700;
color: #64748b;
cursor: pointer;
}
.validity-seg button.on,
.days-row button.on {
border-color: var(--teal-border);
background: var(--teal-soft);
color: var(--teal);
}
.accords {
display: grid;
gap: 0.45rem;
}
.accord {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid #eef2f6;
background: #f8fafc;
border-radius: var(--radius);
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
font-weight: 600;
color: var(--ink);
cursor: pointer;
}
.accord-lead {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.accord-lead :deep(.ico) {
color: var(--teal);
transform: none;
}
.accord > :deep(.ico) {
color: var(--teal);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
.accord > :deep(.ico.open) {
transform: rotate(90deg);
}
.accord-body {
padding: 0.35rem 0.5rem 0.15rem;
font-size: 0.85rem;
}
.side-actions {
display: grid;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
border: 1px solid transparent;
font: inherit;
font-weight: 600;
cursor: pointer;
text-decoration: none;
box-sizing: border-box;
}
.btn.cancel {
background: #fff;
color: #64748b;
border-color: var(--line);
}
.btn.primary {
background: var(--teal);
color: #fff;
}
.btn.ghost {
background: #fff;
color: var(--teal);
border-color: var(--teal-border);
}
.btn.danger {
background: #fff;
color: #b42318;
border-color: #fecaca;
}
.btn.sm {
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.7rem;
font-size: 0.85rem;
}
.btn.full {
width: 100%;
}
.btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.products-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
}
.products-tools {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.search {
position: relative;
min-width: min(100%, 220px);
}
.search :deep(.ico) {
position: absolute;
inset-inline-start: 0.7rem;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
}
.search input {
padding-inline-start: 2.2rem;
}
.table-wrap {
overflow: auto;
}
.lines-table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
.lines-table th {
text-align: start;
font-size: 0.78rem;
color: var(--muted);
font-weight: 700;
padding: 0.45rem 0.4rem;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.lines-table td {
padding: 0.65rem 0.4rem;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.num {
color: var(--muted);
font-weight: 700;
width: 2rem;
}
.prod-cell {
display: flex;
gap: 0.55rem;
align-items: flex-start;
min-width: 220px;
}
.prod-thumb {
width: 40px;
height: 40px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: var(--teal-soft);
color: var(--teal);
flex-shrink: 0;
}
.prod-meta {
display: grid;
gap: 0.3rem;
flex: 1;
min-width: 0;
}
.name-input,
.desc-input {
font-size: 0.85rem;
}
.desc-input {
color: var(--muted);
}
.sku {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.stepper {
display: inline-flex;
align-items: center;
justify-content: space-between;
min-width: 110px;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.15rem;
background: #fff;
}
.stepper button {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
display: grid;
place-items: center;
cursor: pointer;
}
.stepper .step-minus {
background: #f1f5f9;
color: #64748b;
}
.stepper .step-plus {
background: var(--teal);
color: #fff;
}
.stepper strong {
font-size: 0.92rem;
min-width: 1.5rem;
text-align: center;
}
.line-sum {
font-weight: 600;
white-space: nowrap;
}
.icon-trash {
width: 36px;
height: 36px;
border: 0;
border-radius: var(--radius);
background: #fff1f2;
color: #e11d48;
display: grid;
place-items: center;
cursor: pointer;
}
.add-row {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border: 1.5px dashed var(--teal-border);
border-radius: var(--radius);
background: var(--teal-soft);
color: var(--teal);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.summary {
display: grid;
gap: 0.55rem;
margin-top: 0.25rem;
padding: 0.85rem;
border-radius: var(--radius);
background: #f8fafc;
max-width: 360px;
margin-inline-start: auto;
width: 100%;
}
.sum-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.92rem;
}
.sum-row :deep(input),
.sum-row .tax-input {
max-width: 140px;
text-align: start;
}
.sum-row.payable {
margin-top: 0.2rem;
padding: 0.75rem 0.85rem;
border-radius: var(--radius);
background: var(--teal-soft);
color: var(--teal);
border: 1px solid var(--teal-border);
}
.sum-row.payable strong {
font-size: 1.05rem;
color: var(--teal);
}
.muted-sm {
color: var(--muted);
font-size: 0.82rem;
}
.form-foot {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
justify-content: flex-end;
padding-top: 0.25rem;
}
.error {
color: #b42318;
margin: 0;
font-weight: 700;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
.mob-step {
display: grid;
gap: 0.85rem;
}
.mob-bar {
display: flex;
align-items: center;
gap: 0.65rem;
position: sticky;
bottom: calc(0.5rem + var(--mobile-tabbar-h));
padding: 0.35rem 0;
background: linear-gradient(to top, #f8fafc 70%, transparent);
}
.mob-continue {
flex: 1;
}
.mob-products-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.count-badge {
margin-inline-start: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--teal-soft);
color: var(--teal);
font-size: 0.75rem;
font-weight: 600;
}
.icon-add {
width: var(--control-h);
min-height: var(--control-h);
height: var(--control-h);
padding: 0;
border-radius: var(--radius);
}
.mob-unit {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.step2-back {
margin-bottom: 0.15rem;
}
.mob-back {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
border: 1px solid var(--line);
background: #fff;
color: var(--teal);
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.mob-back :deep(.ico) {
transform: rotate(90deg);
}
.mob-line {
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid #eef2f6;
border-radius: var(--radius);
background: #fff;
}
.mob-line-top {
display: flex;
align-items: center;
gap: 0.55rem;
}
.mob-line-top .icon-trash {
margin-inline-start: auto;
}
.mob-line-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
align-items: center;
}
.mob-total {
margin: 0;
font-weight: 600;
color: var(--ink);
}
.pay-card {
gap: 0.65rem;
}
.pay-toggle {
margin: 0;
}
.pay-body {
display: grid;
gap: 0.75rem;
}
.pay-summary {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
font-weight: 700;
font-size: 0.9rem;
}
.inst-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
}
.inst-head h3,
.pay-body h3 {
margin: 0;
font-size: 0.95rem;
}
.split {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.installment-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.45rem;
align-items: center;
}
.pay-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.cheque-fields,
.commission-box {
grid-column: 1 / -1;
display: grid;
gap: 0.45rem;
}
.commission-box {
margin-top: 0.45rem;
padding-top: 0.55rem;
border-top: 1px dashed var(--line);
}
.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: var(--radius);
background: #f8fafc;
}
.print-banner {
display: none;
}
@media (min-width: 960px) {
.desk-layout {
display: grid;
}
.mob-layout {
display: none;
}
}
@media (max-width: 959px) {
.form-foot {
display: none;
}
.side-actions {
display: none;
}
.page.embedded .embed-actions {
display: none !important;
}
.page.embedded .mob-bar {
bottom: 0;
margin-inline: -0.15rem;
padding: 0.65rem 0 calc(0.65rem + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #f1f5f9;
z-index: 3;
}
}
@media (max-width: 700px) {
.pay-form,
.installment-row {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.desk-layout {
display: grid !important;
}
.mob-layout {
display: none !important;
}
.print-banner {
display: flex;
align-items: center;
gap: 0.6rem;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.print-logo {
width: 36px;
height: 36px;
object-fit: contain;
border-radius: var(--radius);
}
.page {
max-width: none;
}
.card {
box-shadow: none;
border: 0;
padding: 0;
}
input,
select,
textarea {
border: 0 !important;
padding: 0.2rem 0 !important;
}
}
</style>