Files
doc-manager/packages/shared/src/render/toHtml.ts
T
admin 8b7fe30b76 feat: simplified document editor + optional blocks
Schema:
- Block: new optional fields `optional` (boolean) and `optionalLabel` (string)
- DocBody: new field `disabledBlockIds` — IDs of optional blocks turned off on this instance
- Renderer skips blocks whose id is in disabledBlockIds

Template authoring (BlocksEditor):
- Each block card has «Опциональный» checkbox + custom label input
- Optional blocks visually highlighted (amber border)

Document editing (DocumentEdit):
- New «Расширенный режим» toggle in header (default off → simple mode)
- Simple mode: only document header, lines, and «Дополнительные пункты»
  section listing optional blocks as checkboxes
- Advanced mode: also shows full BlocksEditor (previous behavior)
- Toggling optional block updates body.disabledBlockIds; renderer/PDF reflects it

Workflow: автор шаблона помечает спорные блоки как опциональные → пользователь
при создании документа просто отмечает галочки, не залезая в контент.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:01:57 +03:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Block, DocBody, RichText, VatRate } from '../blocks/schema.js';
import { VAT_LABEL, VAT_PERCENT } from '../blocks/schema.js';
// ========== Domain types для рендера ==========
// shared не зависит от Prisma — нам нужны простые DTO.
export type RenderOrganization = {
name: string;
inn: string;
kpp: string | null;
ogrn: string | null;
legalAddress: string | null;
bankName: string | null;
bankBik: string | null;
bankAccount: string | null;
signatoryName: string | null;
signatoryPosition: string | null;
};
export type RenderClient = {
id: string;
kind: 'ul' | 'ip' | 'fl';
name: string;
inn: string | null;
kpp: string | null;
address: string | null;
email: string | null;
phone: string | null;
};
export type RenderLine = {
id: string;
position: number;
name: string;
qtyMilli: number;
unit: string;
priceCents: number;
vat: VatRate;
sumCents: number;
};
export type RenderDocument = {
number: string;
docType: 'contract' | 'invoice' | 'act' | 'upd';
issuedAt: Date | string | null;
totalCents: number;
vatCents: number;
currency: string;
};
export type RenderContext = {
doc: RenderDocument;
organization: RenderOrganization;
client: RenderClient | null;
lines: RenderLine[];
vars: Record<string, unknown>;
};
// ========== HTML escaping ==========
const ESC: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => ESC[c]!);
}
// ========== Placeholder resolution ==========
function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce<unknown>((acc, key) => {
if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>)) {
return (acc as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
const PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;
function fillPlaceholders(text: string, ctx: RenderContext): string {
const fullCtx: Record<string, unknown> = {
today: new Date().toLocaleDateString('ru-RU'),
contract: {
number: ctx.doc.number,
date: ctx.doc.issuedAt ? new Date(ctx.doc.issuedAt).toLocaleDateString('ru-RU') : '',
},
customer: ctx.client ?? {},
executor: ctx.organization,
self: ctx.organization,
...ctx.vars,
};
return text.replace(PLACEHOLDER_RE, (_m, path: string) => {
const v = getByPath(fullCtx, path);
return v === undefined || v === null ? '' : esc(String(v));
});
}
// ========== RichText rendering ==========
// На M3 в редакторе тексты вводятся как plain string и оборачиваются в простой
// TipTap-совместимый JSON: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: '...' }] }] }
// Здесь поддерживаем минимум: рекурсивный обход, marks=bold/italic/underline, line breaks.
function renderRichText(node: RichText | undefined, ctx: RenderContext): string {
if (!node) return '';
return renderNode(node, ctx);
}
function renderNode(n: unknown, ctx: RenderContext): string {
if (!n || typeof n !== 'object') return '';
const node = n as { type?: string; content?: unknown[]; text?: string; marks?: { type: string }[] };
if (node.type === 'text' && typeof node.text === 'string') {
let out = esc(fillPlaceholders(node.text, ctx));
for (const m of node.marks ?? []) {
if (m.type === 'bold') out = `<strong>${out}</strong>`;
else if (m.type === 'italic') out = `<em>${out}</em>`;
else if (m.type === 'underline') out = `<u>${out}</u>`;
}
return out;
}
if (node.type === 'hardBreak') return '<br/>';
if (node.type === 'paragraph') return `<p>${(node.content ?? []).map((c) => renderNode(c, ctx)).join('')}</p>`;
// doc / прочие контейнеры — просто содержимое.
return (node.content ?? []).map((c) => renderNode(c, ctx)).join('');
}
// ========== Block rendering ==========
function renderHeading(level: 1 | 2 | 3, text: RichText, ctx: RenderContext): string {
return `<h${level} class="b-heading">${renderRichText(text, ctx) || ''}</h${level}>`;
}
function renderParty(role: 'executor' | 'customer', clientLike: { kind?: string } | RenderOrganization, ctx: RenderContext): string {
const isOrg = !('kind' in clientLike);
const o = clientLike as RenderOrganization;
const c = clientLike as RenderClient;
const title = role === 'executor' ? 'Исполнитель' : 'Заказчик';
const fields: [string, string | null | undefined][] = isOrg
? [
['', o.name],
['ИНН', o.inn],
['КПП', o.kpp],
['ОГРН', o.ogrn],
['Адрес', o.legalAddress],
['Банк', o.bankName],
['БИК', o.bankBik],
['Р/с', o.bankAccount],
]
: [
['', c.name],
['ИНН', c.inn],
['КПП', c.kpp],
['Адрес', c.address],
['Email', c.email],
['Телефон', c.phone],
];
const rows = fields
.filter((f) => f[1])
.map(([label, value]) =>
label
? `<tr><td class="party-k">${esc(label)}</td><td class="party-v">${esc(value!)}</td></tr>`
: `<tr><td class="party-k"></td><td class="party-v"><b>${esc(value!)}</b></td></tr>`,
)
.join('');
return `
<div class="b-party">
<div class="party-title">${title}:</div>
<table class="party-table">${rows}</table>
</div>`;
}
function renderServicesTable(blockLines: { lineId: string }[], ctx: RenderContext, columns: string[]): string {
const wantedIds = new Set(blockLines.map((bl) => bl.lineId));
const rows = ctx.lines
.filter((l) => wantedIds.has(l.id) || blockLines.length === 0)
.sort((a, b) => a.position - b.position);
const colDefs: Record<string, { th: string; th_class?: string; render: (l: RenderLine, idx: number) => string }> = {
name: { th: 'Наименование', render: (l) => esc(l.name) },
qty: { th: 'Кол-во', th_class: 'num', render: (l) => formatQty(l.qtyMilli) },
unit: { th: 'Ед.', render: (l) => esc(l.unit) },
price: { th: 'Цена', th_class: 'num', render: (l) => formatRub(l.priceCents) },
vat: { th: 'НДС', render: (l) => VAT_LABEL[l.vat] },
sum: { th: 'Сумма', th_class: 'num', render: (l) => formatRub(l.sumCents) },
};
const cols = columns.length ? columns : ['name', 'qty', 'unit', 'price', 'vat', 'sum'];
const head = `<tr>${cols.map((c) => `<th class="${colDefs[c]?.th_class ?? ''}">${esc(colDefs[c]?.th ?? c)}</th>`).join('')}</tr>`;
const body = rows
.map((l, idx) =>
`<tr>${cols
.map((c) => `<td class="${colDefs[c]?.th_class ?? ''}">${colDefs[c]?.render(l, idx) ?? ''}</td>`)
.join('')}</tr>`,
)
.join('');
return `<table class="b-services">${head}${body}</table>`;
}
function renderTotals(showVat: boolean, showInWords: boolean, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
const total = ctx.doc.totalCents;
const vat = ctx.doc.vatCents;
const rows: string[] = [];
rows.push(`<tr><td class="totals-k">Итого:</td><td class="totals-v"><b>${formatRub(total)}</b></td></tr>`);
if (showVat) {
rows.push(
`<tr><td class="totals-k">в т.ч. НДС:</td><td class="totals-v">${vat > 0 ? formatRub(vat) : 'Без НДС'}</td></tr>`,
);
}
if (showInWords && rubInWordsFn) {
rows.push(`<tr><td class="totals-k">Прописью:</td><td class="totals-v">${esc(rubInWordsFn(total))}</td></tr>`);
}
return `<table class="b-totals">${rows.join('')}</table>`;
}
function renderSignatures(sides: ('executor' | 'customer')[], ctx: RenderContext): string {
const cells = sides.map((side) => {
const title = side === 'executor' ? 'Исполнитель' : 'Заказчик';
let line2 = '';
if (side === 'executor') {
const pos = ctx.organization.signatoryPosition ?? '';
const fio = ctx.organization.signatoryName ?? '';
line2 = `${esc(pos)} ${esc(fio)}`;
} else if (ctx.client) {
line2 = esc(ctx.client.name);
}
return `
<div class="sig-cell">
<div class="sig-title">${title}</div>
<div class="sig-line">_____________________ / ${line2} /</div>
<div class="sig-stamp">М.П.</div>
</div>`;
});
return `<div class="b-signatures">${cells.join('')}</div>`;
}
function renderBlock(block: Block, ctx: RenderContext, rubInWordsFn?: (cents: number) => string): string {
switch (block.type) {
case 'heading':
return renderHeading(block.level, block.text as RichText, ctx);
case 'paragraph':
return `<p class="b-paragraph">${renderRichText(block.text as RichText, ctx)}</p>`;
case 'party': {
if (block.bind.kind === 'self') return renderParty(block.role, ctx.organization, ctx);
if (ctx.client) return renderParty(block.role, ctx.client, ctx);
return `<div class="b-party"><i>Сторона не указана</i></div>`;
}
case 'services_table':
return renderServicesTable(block.lines, ctx, block.columns);
case 'totals':
return renderTotals(block.showVat, block.showInWords, ctx, rubInWordsFn);
case 'terms':
return `<div class="b-terms">${renderRichText(block.text as RichText, ctx)}</div>`;
case 'signatures':
return renderSignatures(block.sides, ctx);
case 'custom_text':
return `<div class="b-custom">${renderRichText(block.text as RichText, ctx)}</div>`;
case 'page_break':
return `<div class="b-page-break"></div>`;
}
}
function formatRub(cents: number): string {
return (cents / 100).toLocaleString('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
});
}
function formatQty(qtyMilli: number): string {
const n = qtyMilli / 1000;
return n % 1 === 0 ? String(n) : n.toFixed(3).replace(/\.?0+$/, '');
}
export function renderDocumentHtml(
body: DocBody,
ctx: RenderContext,
opts: {
title?: string;
rubInWords?: (cents: number) => string;
} = {},
): string {
const disabled = new Set(body.disabledBlockIds ?? []);
const blocksHtml = body.blocks
.filter((b) => !disabled.has(b.id))
.map((b) => renderBlock(b, ctx, opts.rubInWords))
.join('\n');
const title = opts.title ?? `Документ ${ctx.doc.number}`;
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<title>${esc(title)}</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
* { box-sizing: border-box; }
body {
font-family: 'PT Sans', 'DejaVu Sans', system-ui, sans-serif;
font-size: 11pt; line-height: 1.4; color: #111; margin: 0;
}
h1.b-heading { font-size: 16pt; margin: 12px 0 6px; }
h2.b-heading { font-size: 13pt; margin: 10px 0 4px; }
h3.b-heading { font-size: 11.5pt; margin: 8px 0 4px; }
p.b-paragraph { margin: 4px 0 8px; text-align: justify; }
.b-party { margin: 8px 0 12px; page-break-inside: avoid; }
.party-title { font-weight: bold; margin-bottom: 2px; }
table.party-table { border-collapse: collapse; }
.party-table .party-k { padding: 1px 8px 1px 0; opacity: 0.7; vertical-align: top; white-space: nowrap; }
.party-table .party-v { padding: 1px 0; vertical-align: top; }
table.b-services {
width: 100%; border-collapse: collapse; margin: 8px 0 12px;
page-break-inside: auto;
}
table.b-services th, table.b-services td {
border: 1px solid #888; padding: 4px 8px; text-align: left;
vertical-align: top;
}
table.b-services th { background: #f4f4f4; font-weight: 600; }
table.b-services .num { text-align: right; }
table.b-totals { margin-left: auto; margin-right: 0; margin-top: 8px; }
.totals-k { padding: 2px 12px 2px 0; opacity: 0.7; text-align: right; }
.totals-v { padding: 2px 0; text-align: right; }
.b-terms { margin: 8px 0; text-align: justify; }
.b-custom { margin: 8px 0; }
.b-signatures {
display: flex; gap: 32px; margin-top: 32px; page-break-inside: avoid;
}
.sig-cell { flex: 1; }
.sig-title { font-weight: 600; margin-bottom: 28px; }
.sig-line { font-size: 10pt; }
.sig-stamp { margin-top: 16px; font-size: 9pt; opacity: 0.6; }
.b-page-break { page-break-before: always; }
</style>
</head>
<body>
${blocksHtml}
</body>
</html>`;
}