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:
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user