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:
@@ -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;
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user