Compare commits
8 Commits
ef4591b8bc
...
developer
| Author | SHA1 | Date | |
|---|---|---|---|
| 398579fa82 | |||
| a1e020556e | |||
| 33efb9b9ca | |||
| b3b1e21c20 | |||
| 3785b32c5f | |||
| 1545666f17 | |||
| 553c0d9533 | |||
| 8ab18ababc |
@@ -4,6 +4,7 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { DocumentsService } from "./documents.service";
|
||||
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||
import { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../auth/permission.guard";
|
||||
@@ -24,6 +25,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
@Get("variable-catalog")
|
||||
@RequirePermission("documents", "view")
|
||||
getVariableCatalog() {
|
||||
return this.documents.getVariableCatalog();
|
||||
}
|
||||
|
||||
@Get("templates")
|
||||
@RequirePermission("documents", "view")
|
||||
listTemplates(@Query("docGroup") docGroup?: "cliente" | "revenda") {
|
||||
@@ -54,6 +61,19 @@ export class DocumentsController {
|
||||
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")
|
||||
@RequirePermission("documents", "edit")
|
||||
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
|
||||
@@ -5,10 +5,12 @@ import { auditRepo, documentsRepo } from "@eden/database";
|
||||
import type { DocumentGeneration } from "@eden/database";
|
||||
import { getObjectStream, renderPdf, uploadObject } from "@eden/integrations";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard";
|
||||
import { mergeVariables } from "./merge-engine";
|
||||
import { renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import { resolveDocument } from "./merge-engine";
|
||||
import { collectMergeFieldKeys, renderEditorContentToHtml } from "./editor-content-renderer";
|
||||
import { VARIABLE_CATALOG } from "./variable-catalog";
|
||||
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||
import type { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||
|
||||
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
|
||||
@@ -24,6 +26,10 @@ const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
getVariableCatalog() {
|
||||
return VARIABLE_CATALOG;
|
||||
}
|
||||
|
||||
async listTemplates(docGroup?: "cliente" | "revenda") {
|
||||
return documentsRepo.listTemplates(docGroup ? { docGroup } : {});
|
||||
}
|
||||
@@ -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) {
|
||||
try {
|
||||
const published = await documentsRepo.publishDraft(templateId, user.id);
|
||||
@@ -153,11 +183,9 @@ export class DocumentsService {
|
||||
}
|
||||
|
||||
// eden.md §4 descreve o pipeline completo com sanitizeDocumentHtml —
|
||||
// não reimplementado nesta leva (editor_content ainda não valida a
|
||||
// árvore Tiptap real, ver editor-content-renderer.ts); mas a
|
||||
// renderização de PDF em si (Chromium) e o upload para S3 já são
|
||||
// reais a partir daqui.
|
||||
const { resolved, missingVariables } = mergeVariables(published.version.html_content, dto.context ?? {});
|
||||
// não reimplementado nesta leva; mas a renderização de PDF em si
|
||||
// (Chromium) e o upload para S3 já são reais a partir daqui.
|
||||
const { html: resolved, missingVariables } = resolveDocument(published.version.editor_content, dto.context ?? {});
|
||||
|
||||
const origin = { clientRegistrationId: dto.clientRegistrationId ?? null, resellerId: dto.resellerId ?? null };
|
||||
|
||||
@@ -169,7 +197,7 @@ export class DocumentsService {
|
||||
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 pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
|
||||
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
|
||||
|
||||
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal file
12
apps/api/src/documents/dto/preview-draft.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -1,26 +1,39 @@
|
||||
/**
|
||||
* Stand-in SIMPLIFICADO para o `renderToHtml()`/`renderEditorContent.js`
|
||||
* do legado (eden.md §2.2) — aquele renderer conhece toda a árvore
|
||||
* Tiptap (paragraph/heading/table/mergeField/conditional/...) e escapa
|
||||
* HTML nó a nó. Esta leva de backend não tem o editor visual real ainda
|
||||
* (`editor_content` é aceito como JSONB genérico, não validado), então
|
||||
* em vez de reimplementar aquele renderer pela metade, este módulo faz
|
||||
* só o mínimo necessário para `document_template_versions.html_content`
|
||||
* nunca ficar dessincronizado do `editor_content` salvo:
|
||||
* Serializador do documento Tiptap (`editor_content`) para HTML — porta fiel
|
||||
* de `renderEditorContent.js` do legado OrçaFácil
|
||||
* (`.tmp/orcafacil-reference/server/src/lib/documentTemplates/renderEditorContent.js`),
|
||||
* escrito à mão sem depender de `@tiptap/html` (este pacote não pode
|
||||
* depender de bibliotecas do editor/frontend — eden.md §2.2).
|
||||
*
|
||||
* - se `editor_content` tiver uma propriedade de nível raiz
|
||||
* `plainText` (string), ela é usada como o texto do documento;
|
||||
* - caso contrário, um placeholder determinístico é usado.
|
||||
*
|
||||
* NÃO deve ser confundido com o merge-engine (merge-engine.ts), que roda
|
||||
* DEPOIS, sobre o texto aqui extraído, para resolver `{{variavel}}`.
|
||||
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
||||
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
||||
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
|
||||
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
|
||||
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
|
||||
* 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 =
|
||||
"[Conteúdo gerado pelo editor visual — renderização completa pendente de implementação]";
|
||||
export interface TiptapMark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
export interface TiptapNode {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
content?: TiptapNode[];
|
||||
marks?: TiptapMark[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Resolve o valor final de uma `mergeField` pela sua `key` — quando omitida, o nó vira o chip visual (preview "com variáveis"). */
|
||||
export type ResolveMergeField = (key: string) => string;
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
@@ -28,35 +41,138 @@ function escapeHtml(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `editor_content.plainText` when present as a root-level string
|
||||
* property, or `null` otherwise. Never throws on a malformed/unexpected
|
||||
* shape — this leva doesn't validate the Tiptap tree at all (that's the
|
||||
* visual editor's job once it exists).
|
||||
*/
|
||||
export function extractPlainText(editorContent: unknown): string | null {
|
||||
if (
|
||||
editorContent !== null &&
|
||||
typeof editorContent === "object" &&
|
||||
"plainText" in (editorContent as Record<string, unknown>) &&
|
||||
typeof (editorContent as Record<string, unknown>).plainText === "string"
|
||||
) {
|
||||
return (editorContent as { plainText: string }).plainText;
|
||||
// textAlign é atributo do NODE (parágrafo/título) no Tiptap — não é mark.
|
||||
function textAlignStyle(node: TiptapNode): string {
|
||||
const align = node.attrs?.textAlign;
|
||||
return typeof align === "string" && align.length > 0 ? ` style="text-align:${escapeHtml(align)}"` : "";
|
||||
}
|
||||
|
||||
// fontSize é mark (aplicado por trecho de texto) da extensão oficial
|
||||
// @tiptap/extension-text-style — vira um <span> em volta do texto marcado.
|
||||
function renderMarks(text: string, marks: TiptapMark[] | undefined): string {
|
||||
let html = escapeHtml(text);
|
||||
for (const mark of marks ?? []) {
|
||||
if (mark.type === "bold") html = `<strong>${html}</strong>`;
|
||||
else if (mark.type === "italic") html = `<em>${html}</em>`;
|
||||
else if (mark.type === "underline") html = `<u>${html}</u>`;
|
||||
else if (mark.type === "strike") html = `<s>${html}</s>`;
|
||||
else if (mark.type === "textStyle" && mark.attrs?.fontSize) {
|
||||
html = `<span style="font-size:${escapeHtml(mark.attrs.fontSize)}">${html}</span>`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderInline(nodes: TiptapNode[] | undefined, resolveMergeField?: ResolveMergeField): string {
|
||||
return (nodes ?? [])
|
||||
.map((node) => {
|
||||
if (node.type === "text") return renderMarks(node.text ?? "", node.marks);
|
||||
if (node.type === "mergeField") {
|
||||
const key = String(node.attrs?.key ?? "");
|
||||
if (resolveMergeField) return escapeHtml(resolveMergeField(key));
|
||||
const label = typeof node.attrs?.label === "string" ? node.attrs.label : key;
|
||||
return `<span class="merge-field-chip" data-merge-field="${escapeHtml(key)}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return `<p${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField) || " "}</p>`;
|
||||
case "heading": {
|
||||
const level = typeof node.attrs?.level === "number" ? node.attrs.level : 1;
|
||||
return `<h${level}${textAlignStyle(node)}>${renderInline(node.content, resolveMergeField)}</h${level}>`;
|
||||
}
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map((li) => renderBlock(li, resolveMergeField)).join("")}</ol>`;
|
||||
case "listItem":
|
||||
return `<li>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</li>`;
|
||||
case "image":
|
||||
return `<img src="${escapeHtml(node.attrs?.src)}" alt="${escapeHtml(node.attrs?.alt ?? "")}" />`;
|
||||
case "pageBreak":
|
||||
return `<div class="page-break" style="break-after:page;page-break-after:always;"></div>`;
|
||||
case "table": {
|
||||
// <thead> de verdade (não só a 1ª linha) — é o que faz o Chromium
|
||||
// repetir o cabeçalho da tabela em toda página nova quando ela
|
||||
// atravessa uma quebra.
|
||||
const rows = node.content ?? [];
|
||||
const headerRows = rows.filter((r) => r.content?.[0]?.type === "tableHeader");
|
||||
const bodyRows = rows.filter((r) => r.content?.[0]?.type !== "tableHeader");
|
||||
const theadHtml = headerRows.length
|
||||
? `<thead>${headerRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</thead>`
|
||||
: "";
|
||||
const tbodyHtml = `<tbody>${bodyRows.map((r) => renderBlock(r, resolveMergeField)).join("")}</tbody>`;
|
||||
return `<table>${theadHtml}${tbodyHtml}</table>`;
|
||||
}
|
||||
case "tableRow":
|
||||
return `<tr>${(node.content ?? []).map((cell) => renderBlock(cell, resolveMergeField)).join("")}</tr>`;
|
||||
case "tableHeader": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<th${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</th>`;
|
||||
}
|
||||
case "tableCell": {
|
||||
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
|
||||
}
|
||||
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 —
|
||||
* this is the actual text fed to the merge engine at generation time
|
||||
* (documents.service.ts), never `null`/empty on that path.
|
||||
* Serializa `editor_content` para HTML. Sem `resolveMergeField`, toda
|
||||
* `mergeField` vira o chip visual (é o que fica cacheado em
|
||||
* `document_template_versions.html_content`, eden.md §1.2); com
|
||||
* `resolveMergeField`, cada uma vira o valor final resolvido (usado na
|
||||
* geração real e na pré-visualização de rascunho).
|
||||
*/
|
||||
export function resolveSourceText(editorContent: unknown): string {
|
||||
const plainText = extractPlainText(editorContent);
|
||||
return plainText && plainText.length > 0 ? plainText : EDITOR_CONTENT_PLACEHOLDER_TEXT;
|
||||
export function renderToHtml(editorContent: unknown, resolveMergeField?: ResolveMergeField): string {
|
||||
if (!isTiptapDoc(editorContent)) return "";
|
||||
return (editorContent.content ?? []).map((node) => renderBlock(node, resolveMergeField)).join("\n");
|
||||
}
|
||||
|
||||
/** Recomputes `document_template_versions.html_content` (eden.md §1.2 step 2). */
|
||||
/** `renderEditorContentToHtml` do stand-in anterior — mantido como nome de import estável para os chamadores existentes. */
|
||||
export function renderEditorContentToHtml(editorContent: unknown): string {
|
||||
return `<p>${escapeHtml(resolveSourceText(editorContent))}</p>`;
|
||||
return renderToHtml(editorContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Varre a árvore e devolve todas as chaves de `mergeField` usadas no
|
||||
* documento — usado tanto para "Variáveis usadas neste modelo" (frontend)
|
||||
* quanto para achar `missingVariables` na resolução (merge-engine.ts).
|
||||
* Sem suporte a `tableRow.attrs.repeat`/escopo `collection` nesta leva
|
||||
* (fora de escopo — ver docblock do arquivo).
|
||||
*/
|
||||
export function collectMergeFieldKeys(editorContent: unknown): string[] {
|
||||
const keys = new Set<string>();
|
||||
|
||||
function walk(node: TiptapNode | undefined): void {
|
||||
if (!node) return;
|
||||
if (node.type === "mergeField" && typeof node.attrs?.key === "string") {
|
||||
keys.add(node.attrs.key);
|
||||
return;
|
||||
}
|
||||
(node.content ?? []).forEach(walk);
|
||||
}
|
||||
|
||||
if (isTiptapDoc(editorContent)) {
|
||||
(editorContent.content ?? []).forEach(walk);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
/**
|
||||
* Motor de merge de variáveis — versão SIMPLIFICADA para esta leva,
|
||||
* inspirada no `resolveDocument()` do legado (eden.md §3.2), mas
|
||||
* operando sobre TEXTO PURO com placeholders `{{variavel}}` em vez da
|
||||
* árvore Tiptap completa. A árvore Tiptap real (nós `mergeField`,
|
||||
* `conditional`, `tableRow.attrs.repeat`, catálogo de variáveis
|
||||
* `VARIABLE_CATALOG` com formatação por tipo — eden.md §2.2, §3.1, §3.3,
|
||||
* §3.4) fica para quando o editor visual real existir; não é
|
||||
* reimplementada aqui.
|
||||
*
|
||||
* Pura e sem I/O — testável isoladamente (ver
|
||||
* apps/api/test/merge-engine.spec.ts). Diferença deliberada em relação
|
||||
* ao motor completo do legado: aquele lança `MISSING_REQUIRED_FIELDS`/
|
||||
* `UNRESOLVED_VARIABLES` (HTTP 422) e bloqueia a geração; este NUNCA
|
||||
* lança exceção — variáveis ausentes do contexto são substituídas por
|
||||
* string vazia e apenas reportadas em `missingVariables` (deduplicadas,
|
||||
* na ordem da primeira ocorrência). O chamador decide o que fazer com
|
||||
* elas (bloquear, avisar, etc.); o motor não decide isso sozinho.
|
||||
* Motor de resolução de variáveis — opera direto sobre a árvore Tiptap
|
||||
* (`editor_content`), não mais sobre `{{variavel}}` em texto puro (essa era
|
||||
* a versão stand-in de uma leva anterior, antes do editor visual real
|
||||
* existir). Inspirado em `resolveDocument()` do legado (eden.md §3.2), mas
|
||||
* simplificado: sem nó `conditional`, sem expansão de `tableRow.attrs.repeat`
|
||||
* (fora de escopo — o editor/catálogo desta leva nunca produz nenhum dos
|
||||
* dois) e sem bloquear com `MISSING_REQUIRED_FIELDS`/`UNRESOLVED_VARIABLES`
|
||||
* (HTTP 422) — uma `mergeField` sem valor em `context` vira string vazia e é
|
||||
* só reportada em `missingVariables` (deduplicada, ordem da primeira
|
||||
* ocorrência); o chamador decide o que fazer com isso.
|
||||
*/
|
||||
import { collectMergeFieldKeys, renderToHtml } from "./editor-content-renderer";
|
||||
|
||||
export interface MergeResult {
|
||||
/** Texto com todo `{{variavel}}` substituído — nunca sobra placeholder pendente, mesmo quando ausente do contexto (vira string vazia). */
|
||||
resolved: string;
|
||||
/** Chaves referenciadas no texto mas ausentes de `context`, deduplicadas, na ordem da primeira ocorrência. */
|
||||
export interface ResolveDocumentResult {
|
||||
/** HTML final, com toda `mergeField` já substituída pelo valor do contexto (ou string vazia, quando ausente). */
|
||||
html: string;
|
||||
/** Chaves referenciadas no documento mas ausentes de `context`, deduplicadas, na ordem de `collectMergeFieldKeys`. */
|
||||
missingVariables: string[];
|
||||
}
|
||||
|
||||
// Mesma sintaxe de chave do catálogo legado (eden.md §3.1): namespace.campo,
|
||||
// ex. "cliente.razao_social". Espaços em volta da chave são tolerados
|
||||
// ("{{ cliente.razao_social }}") e ignorados.
|
||||
const MERGE_FIELD_PATTERN = /\{\{\s*([A-Za-z0-9_.]+)\s*\}\}/g;
|
||||
export function resolveDocument(editorContent: unknown, context: Record<string, string>): ResolveDocumentResult {
|
||||
const keys = collectMergeFieldKeys(editorContent);
|
||||
const missingVariables = keys.filter((key) => !Object.prototype.hasOwnProperty.call(context, key));
|
||||
|
||||
export function mergeVariables(text: string, context: Record<string, string>): MergeResult {
|
||||
const missingVariables: string[] = [];
|
||||
const seenMissing = new Set<string>();
|
||||
const html = renderToHtml(editorContent, (key) => context[key] ?? "");
|
||||
|
||||
const resolved = text.replace(MERGE_FIELD_PATTERN, (_match: string, key: string) => {
|
||||
if (Object.prototype.hasOwnProperty.call(context, key)) {
|
||||
return context[key] ?? "";
|
||||
}
|
||||
if (!seenMissing.has(key)) {
|
||||
seenMissing.add(key);
|
||||
missingVariables.push(key);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
return { resolved, missingVariables };
|
||||
return { html, missingVariables };
|
||||
}
|
||||
|
||||
172
apps/api/src/documents/variable-catalog.ts
Normal file
172
apps/api/src/documents/variable-catalog.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
@@ -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
|
||||
* 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 getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
|
||||
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),
|
||||
getObjectStream: (key: string) => getObjectStreamMock(key),
|
||||
}));
|
||||
@@ -47,7 +47,21 @@ describe("documents — PDF generation + download", () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.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);
|
||||
await agent(app)
|
||||
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
||||
@@ -88,6 +102,10 @@ describe("documents — PDF generation + download", () => {
|
||||
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
|
||||
expect(renderedHtml).toContain("Revenda: ACME");
|
||||
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.
|
||||
const uploadCall = uploadObjectMock.mock.calls[0]!;
|
||||
@@ -129,4 +147,62 @@ describe("documents — PDF generation + download", () => {
|
||||
expect(getObjectStreamMock).toHaveBeenCalledWith(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
221
apps/api/test/editor-content-renderer.spec.ts
Normal file
221
apps/api/test/editor-content-renderer.spec.ts
Normal 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 ", () => {
|
||||
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> </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("<script>");
|
||||
expect(html).not.toContain("<img onerror");
|
||||
expect(html).toContain("<img onerror=alert(1)>");
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,9 @@ describe("signature envelopes — automatic contratante signer derivation", () =
|
||||
await agent(app)
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.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);
|
||||
await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201);
|
||||
const generation = await agent(app)
|
||||
|
||||
@@ -118,7 +118,14 @@ describe("signature envelopes — full lifecycle", () => {
|
||||
await agent(app)
|
||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||
.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);
|
||||
await agent(app)
|
||||
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
"dependencies": {
|
||||
"@eden/domain-shared": "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-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1"
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
|
||||
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
|
||||
*
|
||||
* This leva has NO real visual editor: `editorContent` is a simplified
|
||||
* JSONB with a single root-level `plainText` string carrying
|
||||
* `{{variavel}}` placeholders (no rich node tree). PDF generation itself
|
||||
* IS real (Chromium via packages/integrations/src/pdf/renderer.ts) —
|
||||
* `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every
|
||||
* `status: 'final'` generation.
|
||||
* `editorContent` is a real Tiptap document tree (`JSONContent`), edited via
|
||||
* the rich editor in `./documents/editor` — resolved server-side by
|
||||
* `apps/api/src/documents/editor-content-renderer.ts` (ported from the
|
||||
* legacy `renderEditorContent.js`). PDF generation is real (Chromium via
|
||||
* packages/integrations/src/pdf/renderer.ts) — `pdf_s3_key`/`pdf_sha256`/
|
||||
* `pdf_size_bytes` are populated on every `status: 'final'` generation.
|
||||
*
|
||||
* Like Contracts/Resellers, template/version/generation responses are
|
||||
* snake_case straight off the DB row — except the two computed fields on a
|
||||
@@ -15,6 +15,7 @@
|
||||
* camelCase exactly as documented in the task brief; mirrored as-is below,
|
||||
* not "fixed" to snake_case.
|
||||
*/
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { API_BASE_URL, apiRequest } from "./http.js";
|
||||
|
||||
export type DocGroup = "cliente" | "revenda";
|
||||
@@ -30,10 +31,15 @@ export type GenerationStatus = "generating" | "final" | "error";
|
||||
*/
|
||||
export const DOCUMENT_TEMPLATE_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
/** Simplified editor content for this leva — always `{ type: "doc", plainText }`, never a rich node tree. */
|
||||
export interface SimpleEditorContent {
|
||||
type: "doc";
|
||||
plainText: string;
|
||||
/** Empty Tiptap doc — mirrors `EMPTY_DOC_CONTENT` in packages/database/src/repositories/documents.ts. */
|
||||
export const EMPTY_EDITOR_CONTENT: JSONContent = { type: "doc", content: [{ type: "paragraph", content: [] }] };
|
||||
|
||||
/** Uma entrada do catálogo fechado de variáveis — `GET /documents/variable-catalog`. */
|
||||
export interface VariableCatalogEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** Response shape for `GET /documents/templates` (list) and the bare fields of the detail response. */
|
||||
@@ -54,7 +60,7 @@ export interface DocumentTemplateVersion {
|
||||
template_id: string;
|
||||
version: number;
|
||||
status: DocumentVersionStatus;
|
||||
editor_content: SimpleEditorContent | Record<string, unknown>;
|
||||
editor_content: JSONContent;
|
||||
html_content: string | null;
|
||||
css_content: string | null;
|
||||
page_config: Record<string, unknown> | null;
|
||||
@@ -78,7 +84,7 @@ export interface CreateDocumentTemplateInput {
|
||||
|
||||
/** Body of `PATCH /documents/templates/:id/draft` — matches `UpdateDraftDto`. `pageConfig` is deliberately omitted from the UI. */
|
||||
export interface UpdateDraftInput {
|
||||
editorContent?: SimpleEditorContent;
|
||||
editorContent?: JSONContent;
|
||||
cssContent?: string;
|
||||
pageConfig?: Record<string, unknown>;
|
||||
}
|
||||
@@ -208,14 +214,68 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
|
||||
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[] {
|
||||
const found = new Set<string>();
|
||||
const pattern = /\{\{(\w+)\}\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(plainText)) !== null) {
|
||||
const variable = match[1];
|
||||
if (variable) found.add(variable);
|
||||
/**
|
||||
* Renders the CURRENT (possibly unsaved) draft content to PDF via Chromium
|
||||
* — nothing is persisted server-side. Same raw-fetch pattern as
|
||||
* `downloadGenerationPdf` (binary response, still needs the bearer token).
|
||||
* Every `mergeField` shows as `[chave]` in the result (no real
|
||||
* client/reseller context at this stage).
|
||||
*
|
||||
* Returns an object URL for the caller to show inline (`<iframe>`), rather
|
||||
* 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];
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
collectMergeFieldKeys,
|
||||
createGeneration,
|
||||
extractTemplateVariables,
|
||||
getDocumentTemplate,
|
||||
listDocumentTemplates,
|
||||
} from "../../lib/api/documents.js";
|
||||
@@ -92,8 +92,7 @@ export function GenerationFormPage() {
|
||||
.then((detail) => {
|
||||
if (cancelled) return;
|
||||
const published = detail.versions.find((v) => v.id === detail.current_version_id);
|
||||
const content = published?.editor_content as { plainText?: string } | undefined;
|
||||
const variables = content?.plainText ? extractTemplateVariables(content.plainText) : [];
|
||||
const variables = published ? collectMergeFieldKeys(published.editor_content) : [];
|
||||
setTemplateVariables(variables);
|
||||
setVariableValues(Object.fromEntries(variables.map((v) => [v, ""])));
|
||||
})
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
EMPTY_EDITOR_CONTENT,
|
||||
collectMergeFieldKeys,
|
||||
createDraft,
|
||||
getDocumentTemplate,
|
||||
getVariableCatalog,
|
||||
previewDraft,
|
||||
publishDraft,
|
||||
updateDraft,
|
||||
} 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 { Breadcrumb } from "../../components/Breadcrumb.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`
|
||||
* (breadcrumb + title/status-badge header, bordered white "detail" cards)
|
||||
* and `.tmp/theme-reference/documentWorkflows.tsx` for the
|
||||
* draft-in-progress/active status pill language — the theme's Ant Design
|
||||
* table + off-canvas modals were not usable here, so the draft editor is a
|
||||
* plain in-page card (textarea + Save/Publish), same composition style as
|
||||
* `ContractDetailPage`'s `StatusTransitionCard`.
|
||||
* Layout de referência: print do usuário (`/home/handix/editortexto.png`,
|
||||
* editor de "Contrato de Software" do OrçaFácil legado) — header com
|
||||
* título/badge de versão + ações, tabs "Editor" / "Pré-visualização (PDF)",
|
||||
* toolbar rica com variáveis inseridas como chip (nunca `{{texto livre}}`),
|
||||
* painel "Variáveis usadas neste modelo" e "Histórico de versões".
|
||||
*
|
||||
* No rich Tiptap editor and no PDF in this leva: `editorContent` is always
|
||||
* `{ type: "doc", plainText }` and the only free-form field is a `<textarea>`
|
||||
* with `{{variavel}}` placeholders. `html_content` is the server-computed
|
||||
* preview of that merge, rendered read-only.
|
||||
* A tab "Pré-visualização (PDF)" é nomeada diferente do print
|
||||
* ("Visualizar com dados") de propósito: não resolve dado real de
|
||||
* cliente/revenda nesta leva, só o placeholder `[chave]` — ver
|
||||
* apps/api/src/documents/documents.service.ts#previewDraft.
|
||||
*/
|
||||
export function TemplateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -36,6 +39,7 @@ export function TemplateDetailPage() {
|
||||
const [template, setTemplate] = useState<DocumentTemplateWithVersions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
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 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 (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} />
|
||||
@@ -114,7 +135,7 @@ export function TemplateDetailPage() {
|
||||
<Card title="Preview da versão publicada">
|
||||
{publishedVersion?.html_content ? (
|
||||
<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 }}
|
||||
/>
|
||||
) : (
|
||||
@@ -124,12 +145,19 @@ export function TemplateDetailPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<DraftCard
|
||||
templateId={template.id}
|
||||
draftVersion={draftVersion}
|
||||
accessToken={accessToken}
|
||||
onChanged={load}
|
||||
/>
|
||||
<Card title="Rascunho">
|
||||
{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={() => setEditorOpen(true)}>Editar rascunho existente</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CreateDraftPrompt templateId={template.id} accessToken={accessToken} onCreated={() => { load(); setEditorOpen(true); }} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,68 +173,98 @@ function Row({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
function CreateDraftPrompt({
|
||||
templateId,
|
||||
draftVersion,
|
||||
accessToken,
|
||||
onChanged,
|
||||
onCreated,
|
||||
}: {
|
||||
templateId: string;
|
||||
draftVersion: DocumentTemplateVersion | 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 [saving, setSaving] = useState(false);
|
||||
const [confirmingPublish, setConfirmingPublish] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
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() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const version = await createDraft(templateId, accessToken);
|
||||
openEditorFor(version);
|
||||
onChanged();
|
||||
await createDraft(templateId, accessToken);
|
||||
onCreated();
|
||||
} 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.");
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
return (
|
||||
<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;
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDraft(
|
||||
templateId,
|
||||
{ editorContent: { type: "doc", plainText }, cssContent: cssContent || undefined },
|
||||
accessToken,
|
||||
);
|
||||
await updateDraft(templateId, { editorContent: content, cssContent: cssContent || undefined }, accessToken);
|
||||
setSaveSuccess(true);
|
||||
onChanged();
|
||||
} 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() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setPublishing(true);
|
||||
try {
|
||||
await publishDraft(templateId, accessToken);
|
||||
setEditorOpen(false);
|
||||
setConfirmingPublish(false);
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível publicar o rascunho.");
|
||||
} finally {
|
||||
@@ -232,36 +307,28 @@ function DraftCard({
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorOpen) {
|
||||
return (
|
||||
<Card title="Rascunho">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : 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 className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-bold text-title">{templateName}</h1>
|
||||
<Badge variant="warning">Rascunho (v{draftVersion.version})</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-default">Nenhum rascunho em edição no momento.</p>
|
||||
<Button onClick={handleCreateDraft} loading={creating}>
|
||||
Criar rascunho
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Fechar
|
||||
</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>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Editar rascunho">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
@@ -272,51 +339,8 @@ function DraftCard({
|
||||
Rascunho salvo.
|
||||
</Alert>
|
||||
) : 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 ? (
|
||||
<Alert variant="warning" title="Publicar esta versão?">
|
||||
<Alert variant="warning" title="Publicar esta versão?" 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
|
||||
publicação é permanente.
|
||||
@@ -331,7 +355,103 @@ function DraftCard({
|
||||
</div>
|
||||
</Alert>
|
||||
) : 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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
89
apps/core-web/src/routes/documents/editor/DocumentEditor.tsx
Normal file
89
apps/core-web/src/routes/documents/editor/DocumentEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
apps/core-web/src/routes/documents/editor/EditorToolbar.tsx
Normal file
225
apps/core-web/src/routes/documents/editor/EditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -15,20 +15,24 @@ import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/addSalesQuotes.tsx`
|
||||
* (two-column "details" header, editable item table with add/remove rows,
|
||||
* right-hand totals panel) — rebuilt with `@eden/ui` + plain Tailwind
|
||||
* instead of the theme's react-select/CommonDatePicker. Every price/total
|
||||
* Item-picking layout reference: the legacy OrçaFácil "Nova oferta" screen
|
||||
* (two-column catalog + live cart — no longer available to inspect
|
||||
* directly, rebuilt from a precise description) — NOT the DreamsERP theme,
|
||||
* 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)
|
||||
* is the single source of truth and recomputes everything on save.
|
||||
*/
|
||||
|
||||
interface ItemRow {
|
||||
interface CartLine {
|
||||
productId: string;
|
||||
quantity: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
const EMPTY_ROW: ItemRow = { productId: "", quantity: "1" };
|
||||
type ProductSort = "code" | "name";
|
||||
|
||||
function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentCondition[] {
|
||||
if (implTotal <= 1500) return ["À vista"];
|
||||
@@ -36,6 +40,23 @@ function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentC
|
||||
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() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -51,12 +72,16 @@ export function QuoteFormPage() {
|
||||
const [clientPhone, setClientPhone] = useState("");
|
||||
const [contractPeriod, setContractPeriod] = useState<string>("12");
|
||||
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 [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
|
||||
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [salesRepName, setSalesRepName] = useState("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -75,34 +100,81 @@ export function QuoteFormPage() {
|
||||
);
|
||||
}, [accessToken]);
|
||||
|
||||
const productOptions = useMemo(
|
||||
() => (products ?? []).map((p) => ({ value: p.id, label: `${p.name} (cód. ${p.product_code})` })),
|
||||
const contractPeriodNum = Number(contractPeriod) || 0;
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const estimatedImplTotal = useMemo(() => {
|
||||
if (!products) return 0;
|
||||
const fromItems = items.reduce((sum, row) => {
|
||||
const product = products.find((p) => p.id === row.productId);
|
||||
const qty = Number(row.quantity) || 0;
|
||||
if (!product) return sum;
|
||||
return sum + Number(product.impl_unit_price ?? 0) * qty;
|
||||
}, 0);
|
||||
return fromItems + (Number(implGeral) || 0);
|
||||
}, [items, products, implGeral]);
|
||||
const filteredProducts = useMemo(() => {
|
||||
const query = productSearch.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? browsableProducts.filter(
|
||||
(p) => p.name.toLowerCase().includes(query) || String(p.product_code).includes(query),
|
||||
)
|
||||
: browsableProducts;
|
||||
return [...filtered].sort((a, b) =>
|
||||
productSort === "code" ? a.product_code - b.product_code : a.name.localeCompare(b.name, "pt-BR"),
|
||||
);
|
||||
}, [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>) {
|
||||
setItems((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||
const monthlyTotal = round2(cartLines.reduce((sum, line) => sum + line.subtotal, 0));
|
||||
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() {
|
||||
setItems((prev) => [...prev, { ...EMPTY_ROW }]);
|
||||
function setQuantity(productId: string, quantity: number) {
|
||||
if (quantity < 1) return;
|
||||
setCart((prev) => prev.map((line) => (line.productId === productId ? { ...line, quantity } : line)));
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
setItems((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
|
||||
function removeFromCart(productId: string) {
|
||||
setCart((prev) => prev.filter((line) => line.productId !== productId));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
@@ -110,12 +182,8 @@ export function QuoteFormPage() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
|
||||
const validItems = items
|
||||
.filter((row) => row.productId && Number(row.quantity) > 0)
|
||||
.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.");
|
||||
if (cart.length === 0) {
|
||||
setError("Adicione ao menos um produto à oferta.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -130,7 +198,7 @@ export function QuoteFormPage() {
|
||||
clientPhone,
|
||||
contractPeriod: Number(contractPeriod) as ContractPeriod,
|
||||
fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined,
|
||||
items: validItems,
|
||||
items: cart.map((line) => ({ productId: line.productId, quantity: line.quantity })),
|
||||
implGeral: implGeral ? Number(implGeral) : undefined,
|
||||
proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined,
|
||||
implPaymentCondition: implPaymentCondition || undefined,
|
||||
@@ -194,11 +262,14 @@ export function QuoteFormPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{!resellers || !products ? (
|
||||
<Card>
|
||||
<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>
|
||||
<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">
|
||||
@@ -210,21 +281,36 @@ export function QuoteFormPage() {
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
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="E-mail" type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} />
|
||||
<Input label="Telefone" value={clientPhone} onChange={(e) => setClientPhone(e.target.value)} required />
|
||||
<Input
|
||||
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>
|
||||
<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
|
||||
label="Período de contrato"
|
||||
options={CONTRACT_PERIODS.map((period) => ({
|
||||
value: String(period),
|
||||
label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`,
|
||||
label: contractPeriodLabel(period),
|
||||
}))}
|
||||
value={contractPeriod}
|
||||
onChange={(e) => setContractPeriod(e.target.value)}
|
||||
@@ -238,76 +324,118 @@ export function QuoteFormPage() {
|
||||
onChange={(e) => setFidelityPeriod(e.target.value)}
|
||||
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>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-title">Itens</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
+ Adicionar item
|
||||
</button>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5 lg:items-start">
|
||||
<div className="lg:col-span-3">
|
||||
<Card title="Adicionar Produtos" bodyClassName="p-0">
|
||||
<div className="flex flex-col gap-3 border-b border-border-color p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar por código ou nome..."
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
aria-label="Buscar produto"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-xs font-semibold text-default">Ordenar por</span>
|
||||
<Select
|
||||
options={productOptions}
|
||||
placeholder="Selecione o produto"
|
||||
value={row.productId}
|
||||
onChange={(e) => updateItem(index, { productId: e.target.value })}
|
||||
aria-label="Produto"
|
||||
required
|
||||
options={[
|
||||
{ value: "code", label: "Código" },
|
||||
{ value: "name", label: "Nome (A-Z)" },
|
||||
]}
|
||||
value={productSort}
|
||||
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 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>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Implantação e valores</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{cartLines.length === 0 ? (
|
||||
<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
|
||||
label="Implantação geral (opcional)"
|
||||
label="Implantação Geral do Projeto (R$)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
@@ -315,18 +443,9 @@ export function QuoteFormPage() {
|
||||
onChange={(e) => setImplGeral(e.target.value)}
|
||||
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>
|
||||
<Select
|
||||
label="Condição de pagamento da implantação"
|
||||
label="Condição de Pagamento da Implantação"
|
||||
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
|
||||
value: condition,
|
||||
label: condition,
|
||||
@@ -336,7 +455,7 @@ export function QuoteFormPage() {
|
||||
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
|
||||
/>
|
||||
<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) => (
|
||||
<Badge key={c} variant="neutral" className="ms-1">
|
||||
{c}
|
||||
@@ -346,17 +465,35 @@ export function QuoteFormPage() {
|
||||
</p>
|
||||
</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>
|
||||
<Input
|
||||
label="Responsável comercial"
|
||||
value={salesRepName}
|
||||
onChange={(e) => setSalesRepName(e.target.value)}
|
||||
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.'
|
||||
label="Condição Especial — Valor Mensal Final (R$)"
|
||||
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"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default">Tabela: {formatCurrencyBRL(monthlyTotal)}</p>
|
||||
</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
|
||||
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}
|
||||
@@ -364,8 +501,13 @@ export function QuoteFormPage() {
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</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}>
|
||||
Criar oferta
|
||||
</Button>
|
||||
@@ -373,13 +515,143 @@ export function QuoteFormPage() {
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border-color p-4">
|
||||
|
||||
196
docs/progress.md
196
docs/progress.md
@@ -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)
|
||||
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.
|
||||
|
||||
92
docs/runbooks/setup-vm-pre-producao.md
Normal file
92
docs/runbooks/setup-vm-pre-producao.md
Normal 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.
|
||||
@@ -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
600
pnpm-lock.yaml
generated
@@ -120,6 +120,42 @@ importers:
|
||||
'@eden/ui':
|
||||
specifier: workspace:*
|
||||
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:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
@@ -971,6 +1007,15 @@ packages:
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1465,6 +1510,192 @@ packages:
|
||||
'@swc/types@0.1.28':
|
||||
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':
|
||||
resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1585,6 +1816,9 @@ packages:
|
||||
'@types/supertest@6.0.3':
|
||||
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':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
@@ -1995,6 +2229,10 @@ packages:
|
||||
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -2213,6 +2451,9 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkifyjs@4.3.3:
|
||||
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
|
||||
|
||||
load-tsconfig@0.2.5:
|
||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -2399,6 +2640,9 @@ packages:
|
||||
once@1.4.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==}
|
||||
|
||||
@@ -2581,6 +2825,45 @@ packages:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -2670,6 +2953,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rope-sequence@1.3.4:
|
||||
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -2942,6 +3228,11 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@@ -3058,6 +3349,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -3636,6 +3930,20 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.28.2':
|
||||
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': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
@@ -3995,6 +4303,208 @@ snapshots:
|
||||
dependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@7.2.0)
|
||||
@@ -4135,6 +4645,8 @@ snapshots:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.11
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@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))':
|
||||
@@ -4682,6 +5194,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-equals@5.4.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -4904,6 +5418,8 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkifyjs@4.3.3: {}
|
||||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
locate-path@5.0.0:
|
||||
@@ -5039,6 +5555,8 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
orderedmap@2.1.1: {}
|
||||
|
||||
otplib@13.5.0:
|
||||
dependencies:
|
||||
'@otplib/core': 13.5.0
|
||||
@@ -5191,6 +5709,80 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -5305,6 +5897,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.63.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
rope-sequence@1.3.4: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -5606,6 +6200,10 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
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: {}
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
@@ -5690,6 +6288,8 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
Reference in New Issue
Block a user