Compare commits

..

11 Commits

Author SHA1 Message Date
2aeb745d87 Merge pull request 'feat(database): migration de seed com os 7 templates reais de documento' (#3) from developer into main
Reviewed-on: #3
2026-09-19 21:10:34 -03:00
398579fa82 feat(database): migration de seed com os 7 templates reais de documento
Traz pro código os 7 templates importados na leva anterior (Contrato de
Software, Termo de Cessão, Termo de Portabilidade, Termo de Contratação
STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo de Adesão) —
uma VM nova já nasce com eles publicados assim que roda `pnpm db:migrate`,
sem precisar reimportar manualmente. Segue o mesmo padrão de seed já usado
neste repo pra dado de catálogo (INSERT ... ON CONFLICT DO NOTHING dentro
da própria migration), idempotente por key.

Corrige de passagem um html_content vazio em 4 dos 7 JSONs originais
(não afeta geração/editor, só o card de preview) — recalculado com o
próprio renderToHtml antes de virar seed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:09:28 -03:00
338677edc1 Merge pull request 'developer' (#2) from developer into main
Reviewed-on: #2
2026-09-19 20:54:09 -03:00
a1e020556e docs: passo a passo para subir uma VM nova de pré-produção
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:49:26 -03:00
33efb9b9ca docs: revert nota de fluxo de git no CLAUDE.md
Desnecessário documentar no repo — é uma convenção de trabalho do
assistente, não do time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:43:49 -03:00
665b0ac6d5 Merge pull request 'docs: documenta fluxo de git (push para developer, merge manual via Gitea)' (#1) from developer into main
Reviewed-on: #1
2026-09-19 20:42:38 -03:00
b3b1e21c20 docs: documenta fluxo de git (push para developer, merge manual via Gitea)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:42:04 -03:00
3785b32c5f feat(documents): suporte a linha condicional (conditional) + catálogo completo
Necessário pra importar os 7 templates reais do legado (fornecidos pelo
usuário via export do OrçaFácil): o Termo de Contratação STFC usa o nó
`conditional` (envelope em volta de uma tableRow, franquia de minutos/0800)
— sem suporte, tanto o backend quanto o editor Tiptap rejeitavam/renderizavam
em branco esse documento específico, mesmo bug já documentado no export do
legado. A condição em si nunca é avaliada nesta leva (fora de escopo) — a
linha sempre passa adiante, comportamento explícito e documentado.

Também adiciona ao variable-catalog.ts as ~25 variáveis que faltavam
(tarifação STFC por banda, 0800, portabilidade, prazo de instalação),
descobertas comparando contra as chaves reais usadas nos 7 templates
importados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:26:18 -03:00
1545666f17 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>
2026-09-19 19:49:42 -03:00
553c0d9533 feat(documents): pré-visualização em PDF do rascunho via Chromium
Novo endpoint POST /documents/templates/:id/draft/preview: renderiza o
conteúdo atual (ainda não salvo) do editor com placeholders {{variavel}}
substituídos por [variavel], via Chromium real, sem persistir nada
(sem generation, sem upload S3) — permite validar visualmente o
resultado antes do "Publicar", que é uma ação permanente.

Corrige de passagem um bug real: css_content do template nunca chegava
ao renderPdf na geração real de documento (createGeneration), sendo
descartado silenciosamente.

No frontend, a pré-visualização aparece embutida num iframe na própria
tela (não em nova aba): abrir em nova aba se mostrou não-confiável ao
vivo — a navegação tardia de uma aba pré-aberta pode ser ignorada pelo
navegador quando o gesto do usuário que a autorizou já expirou no tempo
que o Chromium leva pra renderizar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:59:02 -03:00
8ab18ababc feat(quotes): catálogo navegável + resumo ao vivo na Nova Oferta
Substitui a tabela de linhas com dropdown de produto por duas colunas,
no padrão do legado OrçaFácil: "Adicionar Produtos" à esquerda (busca,
ordenação, lista com preço da faixa do período de contrato, clique
adiciona ao carrinho) e "Resumo da Oferta" à direita, sticky (itens
com stepper, breakdown de implantação, implantação geral, condição de
pagamento, condição especial e observações — todos migrados pra dentro
do painel —, totais ao vivo com Total do Contrato em destaque).

Fórmula do preview espelha exatamente pricing.ts::computeQuotePricing
— nada muda no backend, é só a experiência de montar a oferta antes de
enviar; o servidor continua sendo a única fonte de verdade nos totais
persistidos.

pnpm turbo run typecheck build: 28/28. Suíte @eden/api: 113/113
(inalterada). Testado ao vivo ponta a ponta, incluindo o caso de
aprovação por desconto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:24:26 -03:00
32 changed files with 24607 additions and 462 deletions

View File

@@ -4,6 +4,7 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { DocumentsService } from "./documents.service"; import { DocumentsService } from "./documents.service";
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
import { UpdateDraftDto } from "./dto/update-draft.dto"; import { UpdateDraftDto } from "./dto/update-draft.dto";
import { PreviewDraftDto } from "./dto/preview-draft.dto";
import { CreateGenerationDto } from "./dto/create-generation.dto"; import { CreateGenerationDto } from "./dto/create-generation.dto";
import { JwtAuthGuard } from "../auth/jwt-auth.guard"; import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { PermissionGuard } from "../auth/permission.guard"; import { PermissionGuard } from "../auth/permission.guard";
@@ -24,6 +25,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
export class DocumentsController { export class DocumentsController {
constructor(private readonly documents: DocumentsService) {} constructor(private readonly documents: DocumentsService) {}
@Get("variable-catalog")
@RequirePermission("documents", "view")
getVariableCatalog() {
return this.documents.getVariableCatalog();
}
@Get("templates") @Get("templates")
@RequirePermission("documents", "view") @RequirePermission("documents", "view")
listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") { listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") {
@@ -54,6 +61,19 @@ export class DocumentsController {
return this.documents.updateDraft(id, dto, user); return this.documents.updateDraft(id, dto, user);
} }
@Post("templates/:id/draft/preview")
@RequirePermission("documents", "edit")
@Header("Content-Type", "application/pdf")
async previewDraft(
@Param("id") id: string,
@Body() dto: PreviewDraftDto,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.documents.previewDraft(id, dto);
res.set({ "Content-Disposition": 'inline; filename="preview.pdf"' });
return new StreamableFile(buffer);
}
@Post("templates/:id/publish") @Post("templates/:id/publish")
@RequirePermission("documents", "edit") @RequirePermission("documents", "edit")
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) { publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {

View File

@@ -5,10 +5,12 @@ import { auditRepo, documentsRepo } from "@eden/database";
import type { DocumentGeneration } from "@eden/database"; import type { DocumentGeneration } from "@eden/database";
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations"; import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
import { mergeVariables } from "./merge-engine"; import { resolveDocument } from "./merge-engine";
import { renderEditorContentToHtml } from "./editor-content-renderer"; import { collectMergeFieldKeys, renderEditorContentToHtml } from "./editor-content-renderer";
import { VARIABLE_CATALOG } from "./variable-catalog";
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto"; import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
import type { UpdateDraftDto } from "./dto/update-draft.dto"; import type { UpdateDraftDto } from "./dto/update-draft.dto";
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
import type { CreateGenerationDto } from "./dto/create-generation.dto"; import type { CreateGenerationDto } from "./dto/create-generation.dto";
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } = const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
@@ -24,6 +26,10 @@ const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError
*/ */
@Injectable() @Injectable()
export class DocumentsService { export class DocumentsService {
getVariableCatalog() {
return VARIABLE_CATALOG;
}
async listTemplates(docGroup?: "cliente" | "revenda") { async listTemplates(docGroup?: "cliente" | "revenda") {
return documentsRepo.listTemplates(docGroup ? { docGroup } : {}); return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
} }
@@ -111,6 +117,30 @@ export class DocumentsService {
} }
} }
/**
* Pré-visualização em PDF de um rascunho — nunca persiste nada (sem
* `document_generations`, sem upload S3), é só uma renderização
* descartável pra validar o layout/CSS antes da publicação irreversível.
* Roda sobre o `editorContent`/`cssContent` recebidos diretamente no
* corpo da requisição (o estado atual do editor, possivelmente ainda não
* salvo) — não busca o rascunho persistido, pra permitir iterar
* (editar → pré-visualizar → editar) sem precisar salvar a cada volta.
*
* Como não há um contexto real (nenhum cliente/revenda selecionado nesta
* 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 keys = collectMergeFieldKeys(dto.editorContent);
const placeholderContext = Object.fromEntries(keys.map((key) => [key, `[${key}]`]));
const { html } = resolveDocument(dto.editorContent, placeholderContext);
return renderPdf({ html, css: dto.cssContent });
}
async publish(templateId: string, user: EdenAuthenticatedUser) { async publish(templateId: string, user: EdenAuthenticatedUser) {
try { try {
const published = await documentsRepo.publishDraft(templateId, user.id); const published = await documentsRepo.publishDraft(templateId, user.id);
@@ -153,11 +183,9 @@ export class DocumentsService {
} }
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml — // eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
// não reimplementado nesta leva (editor_content ainda não valida a // não reimplementado nesta leva; mas a renderização de PDF em si
// árvore Tiptap real, ver editor-content-renderer.ts); mas a // (Chromium) e o upload para S3 já são reais a partir daqui.
// renderização de PDF em si (Chromium) e o upload para S3 já são const { html: resolved, missingVariables } = resolveDocument(published.version.editor_content, dto.context ?? {});
// reais a partir daqui.
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null }; const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
@@ -169,7 +197,7 @@ export class DocumentsService {
return { ...existing, missingVariables, deduped: true }; return { ...existing, missingVariables, deduped: true };
} }
const pdfBuffer = await renderPdf({ html: resolved }); const pdfBuffer = await renderPdf({ html: resolved, css: published.version.css_content ?? undefined });
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex"); const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`; const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf"); await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");

View File

@@ -0,0 +1,12 @@
import { IsObject, IsOptional, IsString } from "class-validator";
/**
* `POST .../draft/preview` body — deliberately takes the CURRENT, possibly
* unsaved, editor state (same shape as `UpdateDraftDto`) instead of reading
* the persisted draft: the point is fast iteration (edit → preview → edit)
* without forcing a save first. Nothing here is persisted.
*/
export class PreviewDraftDto {
@IsObject() editorContent!: Record<string, unknown>;
@IsOptional() @IsString() cssContent?: string;
}

View File

@@ -1,26 +1,39 @@
/** /**
* Stand-in SIMPLIFICADO para o `renderToHtml()`/`renderEditorContent.js` * Serializador do documento Tiptap (`editor_content`) para HTML — porta fiel
* do legado (eden.md §2.2) — aquele renderer conhece toda a árvore * de `renderEditorContent.js` do legado OrçaFácil
* Tiptap (paragraph/heading/table/mergeField/conditional/...) e escapa * (`.tmp/orcafacil-reference/server/src/lib/documentTemplates/renderEditorContent.js`),
* HTML nó a nó. Esta leva de backend não tem o editor visual real ainda * escrito à mão sem depender de `@tiptap/html` (este pacote não pode
* (`editor_content` é aceito como JSONB genérico, não validado), então * depender de bibliotecas do editor/frontend — eden.md §2.2).
* 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:
* *
* - se `editor_content` tiver uma propriedade de nível raiz * Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
* `plainText` (string), ela é usada como o texto do documento; * realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
* - caso contrário, um placeholder determinístico é usado. * pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
* * marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
* NÃO deve ser confundido com o merge-engine (merge-engine.ts), que roda * (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
* DEPOIS, sobre o texto aqui extraído, para resolver `{{variavel}}`. * STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
* condição contra o contexto é fora de escopo desta leva (ver
* docs/progress.md). NÃO implementado: `tableRow.attrs.repeat` (expansão de
* linha de tabela) — nenhum dos 7 templates importados do legado usa.
*/ */
export const EDITOR_CONTENT_PLACEHOLDER_TEXT = export interface TiptapMark {
"[Conteúdo gerado pelo editor visual — renderização completa pendente de implementação]"; type: string;
attrs?: Record<string, unknown>;
}
function escapeHtml(value: string): string { export interface TiptapNode {
return value 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, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
@@ -28,35 +41,138 @@ function escapeHtml(value: string): string {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
/** // textAlign é atributo do NODE (parágrafo/título) no Tiptap — não é mark.
* Returns `editor_content.plainText` when present as a root-level string function textAlignStyle(node: TiptapNode): string {
* property, or `null` otherwise. Never throws on a malformed/unexpected const align = node.attrs?.textAlign;
* shape — this leva doesn't validate the Tiptap tree at all (that's the return typeof align === "string" && align.length > 0 ? ` style="text-align:${escapeHtml(align)}"` : "";
* 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;
} }
return null;
// 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 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) || "&nbsp;"}</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>`;
}
case "conditional":
// Envelope em volta de uma `tableRow` (attrs: field/operator/value) —
// avaliação real da condição contra o contexto (eden.md §3.2,
// `applyConditionals`) é fora de escopo desta leva (ver
// docs/progress.md): a linha sempre passa adiante, igual ao
// comportamento do próprio legado quando não avaliada.
return (node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("");
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 — * Serializa `editor_content` para HTML. Sem `resolveMergeField`, toda
* this is the actual text fed to the merge engine at generation time * `mergeField` vira o chip visual (é o que fica cacheado em
* (documents.service.ts), never `null`/empty on that path. * `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 { export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
const plainText = extractPlainText(editorContent); if (!isTiptapDoc(editorContent)) return "";
return plainText && plainText.length > 0 ? plainText : EDITOR_CONTENT_PLACEHOLDER_TEXT; 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 { 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];
} }

View File

@@ -1,49 +1,29 @@
/** /**
* Motor de merge de variáveis — versão SIMPLIFICADA para esta leva, * Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
* inspirada no `resolveDocument()` do legado (eden.md §3.2), mas * (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
* operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da * a versão stand-in de uma leva anterior, antes do editor visual real
* árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`, * existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
* `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis * simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
* `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3, * (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
* §3.4) fica para quando o editor visual real existir; não é * dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
* reimplementada aqui. * (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
* * só reportada em `missingVariables` (deduplicada, ordem da primeira
* Pura e sem I/O — testável isoladamente (ver * ocorrência); o chamador decide o que fazer com isso.
* 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.
*/ */
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
export interface MergeResult { export interface ResolveDocumentResult {
/** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */ /** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
resolved: string; html: string;
/** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */ /** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
missingVariables: string[]; missingVariables: string[];
} }
// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo, export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
// ex. "cliente.razao_social". Espaços em volta da chave são tolerados const keys = collectMergeFieldKeys(editorContent);
// ("{{ cliente.razao_social }}") e ignorados. const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
export function mergeVariables(text: string, context: Record<string, string>): MergeResult { const html = renderToHtml(editorContent, (key) => context[key] ?? "");
const missingVariables: string[] = [];
const seenMissing = new Set<string>();
const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => { return { html, missingVariables };
if (Object.prototype.hasOwnProperty.call(context, key)) {
return context[key] ?? "";
}
if (!seenMissing.has(key)) {
seenMissing.add(key);
missingVariables.push(key);
}
return "";
});
return { resolved, missingVariables };
} }

View File

@@ -0,0 +1,172 @@
/**
* 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 },
{ key: "oferta.comercializacao_inicio", label: "Início do Prazo de Comercialização", category: "Oferta", required: false },
{ key: "oferta.comercializacao_fim", label: "Fim do Prazo de Comercialização", category: "Oferta", required: false },
// 0800 — sem cadastro próprio, preenchidos manualmente na geração.
{ key: "oferta.inclui_0800", label: "Inclui Número 0800? (Sim/Não)", category: "Oferta", required: false },
{ key: "oferta.numero_0800", label: "Número 0800", category: "Oferta", required: false },
{ key: "oferta.franquia_0800_min", label: "Franquia Mensal de 0800 (min)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_padrao", label: "Valor 0800 — Padrão (por minuto)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_reduzido", label: "Valor 0800 — Reduzido (por minuto)", category: "Oferta", required: false },
// Tabela de tarifação STFC — automática a partir de products.tariff_rates do produto medido da oferta.
...(["lc", "ldn", "vc1", "vc2", "vc3", "ldi"] as const).flatMap((t) => [
{ key: `oferta.tarifa_${t}_normal`, label: `Tarifa ${t.toUpperCase()} — Normal (por minuto)`, category: "Oferta", required: false },
{ key: `oferta.tarifa_${t}_reduzida`, label: `Tarifa ${t.toUpperCase()} — Reduzida (por minuto)`, 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 },
{ key: "contrato.prazo_instalacao", label: "Prazo de Instalação", 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: "portabilidade.titular_cpf_cnpj_doadora",
label: "CNPJ/CPF do Titular 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,
},
];

View File

@@ -14,11 +14,11 @@ import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type Authe
* persist the result, skip re-rendering on dedup, and stream the right S3 * persist the result, skip re-rendering on dedup, and stream the right S3
* key back out on download. * key back out on download.
*/ */
const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes")); const renderPdfMock = vi.fn(async (_input: { html: string; css?: string }) => Buffer.from("%PDF-1.4 fake pdf bytes"));
const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined); const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined);
const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)])); const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
vi.mock("@eden/integrations", () => ({ vi.mock("@eden/integrations", () => ({
renderPdf: (input: { html: string }) => renderPdfMock(input), renderPdf: (input: { html: string; css?: string }) => renderPdfMock(input),
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType), uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
getObjectStream: (key: string) => getObjectStreamMock(key), getObjectStream: (key: string) => getObjectStreamMock(key),
})); }));
@@ -47,7 +47,21 @@ describe("documents — PDF generation + download", () => {
await agent(app) await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`) .patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken)) .set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } }) .send({
editorContent: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Revenda: " },
{ type: "mergeField", attrs: { key: "revenda.nome", label: "Nome da Revenda" } },
],
},
],
},
cssContent: "p { color: red; }",
})
.expect(200); .expect(200);
await agent(app) await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`) .post(`/api/v1/documents/templates/${templateId}/publish`)
@@ -88,6 +102,10 @@ describe("documents — PDF generation + download", () => {
const renderedHtml = renderPdfMock.mock.calls[0]![0].html; const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
expect(renderedHtml).toContain("Revenda: ACME"); expect(renderedHtml).toContain("Revenda: ACME");
expect(renderedHtml).not.toContain("{{revenda.nome}}"); expect(renderedHtml).not.toContain("{{revenda.nome}}");
// The template version's css_content must actually reach the renderer —
// real gap found and fixed alongside the draft-preview feature: this
// used to be silently discarded (renderPdf({ html: resolved }) only).
expect(renderPdfMock.mock.calls[0]![0].css).toBe("p { color: red; }");
// Uploaded buffer's hash matches the mock's output and what got persisted. // Uploaded buffer's hash matches the mock's output and what got persisted.
const uploadCall = uploadObjectMock.mock.calls[0]!; const uploadCall = uploadObjectMock.mock.calls[0]!;
@@ -129,4 +147,62 @@ describe("documents — PDF generation + download", () => {
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key); expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`); expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
}); });
describe("draft preview", () => {
it("renders the CURRENT (unsaved) editor state with placeholder variables, never persisting anything", async () => {
renderPdfMock.mockClear();
uploadObjectMock.mockClear();
const res = await agent(app)
.post(`/api/v1/documents/templates/${templateId}/draft/preview`)
.set(...bearer(admin.accessToken))
.send({
editorContent: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "Olá " },
{ type: "mergeField", attrs: { key: "cliente.nome", label: "Nome do Cliente" } },
{ type: "text", text: ", plano " },
{ type: "mergeField", attrs: { key: "plano", label: "Plano" } },
{ type: "text", text: "." },
],
},
],
},
cssContent: "p { color: blue; }",
})
.expect(201);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(Buffer.isBuffer(res.body) && res.body.toString()).toBe("%PDF-1.4 fake pdf bytes");
expect(renderPdfMock).toHaveBeenCalledTimes(1);
const call = renderPdfMock.mock.calls[0]![0];
expect(call.html).toContain("[cliente.nome]");
expect(call.html).toContain("[plano]");
expect(call.html).not.toContain("merge-field-chip");
expect(call.css).toBe("p { color: blue; }");
// Never persisted: no S3 upload, and the real (published) generation
// count for this template is unaffected.
expect(uploadObjectMock).not.toHaveBeenCalled();
const listRes = await agent(app)
.get("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.query({ resellerId })
.expect(200);
expect(listRes.body).toHaveLength(1); // still just the one from the earlier test
});
it("404s for an unknown template id", async () => {
await agent(app)
.post("/api/v1/documents/templates/00000000-0000-0000-0000-000000000000/draft/preview")
.set(...bearer(admin.accessToken))
.send({ editorContent: { plainText: "x" } })
.expect(404);
});
});
}); });

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import { collectMergeFieldKeys, renderToHtml } from "../src/documents/editor-content-renderer";
/**
* Unidade pura, sem app/DB — cobre a porta do serializador Tiptap→HTML do
* legado (renderEditorContent.js). documents-pdf.spec.ts cobre o pipeline
* completo (geração real via HTTP); aqui é só a árvore de nós, isolada.
*/
describe("editor-content-renderer", () => {
it("renderiza parágrafo com alinhamento e marks (bold/italic/underline/strike/fontSize)", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
attrs: { textAlign: "center" },
content: [
{ type: "text", text: "Olá ", marks: [{ type: "bold" }] },
{ type: "text", text: "mundo", marks: [{ type: "italic" }, { type: "underline" }, { type: "strike" }] },
{ type: "text", text: " grande", marks: [{ type: "textStyle", attrs: { fontSize: "18px" } }] },
],
},
],
};
const html = renderToHtml(doc);
expect(html).toBe(
'<p style="text-align:center"><strong>Olá </strong><s><u><em>mundo</em></u></s><span style="font-size:18px"> grande</span></p>',
);
});
it("renderiza heading, listas e parágrafo vazio como &nbsp;", () => {
const doc = {
type: "doc",
content: [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Título" }] },
{ type: "paragraph", content: [] },
{
type: "bulletList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item 1" }] }] },
],
},
{
type: "orderedList",
content: [
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "item A" }] }] },
],
},
],
};
const html = renderToHtml(doc);
expect(html).toContain("<h2>Título</h2>");
expect(html).toContain("<p>&nbsp;</p>");
expect(html).toContain("<ul><li><p>item 1</p></li></ul>");
expect(html).toContain("<ol><li><p>item A</p></li></ol>");
});
it("renderiza imagem e quebra de página", () => {
const doc = {
type: "doc",
content: [
{ type: "image", attrs: { src: "data:image/png;base64,abc", alt: "logo" } },
{ type: "pageBreak" },
],
};
const html = renderToHtml(doc);
expect(html).toContain('<img src="data:image/png;base64,abc" alt="logo" />');
expect(html).toContain('style="break-after:page;page-break-after:always;"');
});
it("gera <thead> real só quando há tableHeader, mesmo no meio das linhas", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [{ type: "tableHeader", attrs: { colspan: 2 }, content: [{ type: "paragraph", content: [] }] }],
},
{
type: "tableRow",
content: [{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] }],
},
],
},
],
};
const html = renderToHtml(doc);
expect(html).toMatch(/<thead>.*<th colspan="2">/s);
expect(html).toMatch(/<tbody>.*<td>/s);
});
it("mergeField vira chip visual sem resolveMergeField, e valor resolvido com resolveMergeField", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "mergeField", attrs: { key: "cliente.razao_social", label: "Razão Social" } }],
},
],
};
const chipHtml = renderToHtml(doc);
expect(chipHtml).toContain('data-merge-field="cliente.razao_social"');
expect(chipHtml).toContain("Razão Social");
const resolvedHtml = renderToHtml(doc, (key) => (key === "cliente.razao_social" ? "ACME LTDA" : ""));
expect(resolvedHtml).toBe("<p>ACME LTDA</p>");
expect(resolvedHtml).not.toContain("merge-field-chip");
});
it("conditional sempre renderiza sua linha (avaliação real de condição é trabalho futuro)", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Coluna" }] }] }],
},
{
type: "conditional",
attrs: { field: "oferta.franquia_minutos", operator: "exists" },
content: [
{
type: "tableRow",
content: [
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "linha condicional" }] }] },
],
},
],
},
],
},
],
};
const html = renderToHtml(doc);
expect(html).toMatch(/<thead>.*<th><p>Coluna<\/p><\/th>.*<\/thead>/s);
expect(html).toContain("<tr><td><p>linha condicional</p></td></tr>");
expect(html).not.toContain("conditional");
});
it("collectMergeFieldKeys enxerga chaves dentro de um conditional", () => {
const doc = {
type: "doc",
content: [
{
type: "conditional",
attrs: { field: "x" },
content: [
{ type: "tableRow", content: [{ type: "tableCell", content: [{ type: "mergeField", attrs: { key: "a.b" } }] }] },
],
},
],
};
expect(collectMergeFieldKeys(doc)).toEqual(["a.b"]);
});
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "<script>alert(1)</script>" }] },
{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "x" } }] },
],
};
const html = renderToHtml(doc, () => "<img onerror=alert(1)>");
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
expect(html).not.toContain("<img onerror");
expect(html).toContain("&lt;img onerror=alert(1)&gt;");
});
it("collectMergeFieldKeys varre a árvore inteira e dedupe chaves repetidas", () => {
const doc = {
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "mergeField", attrs: { key: "cliente.razao_social" } },
{ type: "mergeField", attrs: { key: "cliente.cnpj" } },
],
},
{
type: "table",
content: [
{
type: "tableRow",
content: [
{
type: "tableCell",
content: [{ type: "paragraph", content: [{ type: "mergeField", attrs: { key: "cliente.razao_social" } }] }],
},
],
},
],
},
],
};
expect(collectMergeFieldKeys(doc)).toEqual(["cliente.razao_social", "cliente.cnpj"]);
});
it("devolve string vazia para conteúdo que não é um doc Tiptap válido", () => {
expect(renderToHtml(null)).toBe("");
expect(renderToHtml({ type: "doc" })).toBe("");
expect(collectMergeFieldKeys(undefined)).toEqual([]);
});
});

View File

@@ -52,7 +52,9 @@ describe("signature envelopes — automatic contratante signer derivation", () =
await agent(app) await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`) .patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken)) .set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste." } }) .send({
editorContent: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste." }] }] },
})
.expect(200); .expect(200);
await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201); await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201);
const generation = await agent(app) const generation = await agent(app)

View File

@@ -118,7 +118,14 @@ describe("signature envelopes — full lifecycle", () => {
await agent(app) await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`) .patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken)) .set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste para assinatura eletrônica." } }) .send({
editorContent: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "Contrato de teste para assinatura eletrônica." }] },
],
},
})
.expect(200); .expect(200);
await agent(app) await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`) .post(`/api/v1/documents/templates/${templateId}/publish`)

View File

@@ -13,6 +13,18 @@
"dependencies": { "dependencies": {
"@eden/domain-shared": "workspace:*", "@eden/domain-shared": "workspace:*",
"@eden/ui": "workspace:*", "@eden/ui": "workspace:*",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/extension-table": "^3.29.2",
"@tiptap/extension-table-cell": "^3.29.2",
"@tiptap/extension-table-header": "^3.29.2",
"@tiptap/extension-table-row": "^3.29.2",
"@tiptap/extension-text-align": "^3.29.2",
"@tiptap/extension-text-style": "^3.29.2",
"@tiptap/extension-underline": "^3.29.2",
"@tiptap/core": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.28.1" "react-router-dom": "^6.28.1"

View File

@@ -2,12 +2,12 @@
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations` * 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). * — 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 * `editorContent` is a real Tiptap document tree (`JSONContent`), edited via
* JSONB with a single root-level `plainText` string carrying * the rich editor in `./documents/editor` — resolved server-side by
* `{{variavel}}` placeholders (no rich node tree). PDF generation itself * `apps/api/src/documents/editor-content-renderer.ts` (ported from the
* IS real (Chromium via packages/integrations/src/pdf/renderer.ts) — * legacy `renderEditorContent.js`). PDF generation is real (Chromium via
* `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every * packages/integrations/src/pdf/renderer.ts) — `pdf_s3_key`/`pdf_sha256`/
* `status: 'final'` generation. * `pdf_size_bytes` are populated on every `status: 'final'` generation.
* *
* Like Contracts/Resellers, template/version/generation responses are * Like Contracts/Resellers, template/version/generation responses are
* snake_case straight off the DB row — except the two computed fields on a * 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, * camelCase exactly as documented in the task brief; mirrored as-is below,
* not "fixed" to snake_case. * not "fixed" to snake_case.
*/ */
import type { JSONContent } from "@tiptap/core";
import { API_BASE_URL, apiRequest } from "./http.js"; import { API_BASE_URL, apiRequest } from "./http.js";
export type DocGroup = "cliente" | "revenda"; 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_]*$/; 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. */ /** Empty Tiptap doc — mirrors `EMPTY_DOC_CONTENT` in packages/database/src/repositories/documents.ts. */
export interface SimpleEditorContent { export const EMPTY_EDITOR_CONTENT: JSONContent = { type: "doc", content: [{ type: "paragraph", content: [] }] };
type: "doc";
plainText: string; /** 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. */ /** 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; template_id: string;
version: number; version: number;
status: DocumentVersionStatus; status: DocumentVersionStatus;
editor_content: SimpleEditorContent | Record<string, unknown>; editor_content: JSONContent;
html_content: string | null; html_content: string | null;
css_content: string | null; css_content: string | null;
page_config: Record<string, unknown> | 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. */ /** Body of `PATCH /documents/templates/:id/draft` — matches `UpdateDraftDto`. `pageConfig` is deliberately omitted from the UI. */
export interface UpdateDraftInput { export interface UpdateDraftInput {
editorContent?: SimpleEditorContent; editorContent?: JSONContent;
cssContent?: string; cssContent?: string;
pageConfig?: Record<string, unknown>; pageConfig?: Record<string, unknown>;
} }
@@ -208,14 +214,68 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */ /**
export function extractTemplateVariables(plainText: string): string[] { * Renders the CURRENT (possibly unsaved) draft content to PDF via Chromium
const found = new Set<string>(); * — nothing is persisted server-side. Same raw-fetch pattern as
const pattern = /\{\{(\w+)\}\}/g; * `downloadGenerationPdf` (binary response, still needs the bearer token).
let match: RegExpExecArray | null; * Every `mergeField` shows as `[chave]` in the result (no real
while ((match = pattern.exec(plainText)) !== null) { * client/reseller context at this stage).
const variable = match[1]; *
if (variable) found.add(variable); * Returns an object URL for the caller to show inline (`<iframe>`), rather
* than opening a new tab: a Chromium render is slow enough (real
* server-side rendering, not instant) that by the time the blob is ready,
* the browser's "this navigation is tied to a user gesture" grace window
* has often already expired — `window.open()` (even pre-opened and
* navigated later) can then be silently ignored, leaving a blank tab.
* Confirmed live: a synchronous in-memory blob navigates the pre-opened
* tab fine, but the real (few-hundred-ms) round trip through this
* endpoint does not. Showing the PDF in the same page sidesteps the whole
* class of popup-timing issues.
*/
export async function previewDraft(
templateId: 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: input.editorContent, cssContent: input.cssContent }),
});
if (!response.ok) {
throw new Error("Não foi possível gerar a pré-visualização.");
} }
return Array.from(found); const blob = await response.blob();
return URL.createObjectURL(blob);
}
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];
} }

View File

@@ -5,8 +5,8 @@ import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js"; import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js"; import { ApiError } from "../../lib/api/http.js";
import { import {
collectMergeFieldKeys,
createGeneration, createGeneration,
extractTemplateVariables,
getDocumentTemplate, getDocumentTemplate,
listDocumentTemplates, listDocumentTemplates,
} from "../../lib/api/documents.js"; } from "../../lib/api/documents.js";
@@ -92,8 +92,7 @@ export function GenerationFormPage() {
.then((detail) => { .then((detail) => {
if (cancelled) return; if (cancelled) return;
const published = detail.versions.find((v) => v.id === detail.current_version_id); const published = detail.versions.find((v) => v.id === detail.current_version_id);
const content = published?.editor_content as { plainText?: string } | undefined; const variables = published ? collectMergeFieldKeys(published.editor_content) : [];
const variables = content?.plainText ? extractTemplateVariables(content.plainText) : [];
setTemplateVariables(variables); setTemplateVariables(variables);
setVariableValues(Object.fromEntries(variables.map((v) => [v, ""]))); setVariableValues(Object.fromEntries(variables.map((v) => [v, ""])));
}) })

View File

@@ -1,33 +1,36 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import type { JSONContent } from "@tiptap/react";
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui"; import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js"; import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js"; import { ApiError } from "../../lib/api/http.js";
import { import {
EMPTY_EDITOR_CONTENT,
collectMergeFieldKeys,
createDraft, createDraft,
getDocumentTemplate, getDocumentTemplate,
getVariableCatalog,
previewDraft,
publishDraft, publishDraft,
updateDraft, updateDraft,
} from "../../lib/api/documents.js"; } from "../../lib/api/documents.js";
import type { DocumentTemplateVersion, DocumentTemplateWithVersions } from "../../lib/api/documents.js"; import type { DocumentTemplateVersion, DocumentTemplateWithVersions, VariableCatalogEntry } from "../../lib/api/documents.js";
import { formatDateTimeBR } from "../../lib/format.js"; import { formatDateTimeBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js"; import { Breadcrumb } from "../../components/Breadcrumb.js";
import { DOC_GROUP_LABEL, DOC_GROUP_VARIANT } from "./documentLabels.js"; import { DOC_GROUP_LABEL, DOC_GROUP_VARIANT } from "./documentLabels.js";
import { DocumentEditor } from "./editor/DocumentEditor.js";
/** /**
* Layout reference: DreamsERP theme `.tmp/theme-reference/documentRepository.tsx` * Layout de referência: print do usuário (`/home/handix/editortexto.png`,
* (breadcrumb + title/status-badge header, bordered white "detail" cards) * editor de "Contrato de Software" do OrçaFácil legado) — header com
* and `.tmp/theme-reference/documentWorkflows.tsx` for the * título/badge de versão + ações, tabs "Editor" / "Pré-visualização (PDF)",
* draft-in-progress/active status pill language — the theme's Ant Design * toolbar rica com variáveis inseridas como chip (nunca `{{texto livre}}`),
* table + off-canvas modals were not usable here, so the draft editor is a * painel "Variáveis usadas neste modelo" e "Histórico de versões".
* plain in-page card (textarea + Save/Publish), same composition style as
* `ContractDetailPage`'s `StatusTransitionCard`.
* *
* No rich Tiptap editor and no PDF in this leva: `editorContent` is always * A tab "Pré-visualização (PDF)" é nomeada diferente do print
* `{ type: "doc", plainText }` and the only free-form field is a `<textarea>` * ("Visualizar com dados") de propósito: não resolve dado real de
* with `{{variavel}}` placeholders. `html_content` is the server-computed * cliente/revenda nesta leva, só o placeholder `[chave]` — ver
* preview of that merge, rendered read-only. * apps/api/src/documents/documents.service.ts#previewDraft.
*/ */
export function TemplateDetailPage() { export function TemplateDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -36,6 +39,7 @@ export function TemplateDetailPage() {
const [template, setTemplate] = useState<DocumentTemplateWithVersions | null>(null); const [template, setTemplate] = useState<DocumentTemplateWithVersions | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
if (!id || !accessToken) return; if (!id || !accessToken) return;
@@ -72,6 +76,23 @@ export function TemplateDetailPage() {
const publishedVersion = template.versions.find((v) => v.id === template.current_version_id) ?? null; const publishedVersion = template.versions.find((v) => v.id === template.current_version_id) ?? null;
const draftVersion = template.versions.find((v) => v.status === "draft") ?? null; const draftVersion = template.versions.find((v) => v.status === "draft") ?? null;
if (editorOpen && draftVersion) {
return (
<div>
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} />
<DraftEditorScreen
templateId={template.id}
templateName={template.name}
draftVersion={draftVersion}
versions={template.versions}
accessToken={accessToken}
onClose={() => setEditorOpen(false)}
onChanged={load}
/>
</div>
);
}
return ( return (
<div> <div>
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} /> <Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} />
@@ -114,7 +135,7 @@ export function TemplateDetailPage() {
<Card title="Preview da versão publicada"> <Card title="Preview da versão publicada">
{publishedVersion?.html_content ? ( {publishedVersion?.html_content ? (
<div <div
className="rounded-md border border-border-color p-4 text-sm leading-relaxed text-title [&_p]:mb-2" className="rounded-md border border-border-color p-4 text-sm leading-relaxed text-title [&_p]:mb-2 [&_.merge-field-chip]:rounded [&_.merge-field-chip]:bg-primary/10 [&_.merge-field-chip]:px-1 [&_.merge-field-chip]:text-primary"
dangerouslySetInnerHTML={{ __html: publishedVersion.html_content }} dangerouslySetInnerHTML={{ __html: publishedVersion.html_content }}
/> />
) : ( ) : (
@@ -124,12 +145,19 @@ export function TemplateDetailPage() {
)} )}
</Card> </Card>
<DraftCard <Card title="Rascunho">
templateId={template.id} {draftVersion ? (
draftVersion={draftVersion} <div className="space-y-3">
accessToken={accessToken} <p className="text-sm text-default">
onChanged={load} Existe um rascunho em edição (v{draftVersion.version}, criado em{" "}
/> {formatDateTimeBR(draftVersion.created_at)}).
</p>
<Button onClick={() => setEditorOpen(true)}>Editar rascunho existente</Button>
</div>
) : (
<CreateDraftPrompt templateId={template.id} accessToken={accessToken} onCreated={() => { load(); setEditorOpen(true); }} />
)}
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -145,68 +173,98 @@ function Row({ label, value }: { label: string; value: string }) {
); );
} }
function DraftCard({ function CreateDraftPrompt({
templateId, templateId,
draftVersion,
accessToken, accessToken,
onChanged, onCreated,
}: { }: {
templateId: string; templateId: string;
draftVersion: DocumentTemplateVersion | null;
accessToken: string | null; accessToken: string | null;
onChanged: () => void; onCreated: () => void;
}) { }) {
const [editorOpen, setEditorOpen] = useState(false);
const [plainText, setPlainText] = useState("");
const [cssContent, setCssContent] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmingPublish, setConfirmingPublish] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
function openEditorFor(version: DocumentTemplateVersion) {
const content = version.editor_content as { plainText?: string } | undefined;
setPlainText(content?.plainText ?? "");
setCssContent(version.css_content ?? "");
setEditorOpen(true);
setError(null);
setSaveSuccess(false);
}
async function handleCreateDraft() { async function handleCreateDraft() {
if (!accessToken) return; if (!accessToken) return;
setError(null); setError(null);
setCreating(true); setCreating(true);
try { try {
const version = await createDraft(templateId, accessToken); await createDraft(templateId, accessToken);
openEditorFor(version); onCreated();
onChanged();
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError("Já existe um rascunho para este template — recarregando para editá-lo.");
onChanged();
} else {
setError(err instanceof ApiError ? err.message : "Não foi possível criar o rascunho."); setError(err instanceof ApiError ? err.message : "Não foi possível criar o rascunho.");
}
} finally { } finally {
setCreating(false); setCreating(false);
} }
} }
async function handleSave(event: FormEvent) { return (
event.preventDefault(); <div className="space-y-3">
{error ? <Alert variant="error">{error}</Alert> : null}
<p className="text-sm text-default">Nenhum rascunho em edição no momento.</p>
<Button onClick={handleCreateDraft} loading={creating}>
Criar rascunho
</Button>
</div>
);
}
type PreviewTab = "editor" | "preview";
function DraftEditorScreen({
templateId,
templateName,
draftVersion,
versions,
accessToken,
onClose,
onChanged,
}: {
templateId: string;
templateName: string;
draftVersion: DocumentTemplateVersion;
versions: DocumentTemplateVersion[];
accessToken: string | null;
onClose: () => void;
onChanged: () => void;
}) {
const [content, setContent] = useState<JSONContent>((draftVersion.editor_content as JSONContent) ?? EMPTY_EDITOR_CONTENT);
const [cssContent, setCssContent] = useState(draftVersion.css_content ?? "");
const [tab, setTab] = useState<PreviewTab>("editor");
const [catalog, setCatalog] = useState<VariableCatalogEntry[]>([]);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [confirmingPublish, setConfirmingPublish] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
getVariableCatalog(accessToken)
.then(setCatalog)
.catch(() => setCatalog([]));
}, [accessToken]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const usedKeys = collectMergeFieldKeys(content);
const usedEntries = usedKeys.map((key) => catalog.find((entry) => entry.key === key) ?? { key, label: key, category: "Outra", required: false });
async function handleSave() {
if (!accessToken) return; if (!accessToken) return;
setError(null); setError(null);
setSaveSuccess(false); setSaveSuccess(false);
setSaving(true); setSaving(true);
try { try {
await updateDraft( await updateDraft(templateId, { editorContent: content, cssContent: cssContent || undefined }, accessToken);
templateId,
{ editorContent: { type: "doc", plainText }, cssContent: cssContent || undefined },
accessToken,
);
setSaveSuccess(true); setSaveSuccess(true);
onChanged(); onChanged();
} catch (err) { } catch (err) {
@@ -216,15 +274,32 @@ function DraftCard({
} }
} }
async function handlePreview() {
if (!accessToken) return;
setError(null);
setPreviewing(true);
try {
const url = await previewDraft(templateId, { editorContent: content, cssContent: cssContent || undefined }, accessToken);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível gerar a pré-visualização.");
} finally {
setPreviewing(false);
}
}
async function handlePublish() { async function handlePublish() {
if (!accessToken) return; if (!accessToken) return;
setError(null); setError(null);
setPublishing(true); setPublishing(true);
try { try {
await publishDraft(templateId, accessToken); await publishDraft(templateId, accessToken);
setEditorOpen(false);
setConfirmingPublish(false); setConfirmingPublish(false);
onChanged(); onChanged();
onClose();
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível publicar o rascunho."); setError(err instanceof ApiError ? err.message : "Não foi possível publicar o rascunho.");
} finally { } finally {
@@ -232,36 +307,28 @@ function DraftCard({
} }
} }
if (!editorOpen) {
return ( return (
<Card title="Rascunho"> <div>
{error ? ( <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<Alert variant="error" className="mb-3"> <div className="flex items-center gap-3">
{error} <h1 className="text-lg font-bold text-title">{templateName}</h1>
</Alert> <Badge variant="warning">Rascunho (v{draftVersion.version})</Badge>
) : null}
{draftVersion ? (
<div className="space-y-3">
<p className="text-sm text-default">
Existe um rascunho em edição (v{draftVersion.version}, criado em{" "}
{formatDateTimeBR(draftVersion.created_at)}).
</p>
<Button onClick={() => openEditorFor(draftVersion)}>Editar rascunho existente</Button>
</div> </div>
) : ( <div className="flex items-center gap-2">
<div className="space-y-3"> <Button type="button" variant="secondary" onClick={onClose}>
<p className="text-sm text-default">Nenhum rascunho em edição no momento.</p> Fechar
<Button onClick={handleCreateDraft} loading={creating}>
Criar rascunho
</Button> </Button>
<Button type="button" variant="secondary" loading={saving} onClick={handleSave}>
Salvar rascunho
</Button>
{!confirmingPublish ? (
<Button type="button" variant="danger" onClick={() => setConfirmingPublish(true)}>
Publicar
</Button>
) : null}
</div>
</div> </div>
)}
</Card>
);
}
return (
<Card title="Editar rascunho">
{error ? ( {error ? (
<Alert variant="error" className="mb-3"> <Alert variant="error" className="mb-3">
{error} {error}
@@ -272,51 +339,8 @@ function DraftCard({
Rascunho salvo. Rascunho salvo.
</Alert> </Alert>
) : null} ) : null}
<form className="space-y-4" onSubmit={handleSave}>
<div>
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="draft-plain-text">
Conteúdo do documento
</label>
<textarea
id="draft-plain-text"
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 font-mono text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
rows={14}
value={plainText}
onChange={(e) => setPlainText(e.target.value)}
placeholder={"Prezado(a) {{nome_cliente}},\n\nEste contrato refere-se a {{plano_contratado}}..."}
/>
<p className="mt-1 text-xs text-default">
Use <code>{"{{variavel}}"}</code> para marcar trechos que serão substituídos na geração do documento.
</p>
</div>
<details>
<summary className="cursor-pointer text-sm font-semibold text-title">CSS (avançado, opcional)</summary>
<textarea
className="mt-2 w-full rounded-md border border-border-color bg-white px-3 py-2.5 font-mono text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
rows={6}
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
/>
</details>
<div className="flex flex-wrap items-center gap-2 border-t border-border-color pt-4">
<Button type="submit" loading={saving}>
Salvar rascunho
</Button>
<Button type="button" variant="secondary" onClick={() => setEditorOpen(false)}>
Fechar
</Button>
{!confirmingPublish ? (
<Button type="button" variant="danger" onClick={() => setConfirmingPublish(true)}>
Publicar
</Button>
) : null}
</div>
{confirmingPublish ? ( {confirmingPublish ? (
<Alert variant="warning" title="Publicar esta versão?"> <Alert variant="warning" title="Publicar esta versão?" className="mb-3">
<p className="mb-3"> <p className="mb-3">
Ao publicar, esta versão passa a ser a versão ativa do template e não poderá mais ser editada — a Ao publicar, esta versão passa a ser a versão ativa do template e não poderá mais ser editada — a
publicação é permanente. publicação é permanente.
@@ -331,7 +355,103 @@ function DraftCard({
</div> </div>
</Alert> </Alert>
) : null} ) : null}
</form>
<div className="mb-3 flex gap-1 border-b border-border-color">
<TabButton active={tab === "editor"} onClick={() => setTab("editor")}>
Editor / com variáveis
</TabButton>
<TabButton active={tab === "preview"} onClick={() => setTab("preview")}>
Pré-visualização (PDF)
</TabButton>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="lg:col-span-3">
{tab === "editor" ? (
<>
<DocumentEditor content={content} onChange={setContent} catalog={catalog} />
<details className="mt-3">
<summary className="cursor-pointer text-sm font-semibold text-title">CSS (avançado, opcional)</summary>
<textarea
className="mt-2 w-full rounded-md border border-border-color bg-white px-3 py-2.5 font-mono text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
rows={6}
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
/>
</details>
</>
) : (
<Card>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm text-default">
Renderização real via Chromium do conteúdo atual (ainda não salvo) — cada variável aparece como{" "}
<code>[chave]</code>, já que não há cliente/revenda selecionado nesta tela.
</p>
<Button type="button" variant="secondary" loading={previewing} onClick={handlePreview}>
Gerar pré-visualização
</Button>
</div>
{previewUrl ? (
<iframe
src={previewUrl}
title="Pré-visualização do documento"
className="h-[600px] w-full rounded-md border border-border-color"
/>
) : (
<p className="text-sm text-default">Clique em "Gerar pré-visualização" para ver o PDF.</p>
)}
</Card> </Card>
)}
</div>
<div className="space-y-4 lg:col-span-1">
<Card title="Variáveis usadas neste modelo">
{usedEntries.length === 0 ? (
<p className="text-xs text-default">Nenhuma variável inserida ainda.</p>
) : (
<ul className="space-y-2 text-xs">
{usedEntries.map((entry) => (
<li key={entry.key} className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-title">
<span className="text-success">✓</span> {entry.label}
</span>
{entry.required ? <span className="font-semibold text-warning">obrigatório</span> : null}
</li>
))}
</ul>
)}
</Card>
<Card title="Histórico de versões">
<ul className="space-y-2 text-xs">
{[...versions]
.sort((a, b) => b.version - a.version)
.map((version) => (
<li key={version.id} className="flex items-center justify-between gap-2">
<span className="text-title">v{version.version}</span>
<Badge variant={version.status === "draft" ? "warning" : "success"}>
{version.status === "draft" ? "Rascunho" : "Publicado"}
</Badge>
</li>
))}
</ul>
</Card>
</div>
</div>
</div>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
active ? "border-primary text-primary" : "border-transparent text-default hover:text-title"
}`}
>
{children}
</button>
); );
} }

View File

@@ -0,0 +1,89 @@
import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle, FontSize } from "@tiptap/extension-text-style";
import Image from "@tiptap/extension-image";
import { Table } from "@tiptap/extension-table";
import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
import { MergeField } from "./mergeFieldExtension.js";
import { PageBreak } from "./pageBreakExtension.js";
import { ConditionalRow } from "./conditionalRowExtension.js";
import { EditorToolbar } from "./EditorToolbar.js";
/**
* `Table` precisa aceitar `conditional` como filho direto, além de
* `tableRow` — sem isso o Tiptap rejeita o documento inteiro (renderiza em
* branco) sempre que uma tabela contém uma linha condicional, exatamente o
* bug documentado no export dos templates do legado (README dentro de
* `document_templates_export.zip`) e já corrigido lá da mesma forma.
*/
const TableWithConditional = Table.extend({ content: "(tableRow|conditional)+" });
/**
* Editor de rascunho real (Tiptap) — substitui o `<textarea>` de texto puro
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
* não-controlado por natureza); mudanças propagam via `onChange` chamado a
* cada transação, devolvendo `editor.getJSON()` — o mesmo formato aceito
* por `editor_content` no backend.
*/
export function DocumentEditor({
content,
onChange,
catalog,
}: {
content: JSONContent;
onChange: (content: JSONContent) => void;
catalog: VariableCatalogEntry[];
}) {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
TextStyle,
FontSize,
TextAlign.configure({ types: ["heading", "paragraph"] }),
Image,
TableWithConditional.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
MergeField,
PageBreak,
ConditionalRow,
],
content,
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
editorProps: {
attributes: {
class: "prose prose-sm max-w-none rounded-b-md border border-border-color bg-white p-4 text-sm text-title focus:outline-none min-h-[420px] [&_table]:border-collapse [&_td]:border [&_td]:border-border-color [&_td]:p-1.5 [&_th]:border [&_th]:border-border-color [&_th]:bg-light [&_th]:p-1.5",
},
},
});
// Rascunhos diferentes abertos no mesmo componente montado (troca de
// template sem desmontar a página) precisam resetar o conteúdo — Tiptap
// não observa `content` depois do mount.
useEffect(() => {
if (editor && content) {
const current = JSON.stringify(editor.getJSON());
const next = JSON.stringify(content);
if (current !== next) editor.commands.setContent(content);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
if (!editor) return null;
return (
<div>
<EditorToolbar editor={editor} catalog={catalog} />
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -0,0 +1,225 @@
import type { Editor } from "@tiptap/react";
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
import { VariablePickerMenu } from "./VariablePickerMenu.js";
const FONT_SIZES = [
{ label: "Padrão", value: "" },
{ label: "Pequeno", value: "10px" },
{ label: "Grande", value: "14px" },
{ label: "Título", value: "18px" },
];
function ToolbarButton({
active,
disabled,
onClick,
title,
children,
}: {
active?: boolean;
disabled?: boolean;
onClick: () => void;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className={`flex h-8 min-w-8 items-center justify-center rounded px-1.5 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
active ? "bg-primary/15 text-primary" : "text-title hover:bg-light"
}`}
>
{children}
</button>
);
}
/**
* Toolbar fiel ao print de referência (`/home/handix/editortexto.png`):
* desfazer/refazer · parágrafo/título · tamanho de fonte · negrito/itálico/
* sublinhado/tachado · alinhamento · listas · tabela (+ controles quando o
* cursor está dentro de uma) · imagem · quebra de página · inserir variável.
*/
export function EditorToolbar({
editor,
catalog,
}: {
editor: Editor;
catalog: VariableCatalogEntry[];
}) {
const insideTable = editor.isActive("table");
function headingValue(): string {
if (editor.isActive("heading", { level: 1 })) return "1";
if (editor.isActive("heading", { level: 2 })) return "2";
if (editor.isActive("heading", { level: 3 })) return "3";
return "p";
}
function fontSizeValue(): string {
return (editor.getAttributes("textStyle").fontSize as string | undefined) ?? "";
}
function insertImage() {
const url = window.prompt("URL da imagem (https:// ou data:)");
if (url) editor.chain().focus().setImage({ src: url, alt: "" }).run();
}
return (
<div className="flex flex-wrap items-center gap-1 rounded-t-md border border-b-0 border-border-color bg-light/50 p-2">
<ToolbarButton title="Desfazer" onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}>
↶
</ToolbarButton>
<ToolbarButton title="Refazer" onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}>
↷
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-border-color" />
<select
title="Parágrafo/Título"
value={headingValue()}
onChange={(e) => {
const v = e.target.value;
if (v === "p") editor.chain().focus().setParagraph().run();
else editor.chain().focus().toggleHeading({ level: Number(v) as 1 | 2 | 3 }).run();
}}
className="h-8 rounded border border-border-color bg-white px-2 text-xs text-title"
>
<option value="p">Parágrafo</option>
<option value="1">Título 1</option>
<option value="2">Título 2</option>
<option value="3">Título 3</option>
</select>
<select
title="Tamanho da fonte"
value={fontSizeValue()}
onChange={(e) => {
const v = e.target.value;
if (v) editor.chain().focus().setFontSize(v).run();
else editor.chain().focus().unsetFontSize().run();
}}
className="h-8 rounded border border-border-color bg-white px-2 text-xs text-title"
>
{FONT_SIZES.map((f) => (
<option key={f.label} value={f.value}>
{f.label}
</option>
))}
</select>
<div className="mx-1 h-5 w-px bg-border-color" />
<ToolbarButton title="Negrito" active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>
B
</ToolbarButton>
<ToolbarButton title="Itálico" active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>
<span className="italic">I</span>
</ToolbarButton>
<ToolbarButton
title="Sublinhado"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span className="underline">U</span>
</ToolbarButton>
<ToolbarButton title="Tachado" active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()}>
<span className="line-through">S</span>
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-border-color" />
<ToolbarButton
title="Alinhar à esquerda"
active={editor.isActive({ textAlign: "left" })}
onClick={() => editor.chain().focus().setTextAlign("left").run()}
>
⯇
</ToolbarButton>
<ToolbarButton
title="Centralizar"
active={editor.isActive({ textAlign: "center" })}
onClick={() => editor.chain().focus().setTextAlign("center").run()}
>
☰
</ToolbarButton>
<ToolbarButton
title="Alinhar à direita"
active={editor.isActive({ textAlign: "right" })}
onClick={() => editor.chain().focus().setTextAlign("right").run()}
>
⯈
</ToolbarButton>
<ToolbarButton
title="Justificar"
active={editor.isActive({ textAlign: "justify" })}
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
>
☷
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-border-color" />
<ToolbarButton
title="Lista com marcadores"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
•≡
</ToolbarButton>
<ToolbarButton
title="Lista numerada"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
1≡
</ToolbarButton>
<div className="mx-1 h-5 w-px bg-border-color" />
<ToolbarButton
title="Inserir tabela"
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
▦
</ToolbarButton>
{insideTable ? (
<>
<ToolbarButton title="Adicionar linha" onClick={() => editor.chain().focus().addRowAfter().run()}>
+L
</ToolbarButton>
<ToolbarButton title="Adicionar coluna" onClick={() => editor.chain().focus().addColumnAfter().run()}>
+C
</ToolbarButton>
<ToolbarButton title="Remover linha" onClick={() => editor.chain().focus().deleteRow().run()}>
-L
</ToolbarButton>
<ToolbarButton title="Remover coluna" onClick={() => editor.chain().focus().deleteColumn().run()}>
-C
</ToolbarButton>
<ToolbarButton title="Remover tabela" onClick={() => editor.chain().focus().deleteTable().run()}>
✕▦
</ToolbarButton>
</>
) : null}
<ToolbarButton title="Inserir imagem" onClick={insertImage}>
🖼
</ToolbarButton>
<ToolbarButton title="Quebra de página" onClick={() => editor.chain().focus().insertPageBreak().run()}>
⇥|
</ToolbarButton>
<div className="ml-auto">
<VariablePickerMenu
catalog={catalog}
onSelect={(entry) => editor.chain().focus().insertMergeField({ key: entry.key, label: entry.label }).run()}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
/**
* "Inserir variável" — não existe um componente de menu/dropdown pronto em
* packages/ui (só `Select`, um `<select>` nativo), então este painel é
* construído aqui, seguindo as mesmas classes Tailwind já usadas no projeto
* (border-border-color/rounded-md/shadow).
*/
export function VariablePickerMenu({
catalog,
onSelect,
}: {
catalog: VariableCatalogEntry[];
onSelect: (entry: VariableCatalogEntry) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const categories = [...new Set(catalog.map((entry) => entry.category))];
return (
<div ref={ref} className="relative inline-block">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md border border-border-color bg-white px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
>
<span aria-hidden>(x)</span> Inserir variável
</button>
{open ? (
<div className="absolute right-0 z-20 mt-1 max-h-80 w-72 overflow-y-auto rounded-md border border-border-color bg-white p-2 shadow-lg">
{categories.map((category) => (
<div key={category} className="mb-2 last:mb-0">
<p className="mb-1 px-2 text-[11px] font-bold uppercase tracking-wide text-default">{category}</p>
{catalog
.filter((entry) => entry.category === category)
.map((entry) => (
<button
key={entry.key}
type="button"
onClick={() => {
onSelect(entry);
setOpen(false);
}}
className="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs text-title hover:bg-light"
>
<span>{entry.label}</span>
{entry.required ? <span className="text-[10px] font-semibold text-warning">obrigatório</span> : null}
</button>
))}
</div>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { mergeAttributes, Node } from "@tiptap/core";
/**
* Envolve uma única `tableRow` que só deveria aparecer no documento final se
* uma condição bater contra o contexto (avaliada no backend — ver
* apps/api/src/documents/editor-content-renderer.ts, case 'conditional').
* Porta de `ConditionalRow.jsx` (export dos templates do legado,
* `/home/handix/document_templates_export.zip`) — necessário pra abrir o
* Termo de Contratação STFC, o único dos 7 templates importados que usa
* linhas condicionais (portabilidade/franquia de minutos).
*
* Nesta leva a condição NUNCA é avaliada de verdade (fora de escopo, ver
* docs/progress.md) — a linha sempre aparece no PDF final, igual ao
* comportamento do backend quando `conditional` não é resolvido.
*
* Renderiza como `<tbody style="display:contents">` (sem caixa própria) —
* a `<tr>` de dentro participa do layout da tabela normalmente, só o
* "envelope" de identificação some visualmente no editor.
*/
export const ConditionalRow = Node.create({
name: "conditional",
group: "block",
content: "tableRow",
isolating: true,
addAttributes() {
return {
field: { default: null },
operator: { default: "exists" },
value: { default: null },
};
},
parseHTML() {
return [{ tag: "tbody[data-conditional-field]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"tbody",
mergeAttributes(HTMLAttributes, {
style: "display:contents",
"data-conditional-field": node.attrs.field || "",
title: `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`,
}),
0,
];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("tbody");
dom.style.cssText = "display:contents";
dom.setAttribute("data-conditional-field", node.attrs.field || "");
dom.title = `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`;
return { dom, contentDOM: dom };
};
},
});

View File

@@ -0,0 +1,70 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { ReactNodeViewProps } from "@tiptap/react";
/**
* Nó Tiptap customizado para variáveis inseridas via catálogo fechado
* (`../variableCatalog.ts`) — nunca texto livre `{{variavel}}` digitado à
* mão (eden.md §3.1). `attrs.key` é o que o backend resolve
* (apps/api/src/documents/editor-content-renderer.ts); `attrs.label` é só
* exibição, nunca interpretado no servidor.
*/
export interface MergeFieldAttrs {
key: string;
label: string;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
mergeField: {
insertMergeField: (attrs: MergeFieldAttrs) => ReturnType;
};
}
}
function MergeFieldChip({ node }: ReactNodeViewProps<HTMLElement>) {
const attrs = node.attrs as MergeFieldAttrs;
return (
<NodeViewWrapper as="span" className="inline-block align-baseline">
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary">
{attrs.label || attrs.key}
</span>
</NodeViewWrapper>
);
}
export const MergeField = Node.create({
name: "mergeField",
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
key: { default: "" },
label: { default: "" },
};
},
parseHTML() {
return [{ tag: 'span[data-merge-field]' }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { "data-merge-field": HTMLAttributes.key }), HTMLAttributes.label];
},
addNodeView() {
return ReactNodeViewRenderer(MergeFieldChip);
},
addCommands() {
return {
insertMergeField:
(attrs: MergeFieldAttrs) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs }),
};
},
});

View File

@@ -0,0 +1,37 @@
import { mergeAttributes, Node } from "@tiptap/core";
/**
* Nó de bloco "quebra de página" — vira `<div style="break-after:page;
* page-break-after:always;">` no PDF final (ver renderBlock em
* apps/api/src/documents/editor-content-renderer.ts, case 'pageBreak').
* No editor, renderiza como uma régua tracejada não editável.
*/
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pageBreak: {
insertPageBreak: () => ReturnType;
};
}
}
export const PageBreak = Node.create({
name: "pageBreak",
group: "block",
atom: true,
selectable: true,
parseHTML() {
return [{ tag: "div.page-break" }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
class: "page-break my-2 border-t-2 border-dashed border-border-color py-1 text-center text-[10px] uppercase tracking-wide text-default",
"data-page-break": "true",
}),
"Quebra de página",
];
},
});

View File

@@ -15,20 +15,24 @@ import { Breadcrumb } from "../../components/Breadcrumb.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js"; import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
/** /**
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/addSalesQuotes.tsx` * Item-picking layout reference: the legacy OrçaFácil "Nova oferta" screen
* (two-column "details" header, editable item table with add/remove rows, * (two-column catalog + live cart — no longer available to inspect
* right-hand totals panel) — rebuilt with `@eden/ui` + plain Tailwind * directly, rebuilt from a precise description) — NOT the DreamsERP theme,
* instead of the theme's react-select/CommonDatePicker. Every price/total * whose `sales/addSalesQuotes.tsx` is a generic invoice-row table with no
* browsable-catalog/live-cart pattern and was used only as loose finish
* inspiration (rounded cards, clear hierarchy, a highlighted total block),
* in the EDEN light theme, not the theme's dark one. Built with `@eden/ui`
* + plain Tailwind, no react-select/CommonDatePicker. Every price/total
* shown here is illustrative only; the server (apps/api/src/quotes/pricing.ts) * shown here is illustrative only; the server (apps/api/src/quotes/pricing.ts)
* is the single source of truth and recomputes everything on save. * is the single source of truth and recomputes everything on save.
*/ */
interface ItemRow { interface CartLine {
productId: string; productId: string;
quantity: string; quantity: number;
} }
const EMPTY_ROW: ItemRow = { productId: "", quantity: "1" }; type ProductSort = "code" | "name";
function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentCondition[] { function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentCondition[] {
if (implTotal <= 1500) return ["À vista"]; if (implTotal <= 1500) return ["À vista"];
@@ -36,6 +40,23 @@ function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentC
return IMPL_PAYMENT_CONDITIONS; return IMPL_PAYMENT_CONDITIONS;
} }
function contractPeriodLabel(period: number): string {
return period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`;
}
function priceForPeriod(product: Product, period: number): number | null {
const key = `price_${period}` as keyof Pick<
Product,
"price_0" | "price_12" | "price_24" | "price_36" | "price_48"
>;
const raw = product[key];
return raw != null ? Number(raw) : null;
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
export function QuoteFormPage() { export function QuoteFormPage() {
const { accessToken } = useAuth(); const { accessToken } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -51,12 +72,16 @@ export function QuoteFormPage() {
const [clientPhone, setClientPhone] = useState(""); const [clientPhone, setClientPhone] = useState("");
const [contractPeriod, setContractPeriod] = useState<string>("12"); const [contractPeriod, setContractPeriod] = useState<string>("12");
const [fidelityPeriod, setFidelityPeriod] = useState(""); const [fidelityPeriod, setFidelityPeriod] = useState("");
const [items, setItems] = useState<ItemRow[]>([{ ...EMPTY_ROW }]); const [salesRepName, setSalesRepName] = useState("");
const [cart, setCart] = useState<CartLine[]>([]);
const [productSearch, setProductSearch] = useState("");
const [productSort, setProductSort] = useState<ProductSort>("code");
const [implGeral, setImplGeral] = useState(""); const [implGeral, setImplGeral] = useState("");
const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">(""); const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [salesRepName, setSalesRepName] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -75,34 +100,81 @@ export function QuoteFormPage() {
); );
}, [accessToken]); }, [accessToken]);
const productOptions = useMemo( const contractPeriodNum = Number(contractPeriod) || 0;
() => (products ?? []).map((p) => ({ value: p.id, label: `${p.name} (cód. ${p.product_code})` })),
const productsById = useMemo(() => {
const map = new Map<string, Product>();
for (const product of products ?? []) map.set(product.id, product);
return map;
}, [products]);
const browsableProducts = useMemo(
() => (products ?? []).filter((p) => p.active && !p.discontinued),
[products], [products],
); );
const estimatedImplTotal = useMemo(() => { const filteredProducts = useMemo(() => {
if (!products) return 0; const query = productSearch.trim().toLowerCase();
const fromItems = items.reduce((sum, row) => { const filtered = query
const product = products.find((p) => p.id === row.productId); ? browsableProducts.filter(
const qty = Number(row.quantity) || 0; (p) => p.name.toLowerCase().includes(query) || String(p.product_code).includes(query),
if (!product) return sum; )
return sum + Number(product.impl_unit_price ?? 0) * qty; : browsableProducts;
}, 0); return [...filtered].sort((a, b) =>
return fromItems + (Number(implGeral) || 0); productSort === "code" ? a.product_code - b.product_code : a.name.localeCompare(b.name, "pt-BR"),
}, [items, products, implGeral]); );
}, [browsableProducts, productSearch, productSort]);
const availablePaymentConditions = getAvailablePaymentConditions(estimatedImplTotal); const cartLines = useMemo(
() =>
cart
.map((line) => {
const product = productsById.get(line.productId);
if (!product) return null;
const unitPrice = priceForPeriod(product, contractPeriodNum) ?? 0;
const implUnitPrice = Number(product.impl_unit_price ?? 0);
return {
product,
quantity: line.quantity,
unitPrice,
subtotal: round2(unitPrice * line.quantity),
implSubtotal: round2(implUnitPrice * line.quantity),
};
})
.filter((line): line is NonNullable<typeof line> => line !== null),
[cart, productsById, contractPeriodNum],
);
function updateItem(index: number, patch: Partial<ItemRow>) { const monthlyTotal = round2(cartLines.reduce((sum, line) => sum + line.subtotal, 0));
setItems((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); const implFromProducts = round2(cartLines.reduce((sum, line) => sum + line.implSubtotal, 0));
const implGeralNum = Number(implGeral) || 0;
const totalImpl = round2(implFromProducts + implGeralNum);
const proposedMonthlyNum = Number(proposedMonthlyTotal) || 0;
const hasProposedMonthly = proposedMonthlyTotal !== "" && proposedMonthlyNum > 0;
const effectiveMonthly = hasProposedMonthly ? proposedMonthlyNum : monthlyTotal;
const contractTotal = round2(effectiveMonthly * (contractPeriodNum || 1) + totalImpl);
const availablePaymentConditions = getAvailablePaymentConditions(totalImpl);
function addToCart(productId: string) {
setCart((prev) => {
const existing = prev.find((line) => line.productId === productId);
if (existing) {
return prev.map((line) =>
line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line,
);
}
return [...prev, { productId, quantity: 1 }];
});
} }
function addItem() { function setQuantity(productId: string, quantity: number) {
setItems((prev) => [...prev, { ...EMPTY_ROW }]); if (quantity < 1) return;
setCart((prev) => prev.map((line) => (line.productId === productId ? { ...line, quantity } : line)));
} }
function removeItem(index: number) { function removeFromCart(productId: string) {
setItems((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); setCart((prev) => prev.filter((line) => line.productId !== productId));
} }
async function handleSubmit(event: FormEvent) { async function handleSubmit(event: FormEvent) {
@@ -110,12 +182,8 @@ export function QuoteFormPage() {
if (!accessToken) return; if (!accessToken) return;
setError(null); setError(null);
const validItems = items if (cart.length === 0) {
.filter((row) => row.productId && Number(row.quantity) > 0) setError("Adicione ao menos um produto à oferta.");
.map((row) => ({ productId: row.productId, quantity: Number(row.quantity) }));
if (validItems.length === 0) {
setError("Adicione ao menos um item com produto e quantidade válidos.");
return; return;
} }
@@ -130,7 +198,7 @@ export function QuoteFormPage() {
clientPhone, clientPhone,
contractPeriod: Number(contractPeriod) as ContractPeriod, contractPeriod: Number(contractPeriod) as ContractPeriod,
fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined, fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined,
items: validItems, items: cart.map((line) => ({ productId: line.productId, quantity: line.quantity })),
implGeral: implGeral ? Number(implGeral) : undefined, implGeral: implGeral ? Number(implGeral) : undefined,
proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined, proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined,
implPaymentCondition: implPaymentCondition || undefined, implPaymentCondition: implPaymentCondition || undefined,
@@ -194,11 +262,14 @@ export function QuoteFormPage() {
</Alert> </Alert>
) : null} ) : null}
<Card>
{!resellers || !products ? ( {!resellers || !products ? (
<Card>
<p className="text-sm text-default">Carregando revendas e produtos…</p> <p className="text-sm text-default">Carregando revendas e produtos…</p>
</Card>
) : ( ) : (
<form className="space-y-8" onSubmit={handleSubmit}> <form className="space-y-6" onSubmit={handleSubmit}>
<Card>
<div className="space-y-8">
<div> <div>
<h2 className="mb-4 text-sm font-semibold text-title">Dados do cliente</h2> <h2 className="mb-4 text-sm font-semibold text-title">Dados do cliente</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -210,21 +281,36 @@ export function QuoteFormPage() {
onChange={(e) => setResellerId(e.target.value)} onChange={(e) => setResellerId(e.target.value)}
required required
/> />
<Input label="Nome do cliente" value={clientName} onChange={(e) => setClientName(e.target.value)} required /> <Input
label="Nome do cliente"
value={clientName}
onChange={(e) => setClientName(e.target.value)}
required
/>
<Input label="Empresa" value={clientCompany} onChange={(e) => setClientCompany(e.target.value)} /> <Input label="Empresa" value={clientCompany} onChange={(e) => setClientCompany(e.target.value)} />
<Input label="E-mail" type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} /> <Input
<Input label="Telefone" value={clientPhone} onChange={(e) => setClientPhone(e.target.value)} required /> label="E-mail"
type="email"
value={clientEmail}
onChange={(e) => setClientEmail(e.target.value)}
/>
<Input
label="Telefone"
value={clientPhone}
onChange={(e) => setClientPhone(e.target.value)}
required
/>
</div> </div>
</div> </div>
<div> <div>
<h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2> <h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Select <Select
label="Período de contrato" label="Período de contrato"
options={CONTRACT_PERIODS.map((period) => ({ options={CONTRACT_PERIODS.map((period) => ({
value: String(period), value: String(period),
label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, label: contractPeriodLabel(period),
}))} }))}
value={contractPeriod} value={contractPeriod}
onChange={(e) => setContractPeriod(e.target.value)} onChange={(e) => setContractPeriod(e.target.value)}
@@ -238,76 +324,118 @@ export function QuoteFormPage() {
onChange={(e) => setFidelityPeriod(e.target.value)} onChange={(e) => setFidelityPeriod(e.target.value)}
hint="Só preencha se for menor que o período do contrato — entra em aprovação" hint="Só preencha se for menor que o período do contrato — entra em aprovação"
/> />
<Input
label="Responsável comercial"
value={salesRepName}
onChange={(e) => setSalesRepName(e.target.value)}
hint='Impresso no PDF — cai para "Responsável Comercial" quando ausente.'
/>
</div> </div>
</div> </div>
</div>
</Card>
<div> <div className="grid grid-cols-1 gap-6 lg:grid-cols-5 lg:items-start">
<div className="mb-4 flex items-center justify-between"> <div className="lg:col-span-3">
<h2 className="text-sm font-semibold text-title">Itens</h2> <Card title="Adicionar Produtos" bodyClassName="p-0">
<button <div className="flex flex-col gap-3 border-b border-border-color p-4 sm:flex-row sm:items-center">
type="button" <div className="flex-1">
onClick={addItem} <Input
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light" placeholder="Buscar por código ou nome..."
> value={productSearch}
+ Adicionar item onChange={(e) => setProductSearch(e.target.value)}
</button> aria-label="Buscar produto"
/>
</div> </div>
<div className="overflow-x-auto"> <div className="flex items-center gap-2">
<table className="w-full text-sm"> <span className="whitespace-nowrap text-xs font-semibold text-default">Ordenar por</span>
<thead>
<tr className="border-b border-border-color text-left">
<th className="py-2 pe-2 font-semibold text-title">Produto</th>
<th className="py-2 pe-2 font-semibold text-title">Quantidade</th>
<th className="py-2 pe-2" />
</tr>
</thead>
<tbody>
{items.map((row, index) => (
<tr key={index} className="border-b border-border-color last:border-0">
<td className="py-2 pe-2 w-2/3">
<Select <Select
options={productOptions} options={[
placeholder="Selecione o produto" { value: "code", label: "Código" },
value={row.productId} { value: "name", label: "Nome (A-Z)" },
onChange={(e) => updateItem(index, { productId: e.target.value })} ]}
aria-label="Produto" value={productSort}
required onChange={(e) => setProductSort(e.target.value as ProductSort)}
aria-label="Ordenar produtos"
className="min-w-36"
/> />
</td>
<td className="py-2 pe-2">
<Input
type="number"
min={0.01}
step={0.01}
value={row.quantity}
onChange={(e) => updateItem(index, { quantity: e.target.value })}
aria-label="Quantidade"
required
/>
</td>
<td className="py-2 pe-2 text-center">
{items.length > 1 ? (
<button
type="button"
onClick={() => removeItem(index)}
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
>
Remover
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> </div>
<div className="max-h-[560px] overflow-y-auto">
{filteredProducts.length === 0 ? (
<p className="p-5 text-sm text-default">
{browsableProducts.length === 0
? "Nenhum produto ativo disponível."
: "Nenhum produto encontrado para essa busca."}
</p>
) : (
<ul className="divide-y divide-border-color">
{filteredProducts.map((product) => (
<ProductRow
key={product.id}
product={product}
contractPeriod={contractPeriodNum}
onAdd={() => addToCart(product.id)}
/>
))}
</ul>
)}
</div>
</Card>
</div>
<div className="lg:sticky lg:top-6 lg:col-span-2 lg:self-start">
<Card title="Resumo da Oferta">
<div className="space-y-6">
<div> <div>
<h2 className="mb-4 text-sm font-semibold text-title">Implantação e valores</h2> {cartLines.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <p className="rounded-md border border-dashed border-border-color p-4 text-center text-sm text-default">
Nenhum produto adicionado ainda. Clique em um produto à esquerda para começar.
</p>
) : (
<ul className="space-y-3">
{cartLines.map((line) => (
<CartItemCard
key={line.product.id}
productName={line.product.name}
quantity={line.quantity}
unitPrice={line.unitPrice}
subtotal={line.subtotal}
onIncrement={() => setQuantity(line.product.id, line.quantity + 1)}
onDecrement={() => setQuantity(line.product.id, line.quantity - 1)}
onRemove={() => removeFromCart(line.product.id)}
/>
))}
</ul>
)}
</div>
{cartLines.length > 0 ? (
<div className="rounded-md border border-border-color p-3">
<p className="mb-2 text-xs font-semibold text-title">Implantação (por produto × qtd)</p>
<ul className="space-y-1">
{cartLines.map((line) => (
<li key={line.product.id} className="flex items-center justify-between text-xs text-default">
<span className="truncate pe-2">
{line.product.name} × {line.quantity}
</span>
<span className="whitespace-nowrap font-semibold text-title">
{formatCurrencyBRL(line.implSubtotal)}
</span>
</li>
))}
</ul>
<div className="mt-2 flex items-center justify-between border-t border-border-color pt-2 text-xs font-semibold text-title">
<span>Total implantação de produtos</span>
<span>{formatCurrencyBRL(implFromProducts)}</span>
</div>
</div>
) : null}
<div className="space-y-4">
<Input <Input
label="Implantação geral (opcional)" label="Implantação Geral do Projeto (R$)"
type="number" type="number"
min={0} min={0}
step={0.01} step={0.01}
@@ -315,18 +443,9 @@ export function QuoteFormPage() {
onChange={(e) => setImplGeral(e.target.value)} onChange={(e) => setImplGeral(e.target.value)}
hint="Custo de implantação além dos produtos" hint="Custo de implantação além dos produtos"
/> />
<Input
label="Mensalidade proposta (opcional)"
type="number"
min={0.01}
step={0.01}
value={proposedMonthlyTotal}
onChange={(e) => setProposedMonthlyTotal(e.target.value)}
hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação"
/>
<div> <div>
<Select <Select
label="Condição de pagamento da implantação" label="Condição de Pagamento da Implantação"
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({ options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
value: condition, value: condition,
label: condition, label: condition,
@@ -336,7 +455,7 @@ export function QuoteFormPage() {
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
/> />
<p className="mt-1 text-xs text-default"> <p className="mt-1 text-xs text-default">
Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} Faixas sugeridas para {formatCurrencyBRL(totalImpl)} de implantação estimada:{" "}
{availablePaymentConditions.map((c) => ( {availablePaymentConditions.map((c) => (
<Badge key={c} variant="neutral" className="ms-1"> <Badge key={c} variant="neutral" className="ms-1">
{c} {c}
@@ -346,17 +465,35 @@ export function QuoteFormPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2 rounded-md border border-border-color p-4">
<TotalRow label="Total Mensal" value={formatCurrencyBRL(monthlyTotal)} />
<TotalRow label="Impl. Produtos" value={formatCurrencyBRL(implFromProducts)} />
<TotalRow label="Total Implantação" value={formatCurrencyBRL(totalImpl)} />
<TotalRow label="Duração (faixa de preço)" value={contractPeriodLabel(contractPeriodNum)} />
<div className="mt-2 flex items-center justify-between rounded-md bg-primary-transparent px-3 py-3">
<span className="text-sm font-bold text-primary-900">Total do Contrato</span>
<span className="text-lg font-extrabold text-primary-900">
{formatCurrencyBRL(contractTotal)}
</span>
</div>
</div> </div>
<div>
<Input <Input
label="Responsável comercial" label="Condição Especial — Valor Mensal Final (R$)"
value={salesRepName} type="number"
onChange={(e) => setSalesRepName(e.target.value)} min={0.01}
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.' step={0.01}
value={proposedMonthlyTotal}
onChange={(e) => setProposedMonthlyTotal(e.target.value)}
hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação"
/> />
<p className="mt-1 text-xs text-default">Tabela: {formatCurrencyBRL(monthlyTotal)}</p>
</div>
<div> <div>
<label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label> <label className="mb-1 block text-sm font-semibold text-gray-900">Observações</label>
<textarea <textarea
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30" className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
rows={4} rows={4}
@@ -364,8 +501,13 @@ export function QuoteFormPage() {
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</div>
</Card>
</div>
</div>
<div className="flex items-center gap-2 border-t border-border-color pt-5"> <Card>
<div className="flex items-center gap-2">
<Button type="submit" loading={submitting}> <Button type="submit" loading={submitting}>
Criar oferta Criar oferta
</Button> </Button>
@@ -373,13 +515,143 @@ export function QuoteFormPage() {
Cancelar Cancelar
</Button> </Button>
</div> </div>
</Card>
</form> </form>
)} )}
</Card>
</div> </div>
); );
} }
function ProductRow({
product,
contractPeriod,
onAdd,
}: {
product: Product;
contractPeriod: number;
onAdd: () => void;
}) {
const price = priceForPeriod(product, contractPeriod);
return (
<li>
<button
type="button"
onClick={price != null ? onAdd : undefined}
disabled={price == null}
className="flex w-full items-start justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-light disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-3">
<Badge variant="neutral" className="mt-0.5 shrink-0">
#{product.product_code}
</Badge>
<div className="min-w-0">
<p className="truncate text-sm font-bold text-title">{product.name}</p>
{product.description ? (
<p className="truncate text-xs text-default">{product.description}</p>
) : null}
</div>
</div>
<div className="shrink-0 text-right">
{price != null ? (
<>
<p className="text-sm font-bold text-secondary">{formatCurrencyBRL(price)}/mês</p>
<Badge variant="secondary" className="mt-1">
plano {contractPeriod}m
</Badge>
</>
) : (
<Badge variant="warning">indisponível p/ este período</Badge>
)}
</div>
</button>
</li>
);
}
function CartItemCard({
productName,
quantity,
unitPrice,
subtotal,
onIncrement,
onDecrement,
onRemove,
}: {
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
onIncrement: () => void;
onDecrement: () => void;
onRemove: () => void;
}) {
return (
<li className="rounded-md border border-border-color p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-title">{productName}</p>
<button
type="button"
onClick={onRemove}
aria-label={`Remover ${productName}`}
className="shrink-0 rounded-md p-1 text-default hover:bg-light hover:text-danger"
>
<TrashIcon />
</button>
</div>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<button
type="button"
onClick={onDecrement}
disabled={quantity <= 1}
aria-label="Diminuir quantidade"
className="flex size-7 items-center justify-center rounded-md border border-border-color text-sm font-bold text-title hover:bg-light disabled:cursor-not-allowed disabled:opacity-40"
>
−
</button>
<span className="w-8 text-center text-sm font-semibold text-title">{quantity}</span>
<button
type="button"
onClick={onIncrement}
aria-label="Aumentar quantidade"
className="flex size-7 items-center justify-center rounded-md border border-border-color text-sm font-bold text-title hover:bg-light"
>
+
</button>
</div>
<p className="text-xs text-default">
{formatCurrencyBRL(unitPrice)} × {quantity} ={" "}
<span className="font-bold text-title">{formatCurrencyBRL(subtotal)}</span>
</p>
</div>
</li>
);
}
function TotalRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-default">{label}</span>
<span className="font-semibold text-title">{value}</span>
</div>
);
}
function TrashIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" className="size-4">
<path
d="M3.5 5.5h13M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5M5.5 5.5v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-10"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SummaryField({ label, value }: { label: string; value: string }) { function SummaryField({ label, value }: { label: string; value: string }) {
return ( return (
<div className="rounded-md border border-border-color p-4"> <div className="rounded-md border border-border-color p-4">

View File

@@ -812,3 +812,199 @@ Data: 2026-09-18. Fecha exatamente a pendência registrada acima. Usuário: "vam
### Achado adicional, resolvido no caminho (não pedido, mas real) ### Achado adicional, resolvido no caminho (não pedido, mas real)
Usuário reportou "testei o cadastro de revenda e continua só com nome/documento/e-mail" — investigado ao vivo: a tela de edição de uma revenda existente já mostrava todas as seções corretamente (confirmado abrindo a revenda real "Handix" pela lista). O que ele via era a tela de **criação** (`/resellers/new`), que por design mesmo só mostra os 3 campos mínimos até a revenda ser salva (CNPJ/sócios/representante precisam de um ID existente) — mas sem nenhuma explicação disso na tela, parecendo um cadastro incompleto. Adicionado um aviso claro no formulário de criação: "Salve com o nome pra continuar o cadastro — busca de CNPJ, representante legal, sócios, endereço e contatos ficam disponíveis logo em seguida, na tela de edição desta revenda." Confirmado ao vivo via screenshot. Usuário reportou "testei o cadastro de revenda e continua só com nome/documento/e-mail" — investigado ao vivo: a tela de edição de uma revenda existente já mostrava todas as seções corretamente (confirmado abrindo a revenda real "Handix" pela lista). O que ele via era a tela de **criação** (`/resellers/new`), que por design mesmo só mostra os 3 campos mínimos até a revenda ser salva (CNPJ/sócios/representante precisam de um ID existente) — mas sem nenhuma explicação disso na tela, parecendo um cadastro incompleto. Adicionado um aviso claro no formulário de criação: "Salve com o nome pra continuar o cadastro — busca de CNPJ, representante legal, sócios, endereço e contatos ficam disponíveis logo em seguida, na tela de edição desta revenda." Confirmado ao vivo via screenshot.
---
## Nova oferta — catálogo navegável + resumo ao vivo, no padrão do legado OrçaFácil
Data: 2026-09-18. Usuário mandou uma captura de tela de como o legado montava essa tela (produtos navegáveis à esquerda, resumo se montando ao lado conforme adicionava) e um link de uma página de exemplo do tema DreamsERP como "só uma ideia" — na prática essa página do tema acabou sendo só inspiração de acabamento solta (é uma tabela de fatura genérica, não tem o padrão de catálogo+carrinho do legado). Pedido: "pode fazer mais legal".
`QuoteFormPage.tsx` (agente `eden-frontend`): a seção de itens, que era uma tabela chata de linhas com dropdown de produto (sem preço visível, sem busca, sem total em tempo real), virou duas colunas — **"Adicionar Produtos"** à esquerda (busca por código/nome, ordenar por Código/Nome, lista rolável com badge de código, preço da faixa do período de contrato selecionado, tag "plano Nm", clique adiciona ao carrinho) e **"Resumo da Oferta"** à direita, sticky (cartão por item com stepper de quantidade e remover, breakdown de implantação por item, os campos que já existiam — implantação geral, condição de pagamento, condição especial/valor mensal final, observações — todos migrados pra dentro deste painel, e totais calculados ao vivo com "Total do Contrato" em destaque). Fórmula do preview espelha `apps/api/src/quotes/pricing.ts::computeQuotePricing` exatamente — mesma disciplina já documentada no próprio arquivo desde que foi criado: todo total mostrado aqui é ilustrativo, o servidor recalcula tudo ao salvar, nada muda no backend.
**Achado real do próprio agente**: produtos sem preço cadastrado pra faixa do período de contrato selecionado (existem de verdade nos dados seedados) precisavam de tratamento — aparecem na lista como "indisponível p/ este período", não clicáveis, em vez de quebrar ou mostrar "R$ null/mês".
**Limpeza feita por mim depois**: a lista de produtos navegável tornou a poluição de dados de teste (`qa-test-*`, acumulada a sessão inteira nas suítes de `product-catalogs`) muito mais visível do que era como dropdown — 173 de 177 produtos ativos eram lixo de teste. Desativados (mesma disciplina de higiene já seguida o resto da sessão), junto com uma nova rodada de limpeza nos catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade (mais rodadas de suíte desde a última limpeza tinham acumulado mais linhas). Sobrou 1 produto real ativo ("Link Dedicado 100Mbps").
### Verificação
`pnpm turbo run typecheck build` 28/28. Suíte 113/113 (inalterada — mudança é só de frontend). Testado ao vivo pelo agente: busca/ordenação filtrando corretamente, período de contrato trocando o preço mostrado e o badge de plano, stepper incrementando/decrementando (desabilita em 1), totais batendo à mão com a fórmula pra vários produtos/quantidades, duas ofertas completas criadas de ponta a ponta com o total final batendo exatamente com o que o servidor calculou (uma delas disparando aprovação por desconto, confirmado o banner "aguarda aprovação"). Confirmado por mim depois, com screenshot, com o catálogo já limpo.
### Achado não resolvido (fora de escopo, registrado pelo agente)
Na tela de sucesso pós-criação, o campo "Implantação total" mostra só `implementation_fee` (implantação dos produtos), sem somar `impl_geral` — quando esse campo é preenchido, o rótulo sub-representa o valor que efetivamente compõe o "Total do contrato". Não é um bug introduzido por esta leva, é pré-existente; não corrigido por estar fora do escopo pedido (só a tela de montagem da oferta).
---
## Pré-visualização em PDF do rascunho de Documentos (Chromium)
Data: 2026-09-18. Usuário pediu pra ajustar a tela de Contratos, mas esclareceu que era na verdade a de Documentos ("templates de contrato e outros documentos"), "aonde teria que rodar com o chromium pra eu poder editar os arquivos com as variáveis". Implementado diretamente (contido, reaproveita 100% da infraestrutura de Chromium já existente, sem tocar em schema).
**Contexto investigado antes de implementar**: o editor de rascunho (`TemplateDetailPage.tsx`) é uma `<textarea>` simples com placeholders `{{variavel}}` — sem preview nenhum do rascunho em si (só a versão JÁ PUBLICADA tinha uma prévia, em HTML cru renderizado no navegador, nunca via Chromium). Publicar é uma ação **permanente** (a versão não pode mais ser editada depois) — ou seja, não existia nenhuma forma de validar visualmente o resultado antes de um passo irreversível.
**Achado real, corrigido no caminho**: `createGeneration` (a geração REAL de documento, usada em produção) nunca passava `css_content` pro `renderPdf` — o campo "CSS (avançado, opcional)" do editor existia, era salvo, mas **era descartado silenciosamente na hora de gerar o PDF final**. Corrigido (`documents.service.ts`), coberto por teste novo.
### Backend
`POST /v1/documents/templates/:id/draft/preview` — recebe o `editorContent`/`cssContent` **atual do editor, ainda não salvo** (não busca o rascunho persistido — permite iterar editar→pré-visualizar→editar sem precisar salvar a cada volta), gera o HTML da mesma forma que o rascunho real (`renderEditorContentToHtml`, reaproveitado), extrai toda `{{variavel}}` encontrada (reaproveitando `mergeVariables` com contexto vazio — tudo que "falta" É a lista completa de variáveis), substitui cada uma por `[variavel]` (não existe cliente/revenda selecionado nesta tela pra ter um valor real), renderiza via `renderPdf` (Chromium real) com o CSS aplicado, e devolve o PDF direto — **nunca persiste nada** (sem `document_generations`, sem upload S3), é puramente descartável.
### Frontend
Novo botão "Pré-visualizar (PDF)" ao lado de "Salvar rascunho". **Decisão de UX importante, mudada no meio do caminho por um achado real**: a primeira versão abria o PDF numa aba nova (`window.open`) — testado ao vivo e descoberto que, mesmo abrindo a aba de forma síncrona (técnica padrão pra não ser bloqueada por pop-up blocker) e só navegando ela depois que o PDF ficasse pronto, o navegador às vezes ignora essa navegação tardia silenciosamente: a "janela de gesto do usuário" que autoriza a navegação de uma aba já aberta expira rápido demais pra uma renderização Chromium real no servidor (que não é instantânea). Confirmado isolando o mecanismo: um blob sintético em memória (sem rede) navegava a aba certinho; o mesmo código com uma chamada de rede real pro endpoint de preview não navegava. Resolvido de forma mais robusta: em vez de nova aba, o PDF aparece **embutido na própria tela** (`<iframe>` logo abaixo dos botões, com blob URL) — elimina esse problema por completo, e nem exige contexto de navegação nova.
### Verificação
`pnpm turbo run typecheck build` 28/28. Suíte 115/115 (113→115: 1 teste cobrindo o preview de rascunho — variáveis viram placeholder, CSS aplicado, nada persiste —, 1 confirmando que `css_content` agora chega no `renderPdf` real da geração). Testado ao vivo contra Chromium de verdade (não mockado): PDF real de 1 página gerado sem nunca ter salvo um rascunho, texto extraído via `pdftotext` confirmando "Prezado(a) [nome_cliente], seu plano é [plano]." e a formatação CSS (cor/tamanho de fonte) presente no stream do PDF. A renderização visual do PDF dentro do iframe não pôde ser confirmada por screenshot (visualizador nativo de PDF do Chromium não aparece em capturas de tela no modo headless do Playwright — mesma limitação de teste automatizado já registrada antes nesta sessão), mas o mecanismo (blob correto, `src` do iframe apontando pro blob certo, bytes do PDF válidos e corretos) foi confirmado de forma independente.
---
## Editor de texto rico para Templates de Documentos (Tiptap)
Data: 2026-09-19. Usuário mandou o print do editor de "Contrato de Software" do OrçaFácil legado
(`/home/handix/editortexto.png`) como referência de como queria a tela de edição de rascunho de
Documentos: header com título/badge de versão + ações, tabs "Editor" / "Visualizar", toolbar rica
(parágrafo/fonte, negrito/itálico/sublinhado/tachado, alinhamento, listas, tabela, imagem, quebra de
página), variáveis inseridas como **chip inline** (nunca `{{texto livre}}`), painel "Variáveis usadas
neste modelo" (com "obrigatório") e "Histórico de versões". Escolheu ir direto pro nível completo.
Até aqui o "editor" era um `<textarea>` puro com `{{variavel}}` digitado à mão — um stand-in documentado
no próprio código/migration (`editor-content-renderer.ts`/`merge-engine.ts`), esperando o "editor visual
real" que essa leva implementa. O banco (`document_template_versions.editor_content` JSONB) já estava
desenhado pra uma árvore Tiptap real (`EMPTY_DOC_CONTENT` já nascia `{type:'doc', content:[...]}`) — sem
migration nova.
Achado o código-fonte real do legado em `.tmp/orcafacil-reference/server/src/lib/documentTemplates/`
(`renderEditorContent.js` + `variableRegistry.js`) — portados fielmente em vez de reinventados.
**Fora de escopo** (documentado de propósito, pra não estourar o pedido — a tela do editor, não o motor de
geração inteiro): nó `conditional` e repetição de linha de tabela (`tableRow.repeat`); bloqueio HTTP 422
por variável obrigatória ausente (mantém o comportamento permissivo já existente — o badge "obrigatório" é
só informativo); auto-resolução de `cliente.*`/`oferta.*`/`contrato.*` a partir do banco na hora de GERAR
o documento (`GenerationFormPage.tsx` continua pedindo cada valor manualmente, como já era); allowlist
completa de sanitização de HTML; cabeçalho/rodapé de PDF.
### Backend
`editor-content-renderer.ts` reescrito: `renderToHtml(doc, resolveMergeField?)` + `collectMergeFieldKeys`
cobrindo paragraph/heading/bulletList/orderedList/listItem/image/pageBreak/table+thead-real/marks
bold-italic-underline-strike-fontSize/mergeField (chip visual sem `resolveMergeField`, valor resolvido
com). `merge-engine.ts` muda de contrato: em vez de regex sobre `html_content` cacheado, `resolveDocument
(editorContent, context)` resolve a árvore direto — mais fiel ao legado e mais simples. Novo
`variable-catalog.ts` com o catálogo fechado de variáveis (portado do legado, só `scope: 'document'`) +
`GET /v1/documents/variable-catalog`. Novo teste unitário puro `editor-content-renderer.spec.ts` (8 casos,
finalmente cumprindo o que o docblock do merge-engine prometia desde uma leva anterior e nunca existiu).
### Frontend
Tiptap (`@tiptap/react` + extensões table/image/text-align/text-style/underline, `^3.29.2`, mesma major do
legado) adicionado ao core-web. Nó customizado `mergeField` (chip visual via NodeView React) e `pageBreak`
(régua tracejada). `EditorToolbar.tsx` fiel ao print + `VariablePickerMenu.tsx` (menu por categoria, sem
componente de dropdown pronto em `packages/ui` — construído ali mesmo). `TemplateDetailPage.tsx`: quando
há rascunho aberto, a tela vira o layout do print (header com título/badge/ações, tabs "Editor / com
variáveis" / "Pré-visualização (PDF)" — nome ajustado do print pra não prometer dado real de
cliente/revenda, já que só mostra `[chave]" —, painel "Variáveis usadas neste modelo" com badge obrigatório
ao vivo, "Histórico de versões"). `GenerationFormPage.tsx` ajustado pra extrair variáveis da árvore real
em vez de regex sobre `plainText` (que deixou de existir).
### Verificação
`pnpm turbo run typecheck build` 28/28. Suíte 123/123 (115→123: 8 novos do renderer). Testado ao vivo
(Playwright + curl): criado template novo, aberto o editor, digitado texto com negrito, inseridas duas
variáveis (`cliente.razao_social` obrigatória, `cliente.cnpj` obrigatória) via o menu por categoria —
confirmado que o painel "Variáveis usadas" atualiza em tempo real com checkmark + badge "obrigatório".
Salvo o rascunho e confirmado via `GET /documents/templates/:id` que `editor_content` persistiu a árvore
Tiptap real (mark `bold` + dois nós `mergeField` com `key`/`label` corretos) e `html_content` já teria o
`<strong>`/chip visual certos. Pré-visualização em PDF gerada ao vivo contra Chromium real (não mockado) e
confirmada via `pdftotext`: `"Prezado(a) cliente, segue o contrato. [cliente.razao_social][cliente.cnpj]"`.
Publicado o rascunho e confirmado que a versão publicada aparece corretamente na tela de overview (chip
"Razão Social"/"CNPJ/CPF" renderizado com o estilo `.merge-field-chip` aplicado). Template de teste
(`qa_test_editor_*`) desativado ao final via `UPDATE document_templates SET active=false` (sem endpoint de
exclusão, mesmo padrão de higiene de dados de teste já seguido nesta sessão).
---
## Importação dos 7 templates reais do legado + suporte a `conditional`
Data: 2026-09-19. Usuário forneceu `/home/handix/document_templates_export.zip` — export real dos 7
templates de documento do OrçaFácil legado (Contrato de Software, Termo de Cessão, Termo de Portabilidade,
Termo de Contratação STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo de Adesão), cada um com
seu `editor_content` (árvore Tiptap real, não simplificada) e as 4 extensões Tiptap customizadas do
próprio editor legado (`mergeField`, `pageBreak`, `conditional`, `tableRow.repeat`). Pediu pra trazer esses
templates pro EDEN "pra ver como ficou" — validação de ponta a ponta do editor rico construído na leva
anterior contra conteúdo de produção real, não sintético.
**Achado real**: dos 7 templates, 6 usam só o que já era suportado (paragraph/table/mergeField/bold/
textAlign/colspan); o Termo de Contratação STFC usa também o nó `conditional` (envelope em volta de uma
`tableRow`, usado nas linhas de franquia de minutos/0800) — não suportado na leva anterior. Sem essa
extensão, o **editor renderizava em branco** pra esse documento específico — exatamente o mesmo bug já
documentado (e corrigido) no próprio export do legado (`ConditionalRow.jsx`, comentário no README do zip).
Nenhum dos 7 usa `tableRow.attrs.repeat` — não precisou ser implementado.
### Backend
`editor-content-renderer.ts`: novo `case "conditional"` em `renderBlock` — sempre renderiza a linha
(avaliação real da condição contra contexto cru é trabalho futuro, fora de escopo, igual já documentado
pro nó em si). `collectMergeFieldKeys` já enxergava por dentro de qualquer node type genericamente, não
precisou mudar. `variable-catalog.ts`: adicionadas as ~25 entradas que faltavam (tabela de tarifação STFC
por banda/normal-reduzida, 0800, `comercializacao_inicio/fim`, `contrato.prazo_instalacao`,
`portabilidade.titular_cpf_cnpj_doadora`) — descobertas comparando as chaves realmente usadas nos 7
templates contra o catálogo já portado, todas historicamente do `variableRegistry.js` original.
### Frontend
Novo `conditionalRowExtension.ts` (porta de `ConditionalRow.jsx` do export) + `Table.extend({ content:
"(tableRow|conditional)+" })` em `DocumentEditor.tsx` — sem essa extensão de schema o Tiptap rejeita
qualquer documento com `conditional` como filho direto de `table`, mesmo bug do parágrafo acima.
### Importação
Script único (`scratchpad`, não versionado — é carga de dados, não feature) reaproveitando os endpoints já
existentes (create template → create draft → update draft com o `editor_content` real → publish) pra cada
um dos 7 JSONs, exatamente como um humano faria pela UI — nenhum endpoint de "importar" novo foi criado.
### Verificação
`pnpm turbo run typecheck build` 28/28. Suíte 125/125 (123→125: 2 novos casos de `conditional`). Os 7
templates reais importados e publicados com sucesso contra o ambiente docker local. Testado ao vivo: tela
de overview do Termo de Contratação STFC renderiza o contrato completo (3 páginas) com a tabela de
tarifação (LC/LDN/VC1/VC2/VC3/LDI × Normal/Reduzido) e as linhas condicionais de franquia/0800 no lugar
certo; o editor rico abre o MESMO documento sem tela em branco, com as 53 variáveis usadas listadas
corretamente no painel lateral (com badges "obrigatório"). Contrato de Software (o maior, 162KB de JSON)
também verificado: abre no editor sem erro de console/página, gera pré-visualização em PDF real sem erro.
PDF do STFC gerado via curl direto (Chromium real) e conferido via `pdftotext`: 3 páginas, texto
`[oferta.franquia_minutos]`/`[oferta.inclui_0800]`/`[oferta.numero_0800]`/tarifas em `[chave]` no lugar
certo do documento — confirma que o `conditional` nunca é removido, só passa adiante sem avaliação real
(comportamento documentado, não um bug).
### Achado colateral, não corrigido (fora de escopo)
A tela de listagem de templates estava com ~145 registros `qa_test_*` acumulados de suítes de teste
anteriores desta sessão — desativados via `UPDATE document_templates SET active=false` (mesmo padrão já
usado antes). Restaram ~9 templates de teste manual mais antigos (`client_test_contract`, `live_test_*`,
`smoke_*`, `teste_web_final` etc.) que não seguem o prefixo `qa_test_` automatizado — não mexidos, por
poderem ser exploração manual do próprio usuário.
---
## Os 7 templates viram parte do código (migration de seed)
Data: 2026-09-19. Usuário pediu pra embutir os 7 templates importados na leva anterior no próprio código,
pra que uma máquina nova (VM de pré-produção) já nasça com eles, sem precisar rodar o script de importação
manualmente de novo.
Padrão já usado neste repo pra dado de catálogo/referência é seed **dentro de uma migration** (`INSERT ...
ON CONFLICT DO NOTHING`, ver `1788457368744_fiscal.cjs`/`fiscal_operation_nature` e
`1788430575858_baseline-schema.cjs`/`applications`/`roles`) — não um serviço à parte. Nova migration
`packages/database/migrations/1789862600000_seed-document-templates.cjs` segue o mesmo padrão, com os 7
JSONs (key/name/doc_group/editor_content/html_content/css_content) copiados em
`document-template-seeds/*.json` ao lado dela. Idempotente por `key`: nunca sobrescreve um template já
existente (ex.: se alguém já editou manualmente).
Único `up`/`down` assíncrono desta pasta de migrations (as outras usam `pgm.sql` síncrono) — precisa do
`id` gerado pelo INSERT do template antes de inserir a versão, e do `id` da versão antes de apontar
`current_version_id` de volta, então usa `pgm.db.query` parametrizado em vez de `pgm.sql`.
**Achado real, corrigido no caminho**: o `html_content` do export original vinha **vazio** pra 4 dos 7
templates (`client_cession_term`/`client_portability_term`/`client_software_contract`/`client_stfc_term`)
— só os 3 `reseller_*` tinham. Não afeta geração real nem o editor (ambos leem `editor_content` direto),
só deixaria o card "Preview da versão publicada" vazio pra esses 4 numa instalação nova. Corrigido
recalculando o `html_content` desses 4 offline com o próprio `renderToHtml` (o mesmo já testado nesta
sessão, via `apps/api/dist/documents/editor-content-renderer.js` compilado) antes de gravar os JSONs de
seed — não precisou mudar nada no motor em si.
### Verificação
Testado contra banco **totalmente vazio** (não só o de desenvolvimento, que já tinha os 7 da importação
manual anterior): criado `eden_migration_test` no mesmo Postgres, rodado `node-pg-migrate up` do zero
(todas as migrations, não só esta) — os 7 templates nasceram publicados, com `editor_content` e
`html_content` corretos (conferido via query direta: `jsonb_array_length`/`length(html_content)` > 0 para
os 7). Banco de teste apagado depois. `pnpm --filter @eden/database typecheck` e suíte da API
125/125 sem regressão. Runbook (`docs/runbooks/setup-vm-pre-producao.md`) atualizado avisando que o passo
de migrations já traz os templates.

View File

@@ -0,0 +1,92 @@
# Setup de uma nova VM (pré-produção)
Passo a passo mínimo para subir o EDEN do zero numa VM nova. Ver `docs/adr/0002-container-topology.md`
para o porquê da topologia (Postgres isolado, 1 container por app).
## 1. Requisitos da VM
- Linux (Debian/Ubuntu), 4 vCPU / 8GB RAM como piso razoável (Chromium headless para PDF consome memória).
- Docker Engine + plugin `docker compose` instalados.
- Git.
- Portas liberadas no firewall: só as que forem expostas publicamente (ver passo 5) — nunca a porta do
Postgres.
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # relogar depois disso
```
## 2. Clonar o repositório
```bash
git clone https://git.falehandix.com.br/Matheus/eden.git /opt/eden
cd /opt/eden
git checkout main # pré-produção valida o que já foi mergeado — não a branch developer
```
## 3. Configurar o `.env`
```bash
cp .env.example .env
```
Preencher no `.env` (nunca commitar este arquivo):
- `EDEN_DATABASE_PASSWORD` / ajustar `DATABASE_URL` com a mesma senha.
- `JWT_SIGNING_SECRET` — `openssl rand -hex 48` (segredo próprio desta VM, nunca reaproveitar o de outro ambiente).
- `SIGNATURE_OTP_SECRET` — `openssl rand -hex 32`.
- `EDEN_FIELD_ENCRYPTION_KEY` — `openssl rand -hex 32`.
- `EDEN_SUPERADMIN_EMAIL` / `EDEN_SUPERADMIN_PASSWORD` — só usado para criar o primeiro usuário se a
tabela `users` estiver vazia.
- `S3_*` — credenciais do MinIO bundled (`openssl rand -hex 12`/`openssl rand -hex 24` para user/senha) ou
de um S3 real.
- `SMTP_*` — se já tiver um provedor de e-mail pra este ambiente.
- `PUBLIC_BASE_URL` — domínio público desta VM (usado em links de e-mail).
- Portas (`EDEN_API_PORT`/`EDEN_CORE_PORT`/`EDEN_PARCEIROS_PORT`/`EDEN_ASSINANTE_PORT`) — só mudar se for
rodar mais de uma instância na mesma máquina.
## 4. Subir os containers
```bash
docker compose --env-file .env up -d
```
Sobe Postgres, MinIO, API, worker e as 3 apps web. Cada serviço tem healthcheck — conferir com:
```bash
docker compose --env-file .env ps
```
## 5. Rodar as migrations
A imagem do `eden-api` carrega o monorepo inteiro (ver `infra/docker/Dockerfile.node`), então dá pra
rodar direto de dentro do container — `DATABASE_URL` já está no ambiente dele:
```bash
docker compose --env-file .env exec eden-api pnpm --filter @eden/database exec node-pg-migrate up
```
Isso já cria e publica os 7 templates de documento reais (Contrato de Software, Termo de Cessão, Termo
de Portabilidade, Termo de Contratação STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo de
Adesão) — vêm junto com as migrations (`1789862600000_seed-document-templates.cjs`), não precisa reimportar
manualmente numa VM nova.
(Ou, se tiver Node 22 + pnpm instalados na própria VM, fora de Docker: `pnpm install && pnpm db:migrate`,
com `DATABASE_URL` apontando pra porta mapeada do Postgres no host.)
## 6. Confirmar o primeiro acesso
```bash
curl -X POST http://localhost:${EDEN_API_PORT:-8080}/api/v1/auth/login \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EDEN_SUPERADMIN_EMAIL\",\"password\":\"$EDEN_SUPERADMIN_PASSWORD\",\"applicationKey\":\"core\"}"
```
Deve devolver um `accessToken`. Depois logar normalmente em `http://<host>:${EDEN_CORE_PORT}`.
## 7. Proxy reverso / domínio (se for expor publicamente)
- Cada app tem um domínio próprio (core/parceiros/assinante); a API **não** tem domínio público — o proxy
na frente de cada domínio deve rotear `/api/*` internamente para `eden-api:8080` na rede docker
(`eden_net`), nunca expor a API direto.
- Nunca expor a porta do Postgres (`EDEN_POSTGRES_DEV_PORT`) fora da rede interna em pré-produção.

View File

@@ -0,0 +1,89 @@
/**
* Semeia os 7 templates reais de documento herdados do OrçaFácil legado
* (Contrato de Software, Termo de Cessão, Termo de Portabilidade, Termo de
* Contratação STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo
* de Adesão) — eden.md §1.1: "Registros existentes hoje (query real ao
* banco): 7 templates". A migration `1788468521075_documents.cjs` que criou
* o schema deixou essas 7 linhas explicitamente de fora ("Handix production
* data to be migrada later, not schema") — esta migration é esse "depois":
* assim toda máquina nova (`pnpm db:migrate` / `docker compose ... exec
* eden-api pnpm --filter @eden/database exec node-pg-migrate up`, ver
* docs/runbooks/setup-vm-pre-producao.md) já nasce com os 7 templates
* publicados, sem precisar reimportar manualmente.
*
* `editor_content`/`html_content` de cada um vêm de
* `document-template-seeds/<key>.json` ao lado deste arquivo — cópia
* exata da versão PUBLICADA fornecida pelo usuário (export do sistema
* legado, `/home/handix/document_templates_export.zip`), o mesmo conteúdo
* já testado ao vivo nesta sessão (renderiza no editor Tiptap, gera PDF via
* Chromium, inclusive o nó `conditional` do Termo de Contratação STFC).
* `html_content` já vem pré-renderizado (não precisa rodar
* `renderEditorContentToHtml` aqui — este pacote não pode depender do
* código do apps/api).
*
* Idempotente via `ON CONFLICT (key) DO NOTHING` (mesmo padrão de
* `fiscal_operation_nature`/`applications`/`roles` — ver
* 1788457368744_fiscal.cjs e 1788430575858_baseline-schema.cjs): se o
* template já existe (por exemplo, alguém já editou manualmente uma versão
* mais nova), esta migration NUNCA sobrescreve — só cria o que falta.
*
* Único par de `up`/`down` assíncrono desta pasta (as outras migrations
* usam `pgm.sql` síncrono) — precisa do `id` gerado pelo INSERT do
* template antes de inserir a versão (e o `id` da versão antes de apontar
* `current_version_id` de volta), então usa `pgm.db.query` (parametrizado,
* `$1`/`$2`/...) em vez de `pgm.sql` (que só monta SQL, não devolve linhas
* pro código da migration decidir o próximo passo).
*/
const fs = require("fs");
const path = require("path");
const SEED_DIR = path.join(__dirname, "document-template-seeds");
const SEED_FILES = [
"client_software_contract.json",
"client_cession_term.json",
"client_portability_term.json",
"client_stfc_term.json",
"reseller_contract_finder.json",
"reseller_contract_recorrente.json",
"reseller_adhesion_term.json",
];
function loadSeeds() {
return SEED_FILES.map((file) => JSON.parse(fs.readFileSync(path.join(SEED_DIR, file), "utf8")));
}
exports.shorthands = undefined;
exports.up = async (pgm) => {
for (const seed of loadSeeds()) {
const { rows: templateRows } = await pgm.db.query(
`INSERT INTO document_templates (key, name, doc_group)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO NOTHING
RETURNING id`,
[seed.key, seed.name, seed.doc_group],
);
const template = templateRows[0];
if (!template) continue; // já existe — nunca sobrescreve.
const { rows: versionRows } = await pgm.db.query(
`INSERT INTO document_template_versions
(template_id, version, status, editor_content, html_content, css_content, page_config, published_at)
VALUES ($1, 1, 'published', $2::jsonb, $3, $4, '{}'::jsonb, now())
RETURNING id`,
[template.id, JSON.stringify(seed.editor_content), seed.html_content ?? "", seed.css_content ?? null],
);
const version = versionRows[0];
await pgm.db.query(`UPDATE document_templates SET current_version_id = $1 WHERE id = $2`, [
version.id,
template.id,
]);
}
};
exports.down = async (pgm) => {
const keys = loadSeeds().map((seed) => seed.key);
// ON DELETE CASCADE em document_template_versions.template_id cuida da versão junto.
await pgm.db.query(`DELETE FROM document_templates WHERE key = ANY($1::text[])`, [keys]);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

600
pnpm-lock.yaml generated
View File

@@ -120,6 +120,42 @@ importers:
'@eden/ui': '@eden/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
'@tiptap/core':
specifier: ^3.29.2
version: 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-image':
specifier: ^3.29.2
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-table':
specifier: ^3.29.2
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-table-cell':
specifier: ^3.29.2
version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-table-header':
specifier: ^3.29.2
version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-table-row':
specifier: ^3.29.2
version: 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-text-align':
specifier: ^3.29.2
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-text-style':
specifier: ^3.29.2
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-underline':
specifier: ^3.29.2
version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/pm':
specifier: ^3.29.2
version: 3.31.3
'@tiptap/react':
specifier: ^3.29.2
version: 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tiptap/starter-kit':
specifier: ^3.29.2
version: 3.31.3
react: react:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1 version: 18.3.1
@@ -971,6 +1007,15 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1465,6 +1510,192 @@ packages:
'@swc/types@0.1.28': '@swc/types@0.1.28':
resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
'@tiptap/core@3.31.3':
resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==}
peerDependencies:
'@tiptap/pm': 3.31.3
'@tiptap/extension-blockquote@3.31.3':
resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-bold@3.31.3':
resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-bubble-menu@3.31.3':
resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-bullet-list@3.31.3':
resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-code-block@3.31.3':
resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-code@3.31.3':
resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-document@3.31.3':
resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-dropcursor@3.31.3':
resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==}
peerDependencies:
'@tiptap/extensions': 3.31.3
'@tiptap/extension-floating-menu@3.31.3':
resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==}
peerDependencies:
'@floating-ui/dom': ^1.0.0
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-gapcursor@3.31.3':
resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==}
peerDependencies:
'@tiptap/extensions': 3.31.3
'@tiptap/extension-hard-break@3.31.3':
resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-heading@3.31.3':
resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-horizontal-rule@3.31.3':
resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-image@3.31.3':
resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-italic@3.31.3':
resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-link@3.31.3':
resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-list-item@3.31.3':
resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-list-keymap@3.31.3':
resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-list@3.31.3':
resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-ordered-list@3.31.3':
resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==}
peerDependencies:
'@tiptap/extension-list': 3.31.3
'@tiptap/extension-paragraph@3.31.3':
resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-strike@3.31.3':
resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-table-cell@3.31.3':
resolution: {integrity: sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==}
peerDependencies:
'@tiptap/extension-table': 3.31.3
'@tiptap/extension-table-header@3.31.3':
resolution: {integrity: sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==}
peerDependencies:
'@tiptap/extension-table': 3.31.3
'@tiptap/extension-table-row@3.31.3':
resolution: {integrity: sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==}
peerDependencies:
'@tiptap/extension-table': 3.31.3
'@tiptap/extension-table@3.31.3':
resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/extension-text-align@3.31.3':
resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-text-style@3.31.3':
resolution: {integrity: sha512-wgjWWrjZwRZHiaDTQPX2am1y/4ePgRgGWF/2MOfSb7g4d5229p4aVtbT1JT7wu9z8OC482pEqcTlhdvgftvW7A==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-text@3.31.3':
resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extension-underline@3.31.3':
resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/extensions@3.31.3':
resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@tiptap/pm@3.31.3':
resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==}
'@tiptap/react@3.31.3':
resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==}
peerDependencies:
'@tiptap/core': 3.31.3
'@tiptap/pm': 3.31.3
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
'@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
'@tiptap/starter-kit@3.31.3':
resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==}
'@tokenizer/inflate@0.2.7': '@tokenizer/inflate@0.2.7':
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1585,6 +1816,9 @@ packages:
'@types/supertest@6.0.3': '@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validator@13.15.10': '@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@@ -1995,6 +2229,10 @@ packages:
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
fast-equals@5.4.2:
resolution: {integrity: sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.3: fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
@@ -2213,6 +2451,9 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
load-tsconfig@0.2.5: load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2399,6 +2640,9 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
otplib@13.5.0: otplib@13.5.0:
resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==} resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==}
@@ -2581,6 +2825,45 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
prosemirror-changeset@2.4.3:
resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==}
prosemirror-commands@1.7.2:
resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==}
prosemirror-dropcursor@1.8.3:
resolution: {integrity: sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==}
prosemirror-gapcursor@1.4.1:
resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
prosemirror-history@1.5.0:
resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
prosemirror-inputrules@1.5.1:
resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
prosemirror-keymap@1.2.3:
resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
prosemirror-model@1.25.11:
resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==}
prosemirror-schema-list@1.5.1:
resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
prosemirror-state@1.4.4:
resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
prosemirror-tables@1.8.5:
resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
prosemirror-transform@1.12.1:
resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==}
prosemirror-view@1.42.4:
resolution: {integrity: sha512-H/LErnE8Vms1GYkvhfj6G3K9rc2p+o5EHGmTwvPOl0f21wPPxlMVRB8ICOseH+COb2oPKFEoufbgY6yet/dR4w==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2670,6 +2953,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2942,6 +3228,11 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
use-sync-external-store@1.7.0:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -3058,6 +3349,9 @@ packages:
jsdom: jsdom:
optional: true optional: true
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
webidl-conversions@3.0.1: webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -3636,6 +3930,20 @@ snapshots:
'@esbuild/win32-x64@0.28.2': '@esbuild/win32-x64@0.28.2':
optional: true optional: true
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
optional: true
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
optional: true
'@floating-ui/utils@0.2.12':
optional: true
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
@@ -3995,6 +4303,208 @@ snapshots:
dependencies: dependencies:
'@swc/counter': 0.1.3 '@swc/counter': 0.1.3
'@tiptap/core@3.31.3(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/pm': 3.31.3
'@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@floating-ui/dom': 1.8.0
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
optional: true
'@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@floating-ui/dom': 1.8.0
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
optional: true
'@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
linkifyjs: 4.3.3
'@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-table-cell@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-table-header@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-table-row@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-text-style@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tiptap/pm@3.31.3':
dependencies:
prosemirror-changeset: 2.4.3
prosemirror-commands: 1.7.2
prosemirror-dropcursor: 1.8.3
prosemirror-gapcursor: 1.4.1
prosemirror-history: 1.5.0
prosemirror-inputrules: 1.5.1
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.11
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.4
'@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@types/react': 18.3.31
'@types/react-dom': 18.3.7(@types/react@18.3.31)
'@types/use-sync-external-store': 0.0.6
fast-equals: 5.4.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
use-sync-external-store: 1.7.0(react@18.3.1)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
transitivePeerDependencies:
- '@floating-ui/dom'
'@tiptap/starter-kit@3.31.3':
dependencies:
'@tiptap/core': 3.31.3(@tiptap/pm@3.31.3)
'@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))
'@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))
'@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)
'@tiptap/pm': 3.31.3
'@tokenizer/inflate@0.2.7(supports-color@7.2.0)': '@tokenizer/inflate@0.2.7(supports-color@7.2.0)':
dependencies: dependencies:
debug: 4.4.3(supports-color@7.2.0) debug: 4.4.3(supports-color@7.2.0)
@@ -4135,6 +4645,8 @@ snapshots:
'@types/methods': 1.1.4 '@types/methods': 1.1.4
'@types/superagent': 8.1.11 '@types/superagent': 8.1.11
'@types/use-sync-external-store@0.0.6': {}
'@types/validator@13.15.10': {} '@types/validator@13.15.10': {}
'@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13))': '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13))':
@@ -4682,6 +5194,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
fast-equals@5.4.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -4904,6 +5418,8 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
linkifyjs@4.3.3: {}
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
locate-path@5.0.0: locate-path@5.0.0:
@@ -5039,6 +5555,8 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
orderedmap@2.1.1: {}
otplib@13.5.0: otplib@13.5.0:
dependencies: dependencies:
'@otplib/core': 13.5.0 '@otplib/core': 13.5.0
@@ -5191,6 +5709,80 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
prosemirror-changeset@2.4.3:
dependencies:
prosemirror-transform: 1.12.1
prosemirror-commands@1.7.2:
dependencies:
prosemirror-model: 1.25.11
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-dropcursor@1.8.3:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.4
prosemirror-gapcursor@1.4.1:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.11
prosemirror-state: 1.4.4
prosemirror-view: 1.42.4
prosemirror-history@1.5.0:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.4
rope-sequence: 1.3.4
prosemirror-inputrules@1.5.1:
dependencies:
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-keymap@1.2.3:
dependencies:
prosemirror-state: 1.4.4
w3c-keyname: 2.2.8
prosemirror-model@1.25.11:
dependencies:
orderedmap: 2.1.1
prosemirror-schema-list@1.5.1:
dependencies:
prosemirror-model: 1.25.11
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-state@1.4.4:
dependencies:
prosemirror-model: 1.25.11
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.4
prosemirror-tables@1.8.5:
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.11
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
prosemirror-view: 1.42.4
prosemirror-transform@1.12.1:
dependencies:
prosemirror-model: 1.25.11
prosemirror-view@1.42.4:
dependencies:
prosemirror-model: 1.25.11
prosemirror-state: 1.4.4
prosemirror-transform: 1.12.1
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -5305,6 +5897,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.63.1 '@rollup/rollup-win32-x64-msvc': 4.63.1
fsevents: 2.3.3 fsevents: 2.3.3
rope-sequence@1.3.4: {}
run-parallel@1.2.0: run-parallel@1.2.0:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
@@ -5606,6 +6200,10 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
use-sync-external-store@1.7.0(react@18.3.1):
dependencies:
react: 18.3.1
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
@@ -5690,6 +6288,8 @@ snapshots:
- supports-color - supports-color
- terser - terser
w3c-keyname@2.2.8: {}
webidl-conversions@3.0.1: {} webidl-conversions@3.0.1: {}
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}