feat(documents): editor de texto rico (Tiptap) para templates de documento
Substitui o textarea de texto puro por um editor visual real, seguindo o
print de referência do usuário do editor "Contrato de Software" do
OrçaFácil legado: toolbar completa (negrito/itálico/sublinhado/tachado,
alinhamento, listas, tabela, imagem, quebra de página), variáveis inseridas
como chip inline via catálogo fechado (nunca {{texto livre}}), painel
"Variáveis usadas neste modelo" (com badge obrigatório ao vivo) e
"Histórico de versões".
Backend: editor-content-renderer.ts e merge-engine.ts portados/reescritos
a partir do renderEditorContent.js/variableRegistry.js reais do legado
(achados em .tmp/orcafacil-reference) — resolve a árvore Tiptap
diretamente em vez de regex sobre HTML cacheado. Novo catálogo de
variáveis + GET /documents/variable-catalog.
Fora de escopo desta leva (documentado em docs/progress.md): nó
`conditional`, repetição de linha de tabela, bloqueio por variável
obrigatória ausente, e auto-resolução de dados reais do cadastro na hora
de gerar o documento.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
@Get("variable-catalog")
|
||||
@RequirePermission("documents", "view")
|
||||
getVariableCatalog() {
|
||||
return this.documents.getVariableCatalog();
|
||||
}
|
||||
|
||||
@Get("templates")
|
||||
@RequirePermission("documents", "view")
|
||||
listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { auditRepo, documentsRepo } from "@eden/database";
|
||||
import type { DocumentGeneration } from "@eden/database";
|
||||
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
import { mergeVariables } from "./merge-engine";
|
||||
import { renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import { resolveDocument } from "./merge-engine";
|
||||
import { collectMergeFieldKeys, renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import { VARIABLE_CATALOG } from "./variable-catalog";
|
||||
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||
@@ -25,6 +26,10 @@ const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
getVariableCatalog() {
|
||||
return VARIABLE_CATALOG;
|
||||
}
|
||||
|
||||
async listTemplates(docGroup?: "cliente" | "revenda") {
|
||||
return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
|
||||
}
|
||||
@@ -122,22 +127,18 @@ export class DocumentsService {
|
||||
* (editar → pré-visualizar → editar) sem precisar salvar a cada volta.
|
||||
*
|
||||
* Como não há um contexto real (nenhum cliente/revenda selecionado nesta
|
||||
* tela), toda `{{variavel}}` encontrada vira um placeholder visível
|
||||
* (`[variavel]`) em vez de string vazia — o merge-engine já reporta
|
||||
* exatamente essas chaves em `missingVariables` quando o contexto é
|
||||
* vazio, então é reaproveitado igual, só invertendo o que fazer com o
|
||||
* resultado.
|
||||
* tela), toda `mergeField` encontrada vira um placeholder visível
|
||||
* (`[variavel]`) em vez de string vazia.
|
||||
*/
|
||||
async previewDraft(templateId: string, dto: PreviewDraftDto): Promise<Buffer> {
|
||||
const template = await documentsRepo.getTemplateById(templateId);
|
||||
if (!template) throw new NotFoundException("Template de documento não encontrado");
|
||||
|
||||
const html = renderEditorContentToHtml(dto.editorContent);
|
||||
const { missingVariables } = mergeVariables(html, {});
|
||||
const placeholderContext = Object.fromEntries(missingVariables.map((key) => [key, `[${key}]`]));
|
||||
const { resolved } = mergeVariables(html, placeholderContext);
|
||||
const keys = collectMergeFieldKeys(dto.editorContent);
|
||||
const placeholderContext = Object.fromEntries(keys.map((key) => [key, `[${key}]`]));
|
||||
const { html } = resolveDocument(dto.editorContent, placeholderContext);
|
||||
|
||||
return renderPdf({ html: resolved, css: dto.cssContent });
|
||||
return renderPdf({ html, css: dto.cssContent });
|
||||
}
|
||||
|
||||
async publish(templateId: string, user: EdenAuthenticatedUser) {
|
||||
@@ -182,11 +183,9 @@ export class DocumentsService {
|
||||
}
|
||||
|
||||
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
|
||||
// não reimplementado nesta leva (editor_content ainda não valida a
|
||||
// árvore Tiptap real, ver editor-content-renderer.ts); mas a
|
||||
// renderização de PDF em si (Chromium) e o upload para S3 já são
|
||||
// reais a partir daqui.
|
||||
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
|
||||
// não reimplementado nesta leva; mas a renderização de PDF em si
|
||||
// (Chromium) e o upload para S3 já são reais a partir daqui.
|
||||
const { html: resolved, missingVariables } = resolveDocument(published.version.editor_content, dto.context ?? {});
|
||||
|
||||
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
|
||||
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
/**
|
||||
* Stand-in SIMPLIFICADO para o `renderToHtml()`/`renderEditorContent.js`
|
||||
* do legado (eden.md §2.2) — aquele renderer conhece toda a árvore
|
||||
* Tiptap (paragraph/heading/table/mergeField/conditional/...) e escapa
|
||||
* HTML nó a nó. Esta leva de backend não tem o editor visual real ainda
|
||||
* (`editor_content` é aceito como JSONB genérico, não validado), então
|
||||
* em vez de reimplementar aquele renderer pela metade, este módulo faz
|
||||
* só o mínimo necessário para `document_template_versions.html_content`
|
||||
* nunca ficar dessincronizado do `editor_content` salvo:
|
||||
* Serializador do documento Tiptap (`editor_content`) para HTML — porta fiel
|
||||
* de `renderEditorContent.js` do legado OrçaFácil
|
||||
* (`.tmp/orcafacil-reference/server/src/lib/documentTemplates/renderEditorContent.js`),
|
||||
* escrito à mão sem depender de `@tiptap/html` (este pacote não pode
|
||||
* depender de bibliotecas do editor/frontend — eden.md §2.2).
|
||||
*
|
||||
* - se `editor_content` tiver uma propriedade de nível raiz
|
||||
* `plainText` (string), ela é usada como o texto do documento;
|
||||
* - caso contrário, um placeholder determinístico é usado.
|
||||
*
|
||||
* NÃO deve ser confundido com o merge-engine (merge-engine.ts), que roda
|
||||
* DEPOIS, sobre o texto aqui extraído, para resolver `{{variavel}}`.
|
||||
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
||||
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
||||
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField, marks bold/
|
||||
* italic/underline/strike/textStyle(fontSize). NÃO implementado (fora de
|
||||
* escopo desta leva, ver docs/progress.md): nó `conditional` e
|
||||
* `tableRow.attrs.repeat` (expansão de linha de tabela) — o editor/catálogo
|
||||
* de variáveis desta leva nunca produz nenhum dos dois.
|
||||
*/
|
||||
|
||||
export const EDITOR_CONTENT_PLACEHOLDER_TEXT =
|
||||
"[Conteúdo gerado pelo editor visual — renderização completa pendente de implementação]";
|
||||
export interface TiptapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
export interface TiptapNode {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TiptapNode[];
|
||||
marks?: TiptapMark[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Resolve o valor final de uma `mergeField` pela sua `key` — quando omitida, o nó vira o chip visual (preview "com variáveis"). */
|
||||
export type ResolveMergeField = (key: string) => string;
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
@@ -28,35 +39,131 @@ function escapeHtml(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `editor_content.plainText` when present as a root-level string
|
||||
* property, or `null` otherwise. Never throws on a malformed/unexpected
|
||||
* shape — this leva doesn't validate the Tiptap tree at all (that's the
|
||||
* visual editor's job once it exists).
|
||||
*/
|
||||
export function extractPlainText(editorContent: unknown): string | null {
|
||||
if (
|
||||
editorContent !== null &&
|
||||
typeof editorContent === "object" &&
|
||||
"plainText" in (editorContent as Record<string, unknown>) &&
|
||||
typeof (editorContent as Record<string, unknown>).plainText === "string"
|
||||
) {
|
||||
return (editorContent as { plainText: string }).plainText;
|
||||
// textAlign é atributo do NODE (parágrafo/título) no Tiptap — não é mark.
|
||||
function textAlignStyle(node: TiptapNode): string {
|
||||
const align = node.attrs?.textAlign;
|
||||
return typeof align === "string" && align.length > 0 ? ` style="text-align:${escapeHtml(align)}"` : "";
|
||||
}
|
||||
|
||||
// fontSize é mark (aplicado por trecho de texto) da extensão oficial
|
||||
// @tiptap/extension-text-style — vira um <span> em volta do texto marcado.
|
||||
function renderMarks(text: string, marks: TiptapMark[] | undefined): string {
|
||||
let html = escapeHtml(text);
|
||||
for (const mark of marks ?? []) {
|
||||
if (mark.type === "bold") html = `<strong>${html}</strong>`;
|
||||
else if (mark.type === "italic") html = `<em>${html}</em>`;
|
||||
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
||||
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
||||
else if (mark.type === "textStyle" && mark.attrs?.fontSize) {
|
||||
html = `<span style="font-size:${escapeHtml(mark.attrs.fontSize)}">${html}</span>`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInline(nodes: TiptapNode[] | undefined, resolveMergeField?: ResolveMergeField): string {
|
||||
return (nodes ?? [])
|
||||
.map((node) => {
|
||||
if (node.type === "text") return renderMarks(node.text ?? "", node.marks);
|
||||
if (node.type === "mergeField") {
|
||||
const key = String(node.attrs?.key ?? "");
|
||||
if (resolveMergeField) return escapeHtml(resolveMergeField(key));
|
||||
const label = typeof node.attrs?.label === "string" ? node.attrs.label : key;
|
||||
return `<span class="merge-field-chip" data-merge-field="${escapeHtml(key)}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return `<p${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField) || " "}</p>`;
|
||||
case "heading": {
|
||||
const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
|
||||
return `<h${level}${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField)}</h${level}>`;
|
||||
}
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ol>`;
|
||||
case "listItem":
|
||||
return `<li>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</li>`;
|
||||
case "image":
|
||||
return `<img src="${escapeHtml(node.attrs?.src)}" alt="${escapeHtml(node.attrs?.alt ?? "")}" />`;
|
||||
case "pageBreak":
|
||||
return `<div class="page-break" style="break-after:page;page-break-after:always;"></div>`;
|
||||
case "table": {
|
||||
// <thead> de verdade (não só a 1ª linha) — é o que faz o Chromium
|
||||
// repetir o cabeçalho da tabela em toda página nova quando ela
|
||||
// atravessa uma quebra.
|
||||
const rows = node.content ?? [];
|
||||
const headerRows = rows.filter((r) => r.content?.[0]?.type === "tableHeader");
|
||||
const bodyRows = rows.filter((r) => r.content?.[0]?.type !== "tableHeader");
|
||||
const theadHtml = headerRows.length
|
||||
? `<thead>${headerRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</thead>`
|
||||
: "";
|
||||
const tbodyHtml = `<tbody>${bodyRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</tbody>`;
|
||||
return `<table>${theadHtml}${tbodyHtml}</table>`;
|
||||
}
|
||||
case "tableRow":
|
||||
return `<tr>${(node.content ?? []).map((cell) => renderBlock(cell, resolveMergeField)).join("")}</tr>`;
|
||||
case "tableHeader": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<th${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</th>`;
|
||||
}
|
||||
case "tableCell": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isTiptapDoc(value: unknown): value is { content?: TiptapNode[] } {
|
||||
return value !== null && typeof value === "object" && (value as { type?: unknown }).type === "doc";
|
||||
}
|
||||
|
||||
/**
|
||||
* `extractPlainText` with the deterministic placeholder as fallback —
|
||||
* this is the actual text fed to the merge engine at generation time
|
||||
* (documents.service.ts), never `null`/empty on that path.
|
||||
* Serializa `editor_content` para HTML. Sem `resolveMergeField`, toda
|
||||
* `mergeField` vira o chip visual (é o que fica cacheado em
|
||||
* `document_template_versions.html_content`, eden.md §1.2); com
|
||||
* `resolveMergeField`, cada uma vira o valor final resolvido (usado na
|
||||
* geração real e na pré-visualização de rascunho).
|
||||
*/
|
||||
export function resolveSourceText(editorContent: unknown): string {
|
||||
const plainText = extractPlainText(editorContent);
|
||||
return plainText && plainText.length > 0 ? plainText : EDITOR_CONTENT_PLACEHOLDER_TEXT;
|
||||
export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
|
||||
if (!isTiptapDoc(editorContent)) return "";
|
||||
return (editorContent.content ?? []).map((node) => renderBlock(node, resolveMergeField)).join("\n");
|
||||
}
|
||||
|
||||
/** Recomputes `document_template_versions.html_content` (eden.md §1.2 step 2). */
|
||||
/** `renderEditorContentToHtml` do stand-in anterior — mantido como nome de import estável para os chamadores existentes. */
|
||||
export function renderEditorContentToHtml(editorContent: unknown): string {
|
||||
return `<p>${escapeHtml(resolveSourceText(editorContent))}</p>`;
|
||||
return renderToHtml(editorContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varre a árvore e devolve todas as chaves de `mergeField` usadas no
|
||||
* documento — usado tanto para "Variáveis usadas neste modelo" (frontend)
|
||||
* quanto para achar `missingVariables` na resolução (merge-engine.ts).
|
||||
* Sem suporte a `tableRow.attrs.repeat`/escopo `collection` nesta leva
|
||||
* (fora de escopo — ver docblock do arquivo).
|
||||
*/
|
||||
export function collectMergeFieldKeys(editorContent: unknown): string[] {
|
||||
const keys = new Set<string>();
|
||||
|
||||
function walk(node: TiptapNode | undefined): void {
|
||||
if (!node) return;
|
||||
if (node.type === "mergeField" && typeof node.attrs?.key === "string") {
|
||||
keys.add(node.attrs.key);
|
||||
return;
|
||||
}
|
||||
(node.content ?? []).forEach(walk);
|
||||
}
|
||||
|
||||
if (isTiptapDoc(editorContent)) {
|
||||
(editorContent.content ?? []).forEach(walk);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
/**
|
||||
* Motor de merge de variáveis — versão SIMPLIFICADA para esta leva,
|
||||
* inspirada no `resolveDocument()` do legado (eden.md §3.2), mas
|
||||
* operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da
|
||||
* árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`,
|
||||
* `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis
|
||||
* `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3,
|
||||
* §3.4) fica para quando o editor visual real existir; não é
|
||||
* reimplementada aqui.
|
||||
*
|
||||
* Pura e sem I/O — testável isoladamente (ver
|
||||
* apps/api/test/merge-engine.spec.ts). Diferença deliberada em relação
|
||||
* ao motor completo do legado: aquele lança `MISSING_REQUIRED_FIELDS`/
|
||||
* `UNRESOLVED_VARIABLES` (HTTP 422) e bloqueia a geração; este NUNCA
|
||||
* lança exceção — variáveis ausentes do contexto são substituídas por
|
||||
* string vazia e apenas reportadas em `missingVariables` (deduplicadas,
|
||||
* na ordem da primeira ocorrência). O chamador decide o que fazer com
|
||||
* elas (bloquear, avisar, etc.); o motor não decide isso sozinho.
|
||||
* Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
|
||||
* (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
|
||||
* a versão stand-in de uma leva anterior, antes do editor visual real
|
||||
* existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
|
||||
* simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
|
||||
* (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
|
||||
* dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
|
||||
* (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
|
||||
* só reportada em `missingVariables` (deduplicada, ordem da primeira
|
||||
* ocorrência); o chamador decide o que fazer com isso.
|
||||
*/
|
||||
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
|
||||
|
||||
export interface MergeResult {
|
||||
/** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */
|
||||
resolved: string;
|
||||
/** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */
|
||||
export interface ResolveDocumentResult {
|
||||
/** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
|
||||
html: string;
|
||||
/** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
|
||||
missingVariables: string[];
|
||||
}
|
||||
|
||||
// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo,
|
||||
// ex. "cliente.razao_social". Espaços em volta da chave são tolerados
|
||||
// ("{{ cliente.razao_social }}") e ignorados.
|
||||
const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
|
||||
export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
|
||||
const keys = collectMergeFieldKeys(editorContent);
|
||||
const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
|
||||
|
||||
export function mergeVariables(text: string, context: Record<string, string>): MergeResult {
|
||||
const missingVariables: string[] = [];
|
||||
const seenMissing = new Set<string>();
|
||||
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
|
||||
|
||||
const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(context, key)) {
|
||||
return context[key] ?? "";
|
||||
}
|
||||
if (!seenMissing.has(key)) {
|
||||
seenMissing.add(key);
|
||||
missingVariables.push(key);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
return { resolved, missingVariables };
|
||||
return { html, missingVariables };
|
||||
}
|
||||
|
||||
150
apps/api/src/documents/variable-catalog.ts
Normal file
150
apps/api/src/documents/variable-catalog.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Catálogo fechado de variáveis (whitelist) — porta de `VARIABLE_CATALOG`
|
||||
* em `.tmp/orcafacil-reference/server/src/lib/documentTemplates/variableRegistry.js`,
|
||||
* só as entradas `scope: 'document'` (sem `item.*`/`pdf.*` — resolução por
|
||||
* collection/renderer está fora de escopo desta leva, ver docs/progress.md).
|
||||
*
|
||||
* O editor nunca deixa o usuário digitar uma chave livre, só escolher daqui
|
||||
* — cada `mergeField` inserido carrega `key`/`label` vindos de uma entrada
|
||||
* deste catálogo. Servido também ao frontend via
|
||||
* `GET /v1/documents/variable-catalog` para montar o menu "Inserir
|
||||
* variável" e o painel "Variáveis usadas neste modelo" sem duplicar a lista
|
||||
* à mão nos dois lados.
|
||||
*/
|
||||
|
||||
export interface VariableCatalogEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
function cedenteCessionarioEntries(): VariableCatalogEntry[] {
|
||||
return (["cedente", "cessionario"] as const).flatMap((prefix) => {
|
||||
const label = prefix === "cedente" ? "Cedente" : "Cessionária";
|
||||
return [
|
||||
{ key: `${prefix}.razao_social`, label: `Razão Social (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cnpj`, label: `CNPJ/CPF (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.email`, label: `E-mail (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.endereco`, label: `Endereço (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.bairro`, label: `Bairro (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cidade`, label: `Cidade (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.uf`, label: `UF (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.cep`, label: `CEP (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.responsavel_nome`, label: `Nome do Responsável (${label})`, category: label, required: false },
|
||||
{ key: `${prefix}.responsavel_cpf`, label: `CPF do Responsável (${label})`, category: label, required: false },
|
||||
{
|
||||
key: `${prefix}.responsavel_telefone`,
|
||||
label: `Telefone do Responsável (${label})`,
|
||||
category: label,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
||||
// CLIENTE — client_registrations (PF/PJ) OU resellers, dependendo da origem do documento.
|
||||
{ key: "cliente.razao_social", label: "Razão Social", category: "Cliente", required: true },
|
||||
{ key: "cliente.nome_fantasia", label: "Nome Fantasia", category: "Cliente", required: false },
|
||||
{ key: "cliente.cnpj", label: "CNPJ/CPF", category: "Cliente", required: true },
|
||||
{ key: "cliente.email", label: "E-mail", category: "Cliente", required: false },
|
||||
{ key: "cliente.telefone", label: "Telefone", category: "Cliente", required: false },
|
||||
{ key: "cliente.cep", label: "CEP", category: "Cliente", required: false },
|
||||
{ key: "cliente.endereco", label: "Endereço", category: "Cliente", required: true },
|
||||
{ key: "cliente.complemento", label: "Complemento", category: "Cliente", required: false },
|
||||
{ key: "cliente.bairro", label: "Bairro", category: "Cliente", required: false },
|
||||
{ key: "cliente.cidade", label: "Cidade", category: "Cliente", required: false },
|
||||
{ key: "cliente.uf", label: "UF", category: "Cliente", required: false },
|
||||
{ key: "cliente.ie_rg", label: "IE/RG", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_nome", label: "Nome do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_cpf", label: "CPF do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.responsavel_telefone", label: "Telefone do Responsável", category: "Cliente", required: false },
|
||||
{ key: "cliente.celular_pf", label: "Celular (pessoa física)", category: "Cliente", required: false },
|
||||
|
||||
...cedenteCessionarioEntries(),
|
||||
|
||||
// EMPRESA EMISSORA
|
||||
{ key: "empresa.razao_social", label: "Razão Social da Empresa", category: "Empresa", required: true },
|
||||
{ key: "empresa.nome_fantasia", label: "Nome Fantasia da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cnpj", label: "CNPJ da Empresa", category: "Empresa", required: true },
|
||||
{ key: "empresa.ie", label: "Inscrição Estadual da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.im", label: "Inscrição Municipal da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.endereco", label: "Endereço da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.bairro", label: "Bairro da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cidade", label: "Cidade da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.uf", label: "UF da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cep", label: "CEP da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.cidade_uf", label: "Cidade/UF da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.telefone", label: "Telefone da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.email", label: "E-mail da Empresa", category: "Empresa", required: false },
|
||||
{ key: "empresa.website", label: "Site da Empresa", category: "Empresa", required: false },
|
||||
|
||||
// OFERTA — tabela quotes.
|
||||
{ key: "oferta.numero", label: "Número da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.data", label: "Data da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.validade", label: "Validade da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.fidelidade", label: "Fidelidade (meses)", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_mensal", label: "Valor Mensal", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_implantacao", label: "Valor de Implantação", category: "Oferta", required: false },
|
||||
{ key: "oferta.valor_total", label: "Valor Total do Contrato", category: "Oferta", required: false },
|
||||
{ key: "oferta.nome_comercial", label: "Nome Comercial da Oferta", category: "Oferta", required: false },
|
||||
{ key: "oferta.vigencia_texto", label: "Prazo de Vigência (texto)", category: "Oferta", required: false },
|
||||
{ key: "oferta.destinacao", label: "Destinação (Corporativo/Residencial)", category: "Oferta", required: false },
|
||||
{ key: "oferta.combo", label: "Contratação em Combo (Sim/Não)", category: "Oferta", required: false },
|
||||
{
|
||||
key: "oferta.permanencia_aplicavel",
|
||||
label: "Prazo de Permanência Aplicável (Sim/Não)",
|
||||
category: "Oferta",
|
||||
required: false,
|
||||
},
|
||||
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", category: "Oferta", required: false },
|
||||
{ key: "oferta.desconto_fidelidade", label: "Desconto por Fidelidade", category: "Oferta", required: false },
|
||||
{ key: "oferta.total_beneficios", label: "Total de Benefícios (VTB)", category: "Oferta", required: false },
|
||||
|
||||
// CONTRATO — client_registrations.ixc_contract_number + quotes.contract_period/fidelity_period.
|
||||
{ key: "contrato.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
|
||||
{ key: "contrato.codigo_cliente_ixc", label: "Código do Cliente IXC", category: "Contrato", required: false },
|
||||
{ key: "contrato.vigencia_meses", label: "Vigência (meses)", category: "Contrato", required: false },
|
||||
{ key: "contrato.data_inicio", label: "Início da Vigência", category: "Contrato", required: false },
|
||||
{ key: "contrato.data_termino", label: "Fim da Vigência", category: "Contrato", required: false },
|
||||
{ key: "contrato.software_nome", label: "Nome do Software", category: "Contrato", required: false },
|
||||
{ key: "contrato.numeros_portar", label: "Números a Portar", category: "Contrato", required: false },
|
||||
{ key: "contrato.quantidade_portar", label: "Quantidade de Números a Portar", category: "Contrato", required: false },
|
||||
|
||||
// PORTABILIDADE / CESSÃO — sem cadastro próprio, preenchidos manualmente na geração.
|
||||
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
|
||||
{ key: "portabilidade.cnpj_doadora", label: "CNPJ da Doadora", category: "Portabilidade", required: false },
|
||||
{
|
||||
key: "portabilidade.titularidade_doadora",
|
||||
label: "Titularidade na Doadora",
|
||||
category: "Portabilidade",
|
||||
required: false,
|
||||
},
|
||||
{ key: "cessao.codigo_acesso_cedido", label: "Código(s) de Acesso Cedido(s)", category: "Cessão", required: false },
|
||||
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },
|
||||
|
||||
// ASSINATURA — leitura do módulo de assinatura eletrônica já existente.
|
||||
{ key: "assinatura.signatario_nome", label: "Nome do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.signatario_cpf", label: "CPF do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.signatario_email", label: "E-mail do Signatário", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.data", label: "Data da Assinatura", category: "Assinatura", required: false },
|
||||
{ key: "assinatura.envelope_numero", label: "Número do Envelope", category: "Assinatura", required: false },
|
||||
{
|
||||
key: "assinatura.codigo_autenticidade",
|
||||
label: "Código de Autenticidade",
|
||||
category: "Assinatura",
|
||||
required: false,
|
||||
},
|
||||
|
||||
// DOCUMENTO — metadados do próprio documento sendo gerado.
|
||||
{ key: "documento.numero", label: "Número do Documento", category: "Documento", required: false },
|
||||
{ key: "documento.ano_atual", label: "Ano Atual", category: "Documento", required: false },
|
||||
{ key: "documento.data_geracao", label: "Data de Geração", category: "Documento", required: false },
|
||||
{
|
||||
key: "documento.data_geracao_extenso",
|
||||
label: "Data de Geração (por extenso)",
|
||||
category: "Documento",
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user