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:
@@ -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>();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user