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:
2026-09-19 19:49:42 -03:00
parent 553c0d9533
commit 1545666f17
20 changed files with 1999 additions and 312 deletions

View File

@@ -2,12 +2,12 @@
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
*
* This leva has NO real visual editor: `editorContent` is a simplified
* JSONB with a single root-level `plainText` string carrying
* `{{variavel}}` placeholders (no rich node tree). PDF generation itself
* IS real (Chromium via packages/integrations/src/pdf/renderer.ts) —
* `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every
* `status: 'final'` generation.
* `editorContent` is a real Tiptap document tree (`JSONContent`), edited via
* the rich editor in `./documents/editor` — resolved server-side by
* `apps/api/src/documents/editor-content-renderer.ts` (ported from the
* legacy `renderEditorContent.js`). PDF generation is real (Chromium via
* packages/integrations/src/pdf/renderer.ts) — `pdf_s3_key`/`pdf_sha256`/
* `pdf_size_bytes` are populated on every `status: 'final'` generation.
*
* Like Contracts/Resellers, template/version/generation responses are
* snake_case straight off the DB row — except the two computed fields on a
@@ -15,6 +15,7 @@
* camelCase exactly as documented in the task brief; mirrored as-is below,
* not "fixed" to snake_case.
*/
import type { JSONContent } from "@tiptap/core";
import { API_BASE_URL, apiRequest } from "./http.js";
export type DocGroup = "cliente" | "revenda";
@@ -30,10 +31,15 @@ export type GenerationStatus = "generating" | "final" | "error";
*/
export const DOCUMENT_TEMPLATE_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
/** Simplified editor content for this leva — always `{ type: "doc", plainText }`, never a rich node tree. */
export interface SimpleEditorContent {
type: "doc";
plainText: string;
/** Empty Tiptap doc — mirrors `EMPTY_DOC_CONTENT` in packages/database/src/repositories/documents.ts. */
export const EMPTY_EDITOR_CONTENT: JSONContent = { type: "doc", content: [{ type: "paragraph", content: [] }] };
/** Uma entrada do catálogo fechado de variáveis — `GET /documents/variable-catalog`. */
export interface VariableCatalogEntry {
key: string;
label: string;
category: string;
required: boolean;
}
/** Response shape for `GET /documents/templates` (list) and the bare fields of the detail response. */
@@ -54,7 +60,7 @@ export interface DocumentTemplateVersion {
template_id: string;
version: number;
status: DocumentVersionStatus;
editor_content: SimpleEditorContent | Record<string, unknown>;
editor_content: JSONContent;
html_content: string | null;
css_content: string | null;
page_config: Record<string, unknown> | null;
@@ -78,7 +84,7 @@ export interface CreateDocumentTemplateInput {
/** Body of `PATCH /documents/templates/:id/draft` — matches `UpdateDraftDto`. `pageConfig` is deliberately omitted from the UI. */
export interface UpdateDraftInput {
editorContent?: SimpleEditorContent;
editorContent?: JSONContent;
cssContent?: string;
pageConfig?: Record<string, unknown>;
}
@@ -212,7 +218,7 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
* Renders the CURRENT (possibly unsaved) draft content to PDF via Chromium
* — nothing is persisted server-side. Same raw-fetch pattern as
* `downloadGenerationPdf` (binary response, still needs the bearer token).
* Every `{{variavel}}` shows as `[variavel]` in the result (no real
* Every `mergeField` shows as `[chave]` in the result (no real
* client/reseller context at this stage).
*
* Returns an object URL for the caller to show inline (`<iframe>`), rather
@@ -228,13 +234,13 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
*/
export async function previewDraft(
templateId: string,
input: { plainText: string; cssContent?: string },
input: { editorContent: JSONContent; cssContent?: string },
accessToken: string,
): Promise<string> {
const response = await fetch(`${API_BASE_URL}/documents/templates/${templateId}/draft/preview`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ editorContent: { plainText: input.plainText }, cssContent: input.cssContent }),
body: JSON.stringify({ editorContent: input.editorContent, cssContent: input.cssContent }),
});
if (!response.ok) {
throw new Error("Não foi possível gerar a pré-visualização.");
@@ -243,14 +249,33 @@ export async function previewDraft(
return URL.createObjectURL(blob);
}
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */
export function extractTemplateVariables(plainText: string): string[] {
const found = new Set<string>();
const pattern = /\{\{(\w+)\}\}/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(plainText)) !== null) {
const variable = match[1];
if (variable) found.add(variable);
}
return Array.from(found);
export function getVariableCatalog(accessToken: string): Promise<VariableCatalogEntry[]> {
return apiRequest<VariableCatalogEntry[]>("/documents/variable-catalog", { method: "GET" }, { accessToken });
}
/**
* Varre a árvore Tiptap e devolve as chaves de `mergeField` usadas —
* espelha `collectMergeFieldKeys` em
* apps/api/src/documents/editor-content-renderer.ts (duplicado de
* propósito: o editor não pode depender do backend, e vice-versa, mesma
* lógica do legado). Usado tanto no painel "Variáveis usadas neste modelo"
* quanto para auto-detectar o formulário de contexto em
* `GenerationFormPage.tsx`.
*/
export function collectMergeFieldKeys(editorContent: JSONContent | null | undefined): string[] {
const keys = new Set<string>();
function walk(node: JSONContent | 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 (editorContent?.type === "doc") {
(editorContent.content ?? []).forEach(walk);
}
return [...keys];
}