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

2384 lines
63 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
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 type {
CreateInvoiceRequest,
CreatePaymentRequest,
CreateQuoteRequest,
DealDto,
DealLineItemInput,
InvoiceDto,
InvoiceInstallmentInput,
InvoiceStatus,
OrganizationDto,
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 route = useRoute();
const router = useRouter();
const loadedInvoiceKind = ref<'invoice' | 'credit' | null>(null);
const docMode = computed<DocMode>(() => {
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(() =>
typeof route.params.id === 'string' ? route.params.id : '',
);
const isNew = computed(
() => 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 payAmount = ref('');
const payMethod = ref<PaymentMethod>('transfer');
const payNotes = ref('');
const payInstallmentId = ref('');
const payDate = ref(formatLocalDateInput(new Date()));
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().toLowerCase();
if (!q) return products.value;
return products.value.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
(p.sku && p.sku.toLowerCase().includes(q)) ||
(p.description && p.description.toLowerCase().includes(q)),
);
});
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,
);
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 =
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 {
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 = [];
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;
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 (!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 && 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',
};
}
function quotePayload(): CreateQuoteRequest {
const fromDealId =
isNew.value && 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());
}
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;
}
paying.value = true;
error.value = '';
try {
const body: CreatePaymentRequest = {
amount: Number(payAmount.value),
method: payMethod.value,
notes: payNotes.value || undefined,
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
installmentId: payInstallmentId.value || null,
};
const { data } = await api.post<InvoiceDto>(
`/invoices/${id.value}/payments`,
body,
);
invoice.value = data;
payNotes.value = '';
payInstallmentId.value = '';
await load();
} catch (e) {
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() {
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">
<header 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">
<strong>منتو</strong>
<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>
</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="یادداشت پرداخت" />
<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 }}
· {{ 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>
</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>
</section>
</template>
<style scoped>
.page {
--teal: #0d9488;
--teal-soft: #f0fdfa;
--teal-border: #99f6e4;
--ink: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--card: #fff;
--radius: 14px;
display: grid;
gap: 1rem;
min-width: 0;
max-width: 1180px;
}
.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: 12px;
padding: 0.55rem 0.9rem;
font: inherit;
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
}
.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: 12px;
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: 800;
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: 800;
}
.card-ico {
width: 30px;
height: 30px;
border-radius: 10px;
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.28rem;
border-radius: 14px;
background: #f8fafc;
margin-bottom: 0.15rem;
}
.seg-tabs button {
min-height: 40px;
border: 0;
border-radius: 12px;
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: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
color: var(--ink);
}
.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: 38px;
border: 1px solid var(--line);
border-radius: 10px;
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: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
font-weight: 800;
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: 44px;
padding: 0.55rem 1rem;
border-radius: 12px;
border: 1px solid transparent;
font: inherit;
font-weight: 800;
cursor: pointer;
text-decoration: none;
}
.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: 36px;
padding: 0.35rem 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;
min-height: 42px;
}
.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: 12px;
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: 40px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.15rem;
background: #fff;
}
.stepper button {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
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: 800;
white-space: nowrap;
}
.icon-trash {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
background: #fff1f2;
color: #e11d48;
display: grid;
place-items: center;
cursor: pointer;
}
.add-row {
width: 100%;
min-height: 46px;
border: 1.5px dashed var(--teal-border);
border-radius: 12px;
background: var(--teal-soft);
color: var(--teal);
font: inherit;
font-weight: 800;
cursor: pointer;
}
.summary {
display: grid;
gap: 0.55rem;
margin-top: 0.25rem;
padding: 0.85rem;
border-radius: 14px;
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;
min-height: 40px;
padding: 0.45rem 0.65rem;
}
.sum-row.payable {
margin-top: 0.2rem;
padding: 0.75rem 0.85rem;
border-radius: 12px;
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: 0.5rem;
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: 800;
}
.icon-add {
width: 42px;
min-height: 42px;
padding: 0;
border-radius: 12px;
}
.mob-unit {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.step2-back {
margin-bottom: 0.15rem;
}
.mob-back {
width: 42px;
height: 42px;
border-radius: 999px;
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: 14px;
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: 800;
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;
}
.pay-list {
display: grid;
gap: 0.45rem;
}
.pay-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--line);
border-radius: 12px;
background: #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;
}
}
@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;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
.page {
max-width: none;
}
.card {
box-shadow: none;
border: 0;
padding: 0;
}
input,
select,
textarea {
border: 0 !important;
padding: 0.2rem 0 !important;
}
}
</style>