Files
CRM_Montzar/apps/web/src/components/InvoiceDocPreview.vue
T
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
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>
2026-10-18 19:39:59 +03:30

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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>