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:
2026-09-18 23:59:02 -03:00
parent 8ab18ababc
commit 553c0d9533
7 changed files with 222 additions and 5 deletions

View File

@@ -208,6 +208,41 @@ export async function downloadGenerationPdf(id: string, accessToken: string, fil
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. */
export function extractTemplateVariables(plainText: string): string[] {
const found = new Set<string>();

View File

@@ -7,6 +7,7 @@ import { ApiError } from "../../lib/api/http.js";
import {
createDraft,
getDocumentTemplate,
previewDraft,
publishDraft,
updateDraft,
} from "../../lib/api/documents.js";
@@ -163,9 +164,20 @@ function DraftCard({
const [saving, setSaving] = useState(false);
const [confirmingPublish, setConfirmingPublish] = 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 [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) {
const content = version.editor_content as { plainText?: string } | undefined;
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() {
if (!accessToken) return;
setError(null);
@@ -287,7 +316,10 @@ function DraftCard({
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.
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>
</div>
@@ -305,6 +337,9 @@ function DraftCard({
<Button type="submit" loading={saving}>
Salvar rascunho
</Button>
<Button type="button" variant="secondary" loading={previewing} onClick={handlePreview}>
Pré-visualizar (PDF)
</Button>
<Button type="button" variant="secondary" onClick={() => setEditorOpen(false)}>
Fechar
</Button>
@@ -315,6 +350,26 @@ function DraftCard({
) : null}
</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 ? (
<Alert variant="warning" title="Publicar esta versão?">
<p className="mb-3">