Files
CRM_Montzar/apps/web/src/components/person-assets/TahesabInvoicePreviewModal.vue
T
MahdiandCursor 2d690b4c04 feat(tahesab): polish asnad invoice PDF and prior balance
Compute حساب قبل from asnad before this doc date/number, add in-app preview layout, and leave a blank mesghal line for handwriting after print.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-24 21:47:46 +03:30

706 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue';
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { useEscClose } from '@/lib/modal-stack';
import { formatTahesabBedBes } from '@/lib/tahesab';
import {
aggregateSameNameInvoiceRows,
dashInvoice,
fmtInvoiceNum,
pickInvoiceDarsad,
pickInvoiceMazaneh,
printTahesabInvoicePdf,
rowInvoiceDesc,
formatInvoiceWeightBalance,
sumInvoiceMali,
tabdilVaznColumnLabel,
type TahesabInvoiceGroup,
} from '@/lib/tahesab-invoice-pdf';
const props = defineProps<{
open: boolean;
group: TahesabInvoiceGroup | null;
settings: TahesabInvoiceSettingsDto | null;
customerName: string;
customerPhone?: string;
moshtariCode?: number | null;
priorWeight?: number | null;
priorRial?: number | null;
finalWeight?: number | null;
finalRial?: number | null;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
useEscClose(
() => props.open,
() => close(),
);
function close() {
emit('update:open', false);
}
const first = computed(() => props.group?.rows[0] ?? {});
const temporary = computed(() => first.value.isSanadMovaghat === true);
const issuerName = computed(
() => dashInvoice(first.value.User, '') || '—',
);
const invoiceRows = computed(() =>
props.group ? aggregateSameNameInvoiceRows(props.group.rows) : [],
);
const total = computed(() => sumInvoiceMali(invoiceRows.value));
const sourceRows = computed(
() => props.group?.rows ?? invoiceRows.value,
);
const totalVaznLabel = computed(() =>
formatInvoiceWeightBalance(sourceRows.value, 'Vazn', false),
);
const totalTabdilLabel = computed(() =>
formatInvoiceWeightBalance(sourceRows.value, 'TabdilVazn', true),
);
const tabdilColLabel = computed(() =>
tabdilVaznColumnLabel(sourceRows.value),
);
const company = computed(
() => props.settings?.companyName?.trim() || 'فاکتور ته‌حساب',
);
const generatedAtLabel = computed(() =>
props.open ? formatJalaliDateTime(new Date()) : '',
);
function printPdf() {
if (!props.group) return;
const ok = printTahesabInvoicePdf({
group: props.group,
settings: props.settings,
customerName: props.customerName,
customerPhone: props.customerPhone,
moshtariCode: props.moshtariCode,
priorWeight: props.priorWeight,
priorRial: props.priorRial,
finalWeight: props.finalWeight,
finalRial: props.finalRial,
});
if (!ok) {
// در مرورگر Cursor پاپ‌آپ کار نمی‌کند؛ پیش‌نمایش همین‌جا کافی است
window.alert(
'چاپ در این مرورگر باز نشد. از Chrome استفاده کنید یا از منوی چاپ سیستم روی همین پیش‌نمایش استفاده کنید.',
);
}
}
</script>
<template>
<div v-if="open && group" class="backdrop" @click.self="close">
<div
class="sheet"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش فاکتور ته‌حساب"
>
<header class="sheet-head">
<div>
<h3>پیش‌نمایش فاکتور ته‌حساب</h3>
<p class="muted">
سند {{ toFaDigits(group.no) }} · همین‌جا قابل بررسی است (بدون پاپ‌آپ)
</p>
</div>
<div class="head-actions">
<button type="button" class="btn" @click="printPdf">چاپ / PDF</button>
<button type="button" class="btn ghost" @click="close">بستن</button>
</div>
</header>
<div class="paper">
<div class="top">
<div class="top-side top-start">
<p v-if="settings?.headerText?.trim()" class="header-text">
{{ settings.headerText }}
</p>
<p class="org">{{ company }}</p>
<div v-if="settings?.phone?.trim()" class="dim">
تلفن: <bdi dir="ltr">{{ settings.phone }}</bdi>
</div>
</div>
<div class="top-center">
<img
v-if="settings?.logoDataUrl"
class="logo"
:src="settings.logoDataUrl"
alt=""
/>
</div>
<div class="top-side top-end">
<div class="doc-date">
تاریخ: {{ toFaDigits(group.date) }}
</div>
<div>
شماره سند:
<strong>{{ toFaDigits(group.no) }}</strong>
</div>
<div
v-if="settings?.companySubtitle?.trim()"
class="sub"
dir="ltr"
>
{{ settings.companySubtitle }}
</div>
<span v-if="temporary" class="badge">سند موقت</span>
</div>
</div>
<div class="party">
<span class="party-item">
حساب:
<strong>{{ customerName || '—' }}</strong>
</span>
<span class="party-item">
کد:
<strong class="ltr">{{
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—'
}}</strong>
</span>
<span class="party-item party-phone">
موبایل:
<strong class="ltr">{{
customerPhone?.trim()
? toFaDigits(customerPhone.trim())
: '—'
}}</strong>
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-radif">ردیف</th>
<th class="col-desc">شرح</th>
<th class="col-darsad">درصد</th>
<th>عیار</th>
<th>وزن</th>
<th class="col-tabdil">{{ tabdilColLabel }}</th>
<th>مظنه/فی</th>
<th class="col-mali">مبلغ (ریال)</th>
</tr>
</thead>
<tbody>
<tr
v-for="(r, i) in invoiceRows"
:key="String(r.ID ?? r.No_Kar ?? i)"
>
<td class="col-radif">
{{ toFaDigits(dashInvoice(r.Radif, String(i + 1))) }}
</td>
<td class="col-desc">{{ rowInvoiceDesc(r) }}</td>
<td class="ltr col-darsad">
{{ fmtInvoiceNum(pickInvoiceDarsad(r)) }}
</td>
<td class="ltr">{{ fmtInvoiceNum(r.Ayar) }}</td>
<td class="ltr">{{ fmtInvoiceNum(r.Vazn) }}</td>
<td class="ltr col-tabdil">
{{ formatTahesabBedBes(r.TabdilVazn) }}
</td>
<td class="ltr">{{ fmtInvoiceNum(pickInvoiceMazaneh(r)) }}</td>
<td class="ltr col-mali">
{{ formatTahesabBedBes(r.Mali) }}
</td>
</tr>
</tbody>
<tfoot>
<tr class="totals-row">
<td colspan="3" class="totals-label">مجموع کل اجناس</td>
<td></td>
<td class="ltr weight-sum">{{ totalVaznLabel }}</td>
<td class="ltr weight-sum col-tabdil">{{ totalTabdilLabel }}</td>
<td></td>
<td class="ltr col-mali">{{ formatTahesabBedBes(total) }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="invoice-balance">
<div class="ib-when">
<span class="ib-k">تاریخ ایجاد:</span>
{{ generatedAtLabel }}
</div>
<div class="ib-label">مانده این فاکتور:</div>
<div class="ib-cell">
<span class="ib-k">مانده وزنی:</span>
<strong class="ib-val" dir="ltr">{{ totalTabdilLabel }}</strong>
</div>
<div class="ib-cell">
<span class="ib-k">مانده ریالی:</span>
<strong class="ib-val" dir="ltr">{{
formatTahesabBedBes(total)
}}</strong>
</div>
</div>
<div class="account-row">
<div class="mesghal-box">
<span class="ib-k">قیمت یک مثقال نقره خام:</span>
<span class="mesghal-blank" aria-hidden="true" />
</div>
<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
<span class="acc-pair">
<span class="ib-k">وزنی</span>
<strong dir="ltr">{{ formatTahesabBedBes(priorWeight) }}</strong>
</span>
<span class="acc-pair">
<span class="ib-k">ریالی</span>
<strong dir="ltr">{{ formatTahesabBedBes(priorRial) }}</strong>
</span>
</div>
<div class="acc-line">
<span class="acc-title">مانده نهایی:</span>
<span class="acc-pair">
<span class="ib-k">وزنی</span>
<strong dir="ltr">{{ formatTahesabBedBes(finalWeight) }}</strong>
</span>
<span class="acc-pair">
<span class="ib-k">ریالی</span>
<strong dir="ltr">{{ formatTahesabBedBes(finalRial) }}</strong>
</span>
</div>
</div>
</div>
<footer class="page-foot">
<p v-if="settings?.footerText?.trim()" class="note foot">
{{ settings.footerText }}
</p>
<div class="sign-row">
<div class="sign-col">
<span class="ib-k">صادر کننده سند:</span>
<strong class="sign-name">{{ issuerName }}</strong>
<div class="sign-space" aria-hidden="true"></div>
</div>
<div class="sign-addr">
{{ settings?.address?.trim() || '—' }}
</div>
<div class="sign-col">
<span class="ib-k">گیرنده سند:</span>
<strong class="sign-name">{{ customerName || '—' }}</strong>
<div class="sign-space" aria-hidden="true"></div>
</div>
</div>
</footer>
</div>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 85;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 12px;
}
.sheet {
width: min(920px, 100%);
max-height: min(92vh, 960px);
overflow: auto;
background: #f3f4f6;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sheet-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
background: #fff;
border-radius: 8px;
padding: 10px 12px;
}
.sheet-head h3 {
margin: 0;
font-size: 1rem;
}
.muted {
margin: 4px 0 0;
color: #6b7280;
font-size: 0.85rem;
}
.head-actions {
display: flex;
gap: 8px;
}
.paper {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px 22px;
color: #111827;
font-size: 13px;
}
.top {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px 16px;
align-items: center;
margin-bottom: 6px;
border: 1px solid #111827;
border-radius: 10px;
padding: 12px 14px;
}
.top-side {
min-width: 0;
}
.top-start {
text-align: right;
}
.top-end {
text-align: left;
}
.top-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.header-text {
margin: 0 0 8px;
white-space: pre-wrap;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
color: #111827;
}
.logo {
max-height: 110px;
max-width: 220px;
object-fit: contain;
}
.org {
font-size: 16px;
font-weight: 800;
margin: 0;
}
.sub {
color: #374151;
margin-top: 8px;
direction: ltr;
unicode-bidi: isolate;
text-align: left;
font-size: 13px;
}
.dim {
color: #6b7280;
margin-top: 2px;
}
.doc-date {
font-weight: 800;
font-size: 15px;
margin-bottom: 4px;
}
.badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border: 1px solid #d97706;
color: #b45309;
border-radius: 999px;
font-size: 11px;
}
.party {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-bottom: 14px;
border: 1px solid #111827;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
}
.party-item {
white-space: nowrap;
}
.party-phone {
margin-inline-start: auto;
}
.page-foot {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.note {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
margin: 0;
white-space: pre-wrap;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
align-items: start;
margin-top: 4px;
}
.sign-col {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.sign-name {
font-size: 13px;
font-weight: 800;
}
.sign-space {
min-height: 56px;
margin-top: 4px;
border-bottom: 1px dashed #d1d5db;
}
.sign-addr {
color: #6b7280;
font-size: 12px;
text-align: center;
line-height: 1.55;
max-width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding-top: 2px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 7px 8px;
border: 1px solid #e5e7eb;
text-align: right;
vertical-align: top;
}
th {
background: #f3f4f6;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.col-radif {
width: 5%;
white-space: nowrap;
padding-inline: 4px;
text-align: center;
vertical-align: middle;
}
.col-desc {
width: 32%;
word-break: break-word;
}
.col-darsad {
width: 6%;
white-space: nowrap;
padding-inline: 4px;
}
.col-tabdil {
width: 14%;
white-space: nowrap;
}
.col-mali {
width: 13%;
white-space: nowrap;
}
.ltr {
direction: ltr;
unicode-bidi: isolate;
text-align: left;
}
.totals-row td {
font-weight: 800;
background: #f9fafb;
vertical-align: middle;
}
.totals-label {
text-align: right;
}
.weight-sum {
font-size: 11px;
white-space: nowrap;
line-height: 1.35;
}
.invoice-balance {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr;
gap: 0;
margin-top: 10px;
border: 1px solid #111827;
border-radius: 10px;
overflow: hidden;
font-size: 13px;
background: #f3f4f6;
}
.invoice-balance > div {
padding: 8px 12px;
}
.invoice-balance > .ib-cell {
border-inline-start: 1px solid #e5e7eb;
}
.ib-when {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
white-space: nowrap;
}
.ib-label {
font-weight: 800;
display: flex;
align-items: center;
justify-content: left;
text-align: left;
}
.ib-cell {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-height: 1.4em;
padding-left: 7.5rem;
box-sizing: border-box;
white-space: nowrap;
}
.ib-k {
color: #6b7280;
font-size: 12px;
}
.ib-val {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
text-align: left;
unicode-bidi: isolate;
font-weight: 800;
}
.account-row {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 10px;
}
.mesghal-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-blank {
flex: 1;
min-width: 4rem;
border-bottom: 1px dotted #9ca3af;
align-self: flex-end;
margin-bottom: 0.35rem;
margin-inline-start: 0.5rem;
}
.account-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
overflow: hidden;
display: flex;
flex-direction: column;
}
.acc-line {
display: grid;
grid-template-columns: auto 1fr 1fr;
align-items: stretch;
gap: 0;
border-top: 1px solid #e5e7eb;
}
.acc-line:first-child {
border-top: 0;
}
.acc-title {
font-weight: 800;
white-space: nowrap;
padding: 6px 12px;
display: flex;
align-items: center;
}
.acc-pair {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 6px 12px;
border-inline-start: 1px solid #e5e7eb;
}
.acc-pair strong {
font-weight: 800;
unicode-bidi: isolate;
}
.btn {
border: 0;
border-radius: 8px;
padding: 0.45rem 0.8rem;
background: var(--accent, #0d9488);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: transparent;
color: #111827;
border: 1px solid #e5e7eb;
}
@media (max-width: 720px) {
.party-phone {
margin-inline-start: 0;
}
.top {
grid-template-columns: 1fr;
justify-items: stretch;
}
.top-center {
order: -1;
}
.top-end {
text-align: right;
}
.invoice-balance {
grid-template-columns: 1fr 1fr;
}
.account-row {
grid-template-columns: 1fr;
}
.sign-row {
grid-template-columns: 1fr;
}
}
</style>