feat(tahesab): PDF invoice export for asnad cards

Add per-document PDF print from account details, plus org invoice settings for logo and texts separate from CRM invoices.
This commit is contained in:
2026-10-24 12:34:02 +03:30
parent 7d9fc8447f
commit f50246dc46
11 changed files with 871 additions and 11 deletions
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "TahesabInvoiceSettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"logoDataUrl" TEXT,
"companyName" TEXT NOT NULL DEFAULT '',
"companySubtitle" TEXT NOT NULL DEFAULT '',
"address" TEXT NOT NULL DEFAULT '',
"phone" TEXT NOT NULL DEFAULT '',
"headerText" TEXT NOT NULL DEFAULT '',
"footerText" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TahesabInvoiceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TahesabInvoiceSettings_organizationId_key" ON "TahesabInvoiceSettings"("organizationId");
-- AddForeignKey
ALTER TABLE "TahesabInvoiceSettings" ADD CONSTRAINT "TahesabInvoiceSettings_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+18
View File
@@ -197,6 +197,7 @@ model Organization {
accountingConnections AccountingConnection[]
accountingSyncJobs AccountingSyncJob[]
accountingAsnadLines AccountingAsnadLine[]
tahesabInvoiceSettings TahesabInvoiceSettings?
warehouses Warehouse[]
stockMovements StockMovement[]
partyOpenings PartyOpeningBalance[]
@@ -1910,6 +1911,23 @@ model AccountingSyncJob {
@@index([organizationId, createdAt])
}
/// تنظیمات ظاهر فاکتور PDF اسناد ته‌حساب (جدا از فاکتور CRM)
model TahesabInvoiceSettings {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
/// data URL تصویر لوگو (مثلاً data:image/png;base64,...)
logoDataUrl String? @db.Text
companyName String @default("")
companySubtitle String @default("")
address String @default("")
phone String @default("")
headerText String @default("")
footerText String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// انبار داخلی — وقتی ته‌حساب وصل نیست
model Warehouse {
id String @id @default(cuid())
+36
View File
@@ -449,6 +449,42 @@ export class SyncAsnadReportDto {
to!: string;
}
export class UpdateTahesabInvoiceSettingsDto {
/** null = حذف لوگو */
@IsOptional()
logoDataUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsString()
@MaxLength(200)
companySubtitle?: string;
@IsOptional()
@IsString()
@MaxLength(500)
address?: string;
@IsOptional()
@IsString()
@MaxLength(80)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
headerText?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
footerText?: string;
}
// silence unused import warnings for ValidateNested/Type if unused
void ValidateNested;
void Type;
@@ -0,0 +1,111 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type {
TahesabInvoiceSettingsDto,
UpdateTahesabInvoiceSettingsRequest,
} from '@mento/shared';
import { PrismaService } from '../../prisma/prisma.service';
const MAX_LOGO_CHARS = 700_000; // ~500KB data URL
function toDto(row: {
id: string;
organizationId: string;
logoDataUrl: string | null;
companyName: string;
companySubtitle: string;
address: string;
phone: string;
headerText: string;
footerText: string;
updatedAt: Date;
}): TahesabInvoiceSettingsDto {
return {
id: row.id,
organizationId: row.organizationId,
logoDataUrl: row.logoDataUrl,
companyName: row.companyName,
companySubtitle: row.companySubtitle,
address: row.address,
phone: row.phone,
headerText: row.headerText,
footerText: row.footerText,
updatedAt: row.updatedAt.toISOString(),
};
}
@Injectable()
export class TahesabInvoiceSettingsService {
constructor(private readonly prisma: PrismaService) {}
async getOrCreate(organizationId: string): Promise<TahesabInvoiceSettingsDto> {
const existing = await this.prisma.tahesabInvoiceSettings.findUnique({
where: { organizationId },
});
if (existing) return toDto(existing);
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true },
});
const created = await this.prisma.tahesabInvoiceSettings.create({
data: {
organizationId,
companyName: org?.name ?? '',
},
});
return toDto(created);
}
async update(
organizationId: string,
dto: UpdateTahesabInvoiceSettingsRequest,
): Promise<TahesabInvoiceSettingsDto> {
if (dto.logoDataUrl != null && dto.logoDataUrl.length > MAX_LOGO_CHARS) {
throw new BadRequestException(
'حجم لوگو زیاد است؛ تصویر کوچک‌تری انتخاب کنید.',
);
}
if (
dto.logoDataUrl != null &&
dto.logoDataUrl !== '' &&
!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dto.logoDataUrl)
) {
throw new BadRequestException('لوگو باید تصویر با فرمت data URL باشد.');
}
const data = {
...(dto.logoDataUrl !== undefined
? { logoDataUrl: dto.logoDataUrl?.trim() || null }
: {}),
...(dto.companyName !== undefined
? { companyName: dto.companyName.trim().slice(0, 200) }
: {}),
...(dto.companySubtitle !== undefined
? { companySubtitle: dto.companySubtitle.trim().slice(0, 200) }
: {}),
...(dto.address !== undefined
? { address: dto.address.trim().slice(0, 500) }
: {}),
...(dto.phone !== undefined
? { phone: dto.phone.trim().slice(0, 80) }
: {}),
...(dto.headerText !== undefined
? { headerText: dto.headerText.trim().slice(0, 1000) }
: {}),
...(dto.footerText !== undefined
? { footerText: dto.footerText.trim().slice(0, 1000) }
: {}),
};
const row = await this.prisma.tahesabInvoiceSettings.upsert({
where: { organizationId },
create: {
organizationId,
companyName: '',
...data,
},
update: data,
});
return toDto(row);
}
}
@@ -22,6 +22,7 @@ import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import {
CreateInTahesabDto,
CreateTahesabConnectionDto,
@@ -38,6 +39,7 @@ import {
SyncAsnadReportDto,
SyncGroupsPageDto,
UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto,
} from './dto';
@Controller('integrations/tahesab')
@@ -51,6 +53,7 @@ export class TahesabController {
private readonly balance: TahesabBalanceService,
private readonly documents: TahesabDocumentsService,
private readonly asnadReport: TahesabAsnadReportService,
private readonly invoiceSettings: TahesabInvoiceSettingsService,
) {}
// —— Connections ——
@@ -279,6 +282,21 @@ export class TahesabController {
return this.products.importBulk(user.organizationId, dto);
}
// —— تنظیمات فاکتور PDF اسناد ته‌حساب ——
@Get('invoice-settings')
getInvoiceSettings(@CurrentUser() user: { organizationId: string }) {
return this.invoiceSettings.getOrCreate(user.organizationId);
}
@Patch('invoice-settings')
@Roles('owner', 'admin')
updateInvoiceSettings(
@CurrentUser() user: { organizationId: string },
@Body() dto: UpdateTahesabInvoiceSettingsDto,
) {
return this.invoiceSettings.update(user.organizationId, dto);
}
// —— گزارش فروش اسناد (اسنپ‌شات DoListAsnad) ——
@Post('reports/asnad/sync')
@Roles('owner', 'admin', 'sales')
@@ -11,6 +11,7 @@ import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
import { TahesabAsnadReportService } from './asnad-report.service';
import { TahesabInvoiceSettingsService } from './invoice-settings.service';
import { TahesabController } from './tahesab.controller';
import { CustomerGroupsModule } from '../../customer-groups/customer-groups.module';
@@ -27,6 +28,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabBalanceService,
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
],
exports: [
TahesabCryptoService,
@@ -36,6 +38,7 @@ import { CustomerGroupsModule } from '../../customer-groups/customer-groups.modu
TahesabBalanceService,
TahesabDocumentsService,
TahesabAsnadReportService,
TahesabInvoiceSettingsService,
],
})
export class TahesabModule {}
@@ -15,12 +15,17 @@ import type {
PersonAccountingLinkDto,
PersonAssetsDto,
TahesabConnectionDto,
TahesabInvoiceSettingsDto,
TahesabSanadType,
} from '@mento/shared';
import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab';
import { printTahesabInvoicePdf } from '@/lib/tahesab-invoice-pdf';
import TahesabInvoiceSettingsModal from '@/components/person-assets/TahesabInvoiceSettingsModal.vue';
const props = defineProps<{
personId: string;
/** نام نمایشی مشتری برای فاکتور PDF */
personName?: string;
}>();
const emit = defineEmits<{
@@ -37,6 +42,8 @@ const assets = ref<PersonAssetsDto | null>(null);
const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب');
const asnadLoading = ref(false);
const asnadItems = ref<Record<string, unknown>[]>([]);
const invoiceSettingsOpen = ref(false);
const invoiceSettings = ref<TahesabInvoiceSettingsDto | null>(null);
/** مدل JalaliDateInput = تاریخ محلی میلادی YYYY-MM-DD */
const fromDate = ref('');
const toDate = ref('');
@@ -383,6 +390,38 @@ async function refreshAssets() {
}
}
async function loadInvoiceSettings() {
try {
const { data } = await api.get<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
);
invoiceSettings.value = data;
} catch {
invoiceSettings.value = null;
}
}
function onInvoiceSettingsSaved(dto: TahesabInvoiceSettingsDto) {
invoiceSettings.value = dto;
}
async function exportAsnadPdf(g: {
no: string;
date: string;
rows: Record<string, unknown>[];
}) {
error.value = '';
if (!invoiceSettings.value) {
await loadInvoiceSettings();
}
printTahesabInvoicePdf({
group: g,
settings: invoiceSettings.value,
customerName: props.personName?.trim() || 'مشتری',
moshtariCode: currentLink.value?.moshtariCode ?? null,
});
}
async function loadAsnad() {
if (!connectionId.value || !currentLink.value) return;
const from = toShamsiYmd(fromDate.value);
@@ -394,22 +433,26 @@ async function loadAsnad() {
asnadLoading.value = true;
error.value = '';
try {
const { data } = await api.get<{ items: Record<string, unknown>[] }>(
`/integrations/tahesab/people/${props.personId}/asnad`,
{
params: {
connectionId: connectionId.value,
from,
to,
const [{ data }] = await Promise.all([
api.get<{ items: Record<string, unknown>[] }>(
`/integrations/tahesab/people/${props.personId}/asnad`,
{
params: {
connectionId: connectionId.value,
from,
to,
},
timeout: 60_000,
},
timeout: 60_000,
},
);
),
loadInvoiceSettings(),
]);
asnadItems.value = data.items ?? [];
ok.value = asnadItems.value.length
? `${asnadItems.value.length} سند نمایش داده شد.`
: 'در این بازه سندی یافت نشد.';
} catch (e) {
asnadItems.value = [];
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
@@ -715,6 +758,9 @@ onMounted(async () => {
<button type="button" class="btn" :disabled="asnadLoading" @click="loadAsnad">
{{ asnadLoading ? '…' : 'نمایش اسناد' }}
</button>
<button type="button" class="btn ghost" @click="invoiceSettingsOpen = true">
تنظیمات فاکتور
</button>
</div>
<div v-if="asnadItems.length" class="view-bar">
<span class="muted">
@@ -748,6 +794,14 @@ onMounted(async () => {
<strong>سند شماره {{ showRaw(g.no) }}</strong>
<span v-if="g.rows[0]?.isSanadMovaghat === true" class="inv-badge">موقت</span>
<span class="sg-count">{{ g.rows.length.toLocaleString('fa-IR') }} ردیف</span>
<button
type="button"
class="btn inv-pdf-btn"
title="خروجی فاکتور PDF"
@click="exportAsnadPdf(g)"
>
خروجی فاکتور PDF
</button>
</div>
<dl class="inv-meta">
<div>
@@ -954,6 +1008,11 @@ onMounted(async () => {
</form>
</template>
</template>
<TahesabInvoiceSettingsModal
v-model:open="invoiceSettingsOpen"
@saved="onInvoiceSettingsSaved"
/>
</div>
</template>
@@ -1195,9 +1254,15 @@ onMounted(async () => {
.inv-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.95rem;
}
.inv-pdf-btn {
margin-inline-start: auto;
font-size: 0.8rem;
padding: 0.3rem 0.65rem;
}
.inv-badge {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { useEscClose } from '@/lib/modal-stack';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{
'update:open': [value: boolean];
saved: [value: TahesabInvoiceSettingsDto];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const logoDataUrl = ref<string | null>(null);
const companyName = ref('');
const companySubtitle = ref('');
const address = ref('');
const phone = ref('');
const headerText = ref('');
const footerText = ref('');
useEscClose(
() => props.open,
() => close(),
);
function close() {
emit('update:open', false);
}
function fill(dto: TahesabInvoiceSettingsDto) {
logoDataUrl.value = dto.logoDataUrl;
companyName.value = dto.companyName ?? '';
companySubtitle.value = dto.companySubtitle ?? '';
address.value = dto.address ?? '';
phone.value = dto.phone ?? '';
headerText.value = dto.headerText ?? '';
footerText.value = dto.footerText ?? '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
);
fill(data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تنظیمات ناموفق بود.',
)
: 'بارگذاری تنظیمات ناموفق بود.';
} finally {
loading.value = false;
}
}
function onLogoFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
error.value = 'فقط فایل تصویر مجاز است.';
return;
}
if (file.size > 400_000) {
error.value = 'حجم تصویر حداکثر حدود ۴۰۰ کیلوبایت باشد.';
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
if (!result.startsWith('data:image/')) {
error.value = 'خواندن تصویر ناموفق بود.';
return;
}
logoDataUrl.value = result;
error.value = '';
};
reader.readAsDataURL(file);
}
function clearLogo() {
logoDataUrl.value = null;
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const { data } = await api.patch<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
{
logoDataUrl: logoDataUrl.value,
companyName: companyName.value,
companySubtitle: companySubtitle.value,
address: address.value,
phone: phone.value,
headerText: headerText.value,
footerText: footerText.value,
},
);
fill(data);
ok.value = 'تنظیمات فاکتور ذخیره شد.';
emit('saved', data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تنظیمات ناموفق بود.',
)
: 'ذخیره تنظیمات ناموفق بود.';
} finally {
saving.value = false;
}
}
watch(
() => props.open,
(open) => {
if (open) {
ok.value = '';
error.value = '';
void load();
}
},
);
onMounted(() => {
if (props.open) void load();
});
</script>
<template>
<div v-if="open" 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">لوگو و متن‌های ثابت خروجی PDF اسناد — جدا از فاکتور CRM</p>
</div>
<button type="button" class="btn ghost" @click="close">بستن</button>
</header>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="grid">
<label class="field logo-field">
<span>لوگو</span>
<div class="logo-row">
<img v-if="logoDataUrl" :src="logoDataUrl" alt="لوگو" class="logo-preview" />
<div class="logo-actions">
<input type="file" accept="image/*" @change="onLogoFile" />
<button v-if="logoDataUrl" type="button" class="btn ghost" @click="clearLogo">
حذف لوگو
</button>
</div>
</div>
</label>
<label class="field">
<span>نام شرکت / فروشگاه</span>
<input v-model="companyName" type="text" maxlength="200" />
</label>
<label class="field">
<span>زیرعنوان</span>
<input v-model="companySubtitle" type="text" maxlength="200" />
</label>
<label class="field">
<span>آدرس</span>
<input v-model="address" type="text" maxlength="500" />
</label>
<label class="field">
<span>تلفن</span>
<input v-model="phone" type="text" maxlength="80" dir="ltr" />
</label>
<label class="field full">
<span>متن بالای فاکتور</span>
<textarea v-model="headerText" rows="2" maxlength="1000" />
</label>
<label class="field full">
<span>متن پایین فاکتور</span>
<textarea v-model="footerText" rows="2" maxlength="1000" />
</label>
</div>
<footer class="sheet-foot">
<button type="button" class="btn" :disabled="saving || loading" @click="save">
{{ saving ? '…' : 'ذخیره تنظیمات' }}
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 16px;
}
.sheet {
width: min(640px, 100%);
max-height: min(90vh, 820px);
overflow: auto;
background: var(--surface, #fff);
border-radius: 12px;
border: 1px solid var(--line, #e5e7eb);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sheet-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.sheet-head h3 {
margin: 0;
font-size: 1.05rem;
}
.muted {
margin: 4px 0 0;
color: var(--ink-secondary, #6b7280);
font-size: 0.85rem;
}
.err {
color: #b91c1c;
margin: 0;
}
.ok {
color: #047857;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
}
.field.full,
.logo-field {
grid-column: 1 / -1;
}
.field input,
.field textarea {
border: 1px solid var(--line, #e5e7eb);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
background: var(--surface, #fff);
}
.logo-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.logo-preview {
max-height: 64px;
max-width: 140px;
object-fit: contain;
border: 1px solid var(--line, #e5e7eb);
border-radius: 8px;
padding: 4px;
background: #fff;
}
.logo-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.sheet-foot {
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+254
View File
@@ -0,0 +1,254 @@
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
export type TahesabInvoiceGroup = {
no: string;
date: string;
rows: Record<string, unknown>[];
};
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function dash(v: unknown, fallback = '—'): string {
if (v == null) return fallback;
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return fallback;
return s;
}
function fmtNum(v: unknown): string {
const s = dash(v, '');
if (!s) return '—';
const n = Number(String(s).replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
return toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n),
);
}
function metalLabel(v: unknown): string {
const s = dash(v, '');
if (s === '0') return 'طلا';
if (s === '1') return 'پلاتین';
if (s === '2') return 'نقره';
return s || '—';
}
function rowDesc(r: Record<string, unknown>): string {
const parts = [dash(r.NO, ''), dash(r.No_Kar, ''), dash(r.Sharh1, '')].filter(
(p) => p && p !== '—',
);
return parts.join(' · ') || '—';
}
function sumMali(rows: Record<string, unknown>[]): number {
let t = 0;
for (const r of rows) {
const n = Number(String(r.Mali ?? '').replace(/,/g, ''));
if (Number.isFinite(n)) t += n;
}
return t;
}
/** خروجی فاکتور سند ته‌حساب — چاپ / ذخیره PDF از دیالوگ مرورگر */
export function printTahesabInvoicePdf(opts: {
group: TahesabInvoiceGroup;
settings: TahesabInvoiceSettingsDto | null;
customerName: string;
moshtariCode?: number | null;
}) {
const { group, settings, customerName, moshtariCode } = opts;
const first = group.rows[0] ?? {};
const time = dash(first.Tarikh_Time ?? first.ZamanSabt, '');
const user = dash(first.User, '');
const temporary = first.isSanadMovaghat === true;
const total = sumMali(group.rows);
const rowsHtml = group.rows
.map((r, i) => {
const idx = dash(r.Radif, String(i + 1));
return `<tr>
<td>${toFaDigits(idx)}</td>
<td>${escapeHtml(rowDesc(r))}</td>
<td>${escapeHtml(metalLabel(r.Jens_Felez))}</td>
<td class="ltr">${fmtNum(r.Count)}</td>
<td class="ltr">${fmtNum(r.Vazn)}</td>
<td class="ltr">${fmtNum(r.Mazaneh ?? r.Fi)}</td>
<td class="ltr">${fmtNum(r.Mali)}</td>
<td>${escapeHtml(dash(r.Arz, 'IRR'))}</td>
</tr>`;
})
.join('');
const logo = settings?.logoDataUrl
? `<img class="logo" src="${escapeHtml(settings.logoDataUrl)}" alt="" />`
: '';
const company = escapeHtml(settings?.companyName?.trim() || 'فاکتور ته‌حساب');
const subtitle = settings?.companySubtitle?.trim()
? `<div class="sub">${escapeHtml(settings.companySubtitle.trim())}</div>`
: '';
const address = settings?.address?.trim()
? `<div class="muted">${escapeHtml(settings.address.trim())}</div>`
: '';
const phone = settings?.phone?.trim()
? `<div class="muted">${escapeHtml(settings.phone.trim())}</div>`
: '';
const headerText = settings?.headerText?.trim()
? `<p class="note">${escapeHtml(settings.headerText.trim())}</p>`
: '';
const footerText = settings?.footerText?.trim()
? `<p class="note foot-note">${escapeHtml(settings.footerText.trim())}</p>`
: '';
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(group.no)}</title>
<style>
@page { size: A4; margin: 12mm; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Tahoma, "Segoe UI", sans-serif;
color: #111827;
font-size: 12px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.top {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 14px;
border-bottom: 2px solid #111827;
padding-bottom: 12px;
}
.brand { display: flex; gap: 12px; align-items: center; }
.logo { max-height: 64px; max-width: 140px; object-fit: contain; }
.org { font-size: 18px; font-weight: 800; margin: 0; }
.sub { color: #374151; margin-top: 2px; }
.muted { color: #6b7280; margin-top: 2px; }
.doc { text-align: left; }
.doc-type { font-weight: 800; font-size: 15px; }
.badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border: 1px solid #d97706;
color: #b45309;
border-radius: 999px;
font-size: 11px;
}
.meta {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px 16px;
margin-bottom: 14px;
}
.lbl { color: #6b7280; font-size: 11px; display: block; margin-bottom: 2px; }
.val { font-weight: 600; }
.note {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
margin: 0 0 12px;
white-space: pre-wrap;
}
table { width: 100%; border-collapse: collapse; }
th, td {
padding: 7px 8px;
border: 1px solid #e5e7eb;
text-align: right;
vertical-align: top;
}
th { background: #f3f4f6; font-size: 11px; }
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
.totals {
margin-top: 12px;
display: flex;
justify-content: flex-start;
}
.totals-box {
min-width: 220px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
}
.t-row { display: flex; justify-content: space-between; gap: 24px; font-weight: 800; }
.foot-note { margin-top: 16px; }
@media print {
body { font-size: 11px; }
}
</style>
</head>
<body>
<div class="top">
<div class="brand">
${logo}
<div>
<p class="org">${company}</p>
${subtitle}${address}${phone}
</div>
</div>
<div class="doc">
<div class="doc-type">فاکتور سند ته‌حساب</div>
<div>شماره: <strong>${toFaDigits(escapeHtml(group.no))}</strong></div>
${temporary ? '<span class="badge">سند موقت</span>' : ''}
</div>
</div>
${headerText}
<div class="meta">
<div><span class="lbl">مشتری</span><div class="val">${escapeHtml(customerName || '—')}</div></div>
<div><span class="lbl">کد مشتری</span><div class="val ltr">${
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—'
}</div></div>
<div><span class="lbl">تاریخ</span><div class="val">${toFaDigits(escapeHtml(group.date))}${
time ? ` · ${toFaDigits(escapeHtml(time))}` : ''
}</div></div>
<div><span class="lbl">کاربر ثبت</span><div class="val">${escapeHtml(user)}</div></div>
<div><span class="lbl">تعداد ردیف</span><div class="val">${toFaDigits(String(group.rows.length))}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>شرح</th>
<th>جنس فلز</th>
<th>تعداد</th>
<th>وزن</th>
<th>مظنه/فی</th>
<th>مالی</th>
<th>ارز</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
<div class="totals">
<div class="totals-box">
<div class="t-row"><span>جمع مالی</span><span class="ltr">${fmtNum(total)}</span></div>
</div>
</div>
${footerText}
</body>
</html>`;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => {
win.print();
}, 300);
}
+5 -1
View File
@@ -1043,7 +1043,11 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
<PersonAssetsPanel
:person-id="id"
:person-name="fullName"
@link-changed="onTahesabLinkChanged"
/>
</div>
<div v-if="canBehavior && tab === 'disc'" class="panel">
+24
View File
@@ -4165,6 +4165,30 @@ export interface TahesabAsnadSummaryDto {
lastSyncedAt: string | null;
}
/** تنظیمات ظاهر فاکتور PDF اسناد ته‌حساب (جدا از فاکتور CRM) */
export interface TahesabInvoiceSettingsDto {
id: string;
organizationId: string;
logoDataUrl: string | null;
companyName: string;
companySubtitle: string;
address: string;
phone: string;
headerText: string;
footerText: string;
updatedAt: string;
}
export interface UpdateTahesabInvoiceSettingsRequest {
logoDataUrl?: string | null;
companyName?: string;
companySubtitle?: string;
address?: string;
phone?: string;
headerText?: string;
footerText?: string;
}
export interface MandehValueDto {
amount: string | number;
unit: string;