Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance. Co-authored-by: Cursor <cursoragent@cursor.com>
428 lines
15 KiB
Vue
428 lines
15 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue';
|
|
import {
|
|
Button as AButton,
|
|
Flex as AFlex,
|
|
Modal as AModal,
|
|
Segmented as ASegmented,
|
|
Space as ASpace,
|
|
Typography,
|
|
} from 'ant-design-vue';
|
|
import { PrinterOutlined } from '@ant-design/icons-vue';
|
|
import { brand } from '@mento/shared';
|
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
|
import { formatMoney, currencySuffix } from '@/lib/money';
|
|
|
|
const { Text: ATypographyText } = Typography;
|
|
|
|
export type PreviewLine = {
|
|
name: string;
|
|
description?: string;
|
|
sku?: string;
|
|
quantity: number;
|
|
unitPrice: number;
|
|
total: number;
|
|
};
|
|
|
|
type PaperSize = 'A4' | 'A5';
|
|
|
|
const props = defineProps<{
|
|
open: boolean;
|
|
docMode: 'invoice' | 'quote' | 'credit';
|
|
orgName: string;
|
|
number: string;
|
|
title: string;
|
|
statusLabel: string;
|
|
issuedAt: string;
|
|
dueOrValid: string;
|
|
dueOrValidLabel: string;
|
|
customerName: string;
|
|
customerSub?: string;
|
|
notes: string;
|
|
currency: string;
|
|
lines: PreviewLine[];
|
|
subtotal: number;
|
|
discount: number;
|
|
taxAmount: number;
|
|
taxPercent: number;
|
|
payable: number;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
'update:open': [value: boolean];
|
|
}>();
|
|
|
|
const paperSize = ref<PaperSize>('A4');
|
|
|
|
const thStyle = {
|
|
padding: '7px 8px',
|
|
textAlign: 'right' as const,
|
|
color: '#6b7280',
|
|
fontWeight: '700',
|
|
fontSize: '11px',
|
|
borderBottom: '1px solid #e5e7eb',
|
|
};
|
|
const tdStyle = {
|
|
padding: '7px 8px',
|
|
textAlign: 'right' as const,
|
|
borderBottom: '1px solid #e5e7eb',
|
|
verticalAlign: 'top' as const,
|
|
};
|
|
|
|
watch(
|
|
() => props.open,
|
|
(open) => {
|
|
if (open) paperSize.value = 'A4';
|
|
},
|
|
);
|
|
|
|
const paperOptions = [
|
|
{ value: 'A4', label: 'A4' },
|
|
{ value: 'A5', label: 'A5' },
|
|
];
|
|
|
|
const docTypeLabel = computed(() => {
|
|
if (props.docMode === 'quote') return 'پیشفاکتور';
|
|
if (props.docMode === 'credit') return 'فاکتور برگشتی';
|
|
return 'فاکتور فروش';
|
|
});
|
|
|
|
const accent = '#10b981';
|
|
|
|
const displayNumber = computed(() => props.number?.trim() || 'پیشنویس');
|
|
|
|
const paperWidth = computed(() => (paperSize.value === 'A5' ? 520 : 720));
|
|
|
|
function faDate(value: string) {
|
|
if (!value) return '—';
|
|
try {
|
|
return formatJalaliNumeric(new Date(value));
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
emit('update:open', false);
|
|
}
|
|
|
|
function escapeHtml(value: string) {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function money(n: number) {
|
|
return formatMoney(n, props.currency);
|
|
}
|
|
|
|
function printSheet() {
|
|
const size = paperSize.value;
|
|
const rowsHtml = props.lines.length
|
|
? props.lines
|
|
.map(
|
|
(line, index) => `<tr>
|
|
<td>${toFaDigits(index + 1)}</td>
|
|
<td>
|
|
<strong>${escapeHtml(line.name)}</strong>
|
|
${line.description ? `<div class="sub">${escapeHtml(line.description)}</div>` : ''}
|
|
</td>
|
|
<td class="ltr">${escapeHtml(line.sku || '—')}</td>
|
|
<td>${toFaDigits(line.quantity)}</td>
|
|
<td class="ltr">${escapeHtml(money(line.unitPrice))}</td>
|
|
<td class="ltr">${escapeHtml(money(line.total))}</td>
|
|
</tr>`,
|
|
)
|
|
.join('')
|
|
: `<tr><td colspan="6" class="empty">ردیفی ثبت نشده است</td></tr>`;
|
|
|
|
const win = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1200');
|
|
if (!win) return;
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="fa" dir="rtl">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${escapeHtml(docTypeLabel.value)} ${escapeHtml(displayNumber.value)}</title>
|
|
<style>
|
|
@page { size: ${size}; margin: 12mm; }
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "IRANSans", Tahoma, sans-serif;
|
|
color: #111827;
|
|
font-size: ${size === 'A5' ? '11px' : '12.5px'};
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
.top { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
|
.org { font-size: ${size === 'A5' ? '15px' : '18px'}; font-weight: 800; margin: 0; }
|
|
.doc { text-align: left; }
|
|
.doc-type { color: ${accent}; font-weight: 800; font-size: ${size === 'A5' ? '13px' : '15px'}; }
|
|
.num { font-weight: 700; direction: ltr; unicode-bidi: isolate; margin-top: 4px; }
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 1fr 1fr;
|
|
gap: 8px 16px;
|
|
padding: 10px 0;
|
|
border-top: 1px solid #e5e7eb;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
margin-bottom: 14px;
|
|
}
|
|
.lbl { color: #6b7280; font-size: 11px; display: block; margin-bottom: 2px; }
|
|
.val { font-weight: 600; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { padding: 7px 8px; border-bottom: 1px solid #e5e7eb; text-align: right; vertical-align: top; }
|
|
th { color: #6b7280; font-weight: 700; font-size: 11px; background: #f9fafb; }
|
|
.sub { color: #6b7280; font-size: 11px; margin-top: 2px; }
|
|
.empty { text-align: center; color: #9ca3af; padding: 16px !important; }
|
|
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
|
|
.foot {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 20px;
|
|
margin-top: 14px;
|
|
align-items: flex-start;
|
|
}
|
|
.notes { flex: 1; color: #374151; }
|
|
.totals { width: 240px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; }
|
|
.t-row { display: flex; justify-content: space-between; margin-bottom: 6px; }
|
|
.t-row.pay { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; font-weight: 800; color: ${accent}; }
|
|
.muted { color: #6b7280; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="top">
|
|
<div>
|
|
<p class="org">${escapeHtml(props.orgName || brand.nameFa)}</p>
|
|
<div class="muted">${escapeHtml(docTypeLabel.value)}</div>
|
|
</div>
|
|
<div class="doc">
|
|
<div class="doc-type">${escapeHtml(docTypeLabel.value)}</div>
|
|
<div class="num">${escapeHtml(displayNumber.value)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="grid">
|
|
<div><span class="lbl">صادر برای</span><div class="val">${escapeHtml(props.customerName || '—')}</div>
|
|
${props.customerSub ? `<div class="sub">${escapeHtml(props.customerSub)}</div>` : ''}
|
|
</div>
|
|
<div><span class="lbl">عنوان</span><div class="val">${escapeHtml(props.title || docTypeLabel.value)}</div></div>
|
|
<div><span class="lbl">وضعیت</span><div class="val">${escapeHtml(props.statusLabel)}</div></div>
|
|
<div><span class="lbl">تاریخ صدور</span><div class="val">${escapeHtml(faDate(props.issuedAt))}</div></div>
|
|
<div><span class="lbl">${escapeHtml(props.dueOrValidLabel)}</span><div class="val">${escapeHtml(faDate(props.dueOrValid))}</div></div>
|
|
<div><span class="lbl">ارز</span><div class="val">${escapeHtml(currencySuffix(props.currency))} (${escapeHtml(props.currency)})</div></div>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>شرح</th>
|
|
<th>کد</th>
|
|
<th>تعداد</th>
|
|
<th>فی</th>
|
|
<th>مبلغ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rowsHtml}</tbody>
|
|
</table>
|
|
<div class="foot">
|
|
<div class="notes">
|
|
${
|
|
props.notes?.trim()
|
|
? `<span class="lbl">یادداشت</span><div>${escapeHtml(props.notes)}</div>`
|
|
: ''
|
|
}
|
|
</div>
|
|
<div class="totals">
|
|
<div class="t-row"><span class="muted">جمع</span><span class="ltr">${escapeHtml(money(props.subtotal))}</span></div>
|
|
<div class="t-row"><span class="muted">تخفیف</span><span class="ltr">${escapeHtml(money(props.discount))}</span></div>
|
|
<div class="t-row"><span class="muted">مالیات</span><span class="ltr">${escapeHtml(money(props.taxAmount))}</span></div>
|
|
<div class="t-row pay"><span>قابل پرداخت</span><span class="ltr">${escapeHtml(money(props.payable))}</span></div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
|
|
win.document.open();
|
|
win.document.write(html);
|
|
win.document.close();
|
|
win.focus();
|
|
setTimeout(() => {
|
|
win.print();
|
|
win.close();
|
|
}, 250);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<a-modal
|
|
:open="open"
|
|
title="پیشنمایش سند"
|
|
:width="paperSize === 'A5' ? 640 : 840"
|
|
destroy-on-close
|
|
centered
|
|
:body-style="{ paddingTop: '12px' }"
|
|
@cancel="close"
|
|
>
|
|
<template #footer>
|
|
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" style="width: 100%">
|
|
<a-segmented v-model:value="paperSize" :options="paperOptions" />
|
|
<a-space :size="8">
|
|
<a-button @click="close">بستن</a-button>
|
|
<a-button type="primary" @click="printSheet">
|
|
<template #icon><PrinterOutlined /></template>
|
|
چاپ {{ paperSize }}
|
|
</a-button>
|
|
</a-space>
|
|
</a-flex>
|
|
</template>
|
|
|
|
<a-flex justify="center" :style="{ background: '#f3f4f6', padding: '16px', borderRadius: '8px' }">
|
|
<div
|
|
:style="{
|
|
width: '100%',
|
|
maxWidth: `${paperWidth}px`,
|
|
background: '#fff',
|
|
border: '1px solid #e5e7eb',
|
|
borderRadius: '4px',
|
|
padding: paperSize === 'A5' ? '18px 20px' : '24px 28px',
|
|
fontSize: paperSize === 'A5' ? '12px' : '13px',
|
|
color: '#111827',
|
|
}"
|
|
>
|
|
<a-flex justify="space-between" align="flex-start" :gap="12" :style="{ marginBottom: '14px' }">
|
|
<div>
|
|
<div :style="{ fontWeight: 800, fontSize: paperSize === 'A5' ? '15px' : '18px' }">
|
|
{{ orgName || brand.nameFa }}
|
|
</div>
|
|
<a-typography-text type="secondary">{{ docTypeLabel }}</a-typography-text>
|
|
</div>
|
|
<div :style="{ textAlign: 'left' }">
|
|
<div :style="{ color: accent, fontWeight: 800 }">{{ docTypeLabel }}</div>
|
|
<div dir="ltr" :style="{ fontWeight: 700, marginTop: '4px' }">{{ displayNumber }}</div>
|
|
</div>
|
|
</a-flex>
|
|
|
|
<div
|
|
:style="{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
|
|
gap: '8px 12px',
|
|
padding: '10px 0',
|
|
borderTop: '1px solid #e5e7eb',
|
|
borderBottom: '1px solid #e5e7eb',
|
|
marginBottom: '14px',
|
|
}"
|
|
>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">صادر برای</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ customerName || '—' }}</div>
|
|
<a-typography-text v-if="customerSub" type="secondary" :style="{ fontSize: '11px' }">
|
|
{{ customerSub }}
|
|
</a-typography-text>
|
|
</div>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">عنوان</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ title || docTypeLabel }}</div>
|
|
</div>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">وضعیت</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ statusLabel }}</div>
|
|
</div>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">تاریخ صدور</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ faDate(issuedAt) }}</div>
|
|
</div>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">{{ dueOrValidLabel }}</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ faDate(dueOrValid) }}</div>
|
|
</div>
|
|
<div>
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">ارز</a-typography-text>
|
|
<div :style="{ fontWeight: 600 }">{{ currencySuffix(currency) }} ({{ currency }})</div>
|
|
</div>
|
|
</div>
|
|
|
|
<table :style="{ width: '100%', borderCollapse: 'collapse' }">
|
|
<thead>
|
|
<tr :style="{ background: '#f9fafb' }">
|
|
<th :style="thStyle">#</th>
|
|
<th :style="thStyle">شرح</th>
|
|
<th :style="thStyle">کد</th>
|
|
<th :style="thStyle">تعداد</th>
|
|
<th :style="thStyle">فی</th>
|
|
<th :style="thStyle">مبلغ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="!lines.length">
|
|
<td colspan="6" :style="{ textAlign: 'center', color: '#9ca3af', padding: '16px' }">
|
|
ردیفی در فرم ثبت نشده است
|
|
</td>
|
|
</tr>
|
|
<tr v-for="(line, index) in lines" :key="`${line.name}-${index}`">
|
|
<td :style="tdStyle">{{ toFaDigits(index + 1) }}</td>
|
|
<td :style="tdStyle">
|
|
<div :style="{ fontWeight: 600 }">{{ line.name }}</div>
|
|
<div v-if="line.description" :style="{ color: '#6b7280', fontSize: '11px' }">
|
|
{{ line.description }}
|
|
</div>
|
|
</td>
|
|
<td :style="tdStyle" dir="ltr">{{ line.sku || '—' }}</td>
|
|
<td :style="tdStyle">{{ toFaDigits(line.quantity) }}</td>
|
|
<td :style="tdStyle" dir="ltr">{{ money(line.unitPrice) }}</td>
|
|
<td :style="tdStyle" dir="ltr">{{ money(line.total) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<a-flex justify="space-between" align="flex-start" :gap="16" :style="{ marginTop: '14px' }">
|
|
<div :style="{ flex: 1, minWidth: 0 }">
|
|
<template v-if="notes?.trim()">
|
|
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">یادداشت</a-typography-text>
|
|
<div>{{ notes }}</div>
|
|
</template>
|
|
</div>
|
|
<div
|
|
:style="{
|
|
width: paperSize === 'A5' ? '200px' : '240px',
|
|
border: '1px solid #e5e7eb',
|
|
borderRadius: '8px',
|
|
padding: '10px 12px',
|
|
flexShrink: 0,
|
|
}"
|
|
>
|
|
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
|
|
<a-typography-text type="secondary">جمع</a-typography-text>
|
|
<span dir="ltr">{{ money(subtotal) }}</span>
|
|
</a-flex>
|
|
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
|
|
<a-typography-text type="secondary">تخفیف</a-typography-text>
|
|
<span dir="ltr">{{ money(discount) }}</span>
|
|
</a-flex>
|
|
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
|
|
<a-typography-text type="secondary">مالیات</a-typography-text>
|
|
<span dir="ltr">{{ money(taxAmount) }}</span>
|
|
</a-flex>
|
|
<a-flex
|
|
justify="space-between"
|
|
:style="{
|
|
marginTop: '8px',
|
|
paddingTop: '8px',
|
|
borderTop: '1px solid #e5e7eb',
|
|
fontWeight: 800,
|
|
color: accent,
|
|
}"
|
|
>
|
|
<span>قابل پرداخت</span>
|
|
<span dir="ltr">{{ money(payable) }}</span>
|
|
</a-flex>
|
|
</div>
|
|
</a-flex>
|
|
</div>
|
|
</a-flex>
|
|
</a-modal>
|
|
</template>
|