feat(documents): pré-visualização em PDF do rascunho via Chromium
Novo endpoint POST /documents/templates/:id/draft/preview: renderiza o
conteúdo atual (ainda não salvo) do editor com placeholders {{variavel}}
substituídos por [variavel], via Chromium real, sem persistir nada
(sem generation, sem upload S3) — permite validar visualmente o
resultado antes do "Publicar", que é uma ação permanente.
Corrige de passagem um bug real: css_content do template nunca chegava
ao renderPdf na geração real de documento (createGeneration), sendo
descartado silenciosamente.
No frontend, a pré-visualização aparece embutida num iframe na própria
tela (não em nova aba): abrir em nova aba se mostrou não-confiável ao
vivo — a navegação tardia de uma aba pré-aberta pode ser ignorada pelo
navegador quando o gesto do usuário que a autorizou já expirou no tempo
que o Chromium leva pra renderizar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
|||||||
import { DocumentsService } from "./documents.service";
|
import { DocumentsService } from "./documents.service";
|
||||||
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
import { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||||
import { UpdateDraftDto } from "./dto/update-draft.dto";
|
import { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||||
|
import { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||||
import { CreateGenerationDto } from "./dto/create-generation.dto";
|
import { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
|
||||||
import { PermissionGuard } from "../auth/permission.guard";
|
import { PermissionGuard } from "../auth/permission.guard";
|
||||||
@@ -54,6 +55,19 @@ export class DocumentsController {
|
|||||||
return this.documents.updateDraft(id, dto, user);
|
return this.documents.updateDraft(id, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("templates/:id/draft/preview")
|
||||||
|
@RequirePermission("documents", "edit")
|
||||||
|
@Header("Content-Type", "application/pdf")
|
||||||
|
async previewDraft(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: PreviewDraftDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const buffer = await this.documents.previewDraft(id, dto);
|
||||||
|
res.set({ "Content-Disposition": 'inline; filename="preview.pdf"' });
|
||||||
|
return new StreamableFile(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("templates/:id/publish")
|
@Post("templates/:id/publish")
|
||||||
@RequirePermission("documents", "edit")
|
@RequirePermission("documents", "edit")
|
||||||
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
publish(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { mergeVariables } from "./merge-engine";
|
|||||||
import { renderEditorContentToHtml } from "./editor-content-renderer";
|
import { renderEditorContentToHtml } from "./editor-content-renderer";
|
||||||
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
import type { CreateDocumentTemplateDto } from "./dto/create-document-template.dto";
|
||||||
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
import type { UpdateDraftDto } from "./dto/update-draft.dto";
|
||||||
|
import type { PreviewDraftDto } from "./dto/preview-draft.dto";
|
||||||
import type { CreateGenerationDto } from "./dto/create-generation.dto";
|
import type { CreateGenerationDto } from "./dto/create-generation.dto";
|
||||||
|
|
||||||
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
|
const { TemplateNotFoundError, TemplateKeyConflictError, DraftAlreadyExistsError, DraftNotFoundError } =
|
||||||
@@ -111,6 +112,34 @@ 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 `{{variavel}}` encontrada vira um placeholder visível
|
||||||
|
* (`[variavel]`) em vez de string vazia — o merge-engine já reporta
|
||||||
|
* exatamente essas chaves em `missingVariables` quando o contexto é
|
||||||
|
* vazio, então é reaproveitado igual, só invertendo o que fazer com o
|
||||||
|
* resultado.
|
||||||
|
*/
|
||||||
|
async previewDraft(templateId: string, dto: PreviewDraftDto): Promise<Buffer> {
|
||||||
|
const template = await documentsRepo.getTemplateById(templateId);
|
||||||
|
if (!template) throw new NotFoundException("Template de documento não encontrado");
|
||||||
|
|
||||||
|
const html = renderEditorContentToHtml(dto.editorContent);
|
||||||
|
const { missingVariables } = mergeVariables(html, {});
|
||||||
|
const placeholderContext = Object.fromEntries(missingVariables.map((key) => [key, `[${key}]`]));
|
||||||
|
const { resolved } = mergeVariables(html, placeholderContext);
|
||||||
|
|
||||||
|
return renderPdf({ html: resolved, css: dto.cssContent });
|
||||||
|
}
|
||||||
|
|
||||||
async publish(templateId: string, user: EdenAuthenticatedUser) {
|
async publish(templateId: string, user: EdenAuthenticatedUser) {
|
||||||
try {
|
try {
|
||||||
const published = await documentsRepo.publishDraft(templateId, user.id);
|
const published = await documentsRepo.publishDraft(templateId, user.id);
|
||||||
@@ -169,7 +198,7 @@ export class DocumentsService {
|
|||||||
return { ...existing, missingVariables, deduped: true };
|
return { ...existing, missingVariables, deduped: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBuffer = await renderPdf({ html: resolved });
|
const pdfBuffer = await renderPdf({ html: resolved, css: published.version.css_content ?? undefined });
|
||||||
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
|
const pdfSha256 = createHash("sha256").update(pdfBuffer).digest("hex");
|
||||||
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
|
const pdfS3Key = `documents/generations/${randomUUID()}.pdf`;
|
||||||
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
|
await uploadObject(pdfS3Key, pdfBuffer, "application/pdf");
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@ import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type Authe
|
|||||||
* persist the result, skip re-rendering on dedup, and stream the right S3
|
* persist the result, skip re-rendering on dedup, and stream the right S3
|
||||||
* key back out on download.
|
* key back out on download.
|
||||||
*/
|
*/
|
||||||
const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake pdf bytes"));
|
const renderPdfMock = vi.fn(async (_input: { html: string; css?: string }) => Buffer.from("%PDF-1.4 fake pdf bytes"));
|
||||||
const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined);
|
const uploadObjectMock = vi.fn(async (_key: string, _buffer: Buffer, _mimeType: string) => undefined);
|
||||||
const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
|
const getObjectStreamMock = vi.fn(async (key: string) => Readable.from([Buffer.from(`content-of:${key}`)]));
|
||||||
vi.mock("@eden/integrations", () => ({
|
vi.mock("@eden/integrations", () => ({
|
||||||
renderPdf: (input: { html: string }) => renderPdfMock(input),
|
renderPdf: (input: { html: string; css?: string }) => renderPdfMock(input),
|
||||||
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
|
uploadObject: (key: string, buffer: Buffer, mimeType: string) => uploadObjectMock(key, buffer, mimeType),
|
||||||
getObjectStream: (key: string) => getObjectStreamMock(key),
|
getObjectStream: (key: string) => getObjectStreamMock(key),
|
||||||
}));
|
}));
|
||||||
@@ -47,7 +47,10 @@ describe("documents — PDF generation + download", () => {
|
|||||||
await agent(app)
|
await agent(app)
|
||||||
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
.patch(`/api/v1/documents/templates/${templateId}/draft`)
|
||||||
.set(...bearer(admin.accessToken))
|
.set(...bearer(admin.accessToken))
|
||||||
.send({ editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" } })
|
.send({
|
||||||
|
editorContent: { type: "doc", plainText: "Revenda: {{revenda.nome}}" },
|
||||||
|
cssContent: "p { color: red; }",
|
||||||
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
await agent(app)
|
await agent(app)
|
||||||
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
.post(`/api/v1/documents/templates/${templateId}/publish`)
|
||||||
@@ -88,6 +91,10 @@ describe("documents — PDF generation + download", () => {
|
|||||||
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
|
const renderedHtml = renderPdfMock.mock.calls[0]![0].html;
|
||||||
expect(renderedHtml).toContain("Revenda: ACME");
|
expect(renderedHtml).toContain("Revenda: ACME");
|
||||||
expect(renderedHtml).not.toContain("{{revenda.nome}}");
|
expect(renderedHtml).not.toContain("{{revenda.nome}}");
|
||||||
|
// The template version's css_content must actually reach the renderer —
|
||||||
|
// real gap found and fixed alongside the draft-preview feature: this
|
||||||
|
// used to be silently discarded (renderPdf({ html: resolved }) only).
|
||||||
|
expect(renderPdfMock.mock.calls[0]![0].css).toBe("p { color: red; }");
|
||||||
|
|
||||||
// Uploaded buffer's hash matches the mock's output and what got persisted.
|
// Uploaded buffer's hash matches the mock's output and what got persisted.
|
||||||
const uploadCall = uploadObjectMock.mock.calls[0]!;
|
const uploadCall = uploadObjectMock.mock.calls[0]!;
|
||||||
@@ -129,4 +136,48 @@ describe("documents — PDF generation + download", () => {
|
|||||||
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
|
expect(getObjectStreamMock).toHaveBeenCalledWith(generation.pdf_s3_key);
|
||||||
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
|
expect(Buffer.isBuffer(download.body) && download.body.toString()).toBe(`content-of:${generation.pdf_s3_key}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("draft preview", () => {
|
||||||
|
it("renders the CURRENT (unsaved) editor state with placeholder variables, never persisting anything", async () => {
|
||||||
|
renderPdfMock.mockClear();
|
||||||
|
uploadObjectMock.mockClear();
|
||||||
|
|
||||||
|
const res = await agent(app)
|
||||||
|
.post(`/api/v1/documents/templates/${templateId}/draft/preview`)
|
||||||
|
.set(...bearer(admin.accessToken))
|
||||||
|
.send({
|
||||||
|
editorContent: { plainText: "Olá {{cliente.nome}}, plano {{plano}}." },
|
||||||
|
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("{{");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -208,6 +208,41 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the CURRENT (possibly unsaved) draft content to PDF via Chromium
|
||||||
|
* — nothing is persisted server-side. Same raw-fetch pattern as
|
||||||
|
* `downloadGenerationPdf` (binary response, still needs the bearer token).
|
||||||
|
* Every `{{variavel}}` shows as `[variavel]` in the result (no real
|
||||||
|
* 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: { plainText: string; cssContent?: string },
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/documents/templates/${templateId}/draft/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: JSON.stringify({ editorContent: { plainText: input.plainText }, cssContent: input.cssContent }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Não foi possível gerar a pré-visualização.");
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */
|
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */
|
||||||
export function extractTemplateVariables(plainText: string): string[] {
|
export function extractTemplateVariables(plainText: string): string[] {
|
||||||
const found = new Set<string>();
|
const found = new Set<string>();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ApiError } from "../../lib/api/http.js";
|
|||||||
import {
|
import {
|
||||||
createDraft,
|
createDraft,
|
||||||
getDocumentTemplate,
|
getDocumentTemplate,
|
||||||
|
previewDraft,
|
||||||
publishDraft,
|
publishDraft,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
} from "../../lib/api/documents.js";
|
} from "../../lib/api/documents.js";
|
||||||
@@ -163,9 +164,20 @@ function DraftCard({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmingPublish, setConfirmingPublish] = useState(false);
|
const [confirmingPublish, setConfirmingPublish] = useState(false);
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Revoga a URL antiga sempre que uma nova pré-visualização substitui a
|
||||||
|
// anterior, e ao fechar o editor/desmontar — sem isso o blob fica
|
||||||
|
// retido na memória do navegador indefinidamente.
|
||||||
|
return () => {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
};
|
||||||
|
}, [previewUrl]);
|
||||||
|
|
||||||
function openEditorFor(version: DocumentTemplateVersion) {
|
function openEditorFor(version: DocumentTemplateVersion) {
|
||||||
const content = version.editor_content as { plainText?: string } | undefined;
|
const content = version.editor_content as { plainText?: string } | undefined;
|
||||||
setPlainText(content?.plainText ?? "");
|
setPlainText(content?.plainText ?? "");
|
||||||
@@ -216,6 +228,23 @@ function DraftCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePreview() {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setError(null);
|
||||||
|
setPreviewing(true);
|
||||||
|
try {
|
||||||
|
const url = await previewDraft(templateId, { plainText, cssContent: cssContent || undefined }, accessToken);
|
||||||
|
setPreviewUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return url;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Não foi possível gerar a pré-visualização.");
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePublish() {
|
async function handlePublish() {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -287,7 +316,10 @@ function DraftCard({
|
|||||||
placeholder={"Prezado(a) {{nome_cliente}},\n\nEste contrato refere-se a {{plano_contratado}}..."}
|
placeholder={"Prezado(a) {{nome_cliente}},\n\nEste contrato refere-se a {{plano_contratado}}..."}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-default">
|
<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.
|
Use <code>{"{{variavel}}"}</code> para marcar trechos que serão substituídos na geração do documento. Use
|
||||||
|
"Pré-visualizar (PDF)" abaixo pra ver o resultado real (renderizado no servidor) antes de publicar — cada
|
||||||
|
variável aparece como <code>[variavel]</code>, já que ainda não há um cliente/revenda selecionado nesta
|
||||||
|
tela.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -305,6 +337,9 @@ function DraftCard({
|
|||||||
<Button type="submit" loading={saving}>
|
<Button type="submit" loading={saving}>
|
||||||
Salvar rascunho
|
Salvar rascunho
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" loading={previewing} onClick={handlePreview}>
|
||||||
|
Pré-visualizar (PDF)
|
||||||
|
</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => setEditorOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setEditorOpen(false)}>
|
||||||
Fechar
|
Fechar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -315,6 +350,26 @@ function DraftCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{previewUrl ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-title">Pré-visualização</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Fechar pré-visualização
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<iframe src={previewUrl} title="Pré-visualização do documento" className="h-[600px] w-full rounded-md border border-border-color" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{confirmingPublish ? (
|
{confirmingPublish ? (
|
||||||
<Alert variant="warning" title="Publicar esta versão?">
|
<Alert variant="warning" title="Publicar esta versão?">
|
||||||
<p className="mb-3">
|
<p className="mb-3">
|
||||||
|
|||||||
@@ -830,3 +830,24 @@ Data: 2026-09-18. Usuário mandou uma captura de tela de como o legado montava e
|
|||||||
|
|
||||||
### Achado não resolvido (fora de escopo, registrado pelo agente)
|
### 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).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user