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>
2643 lines
72 KiB
Vue
2643 lines
72 KiB
Vue
<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>
|