feat(web): telas de Documentos e Ponto Eletrônico — cobertura completa do EDEN Core
Última leva desta sequência, sem parar para autorização. 2 subagentes
eden-frontend em paralelo.
- Documentos: templates com ciclo draft→publish (edição por texto com
{{variavel}}, sem editor Tiptap real ainda), preview do HTML
publicado, geração de documento com extração automática de variáveis
via regex e aviso de missingVariables. Achado do agente corrigindo
meu brief: regra de key aceita minúsculas+dígitos+underscore, não só
minúsculas+underscore.
- Ponto Eletrônico (fundação): dispositivos Control iD (senha nunca
retornada, só hasCredential), import de AFD com decodificação
ISO-8859-1 explícita documentada no código, registros AFD só-leitura
(sem nenhuma ação de edição — imutabilidade legal respeitada também
na UI). 2 discrepâncias reais encontradas e corrigidas pelo agente:
reimportar retorna 409 estruturado (não 200), e a listagem de
registros é um envelope paginado (não array simples).
Reconciliação manual de App.tsx/AuthenticatedLayout.tsx entre os 2
agentes. Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001) incluindo confirmação de que a senha do
dispositivo nunca aparece na resposta; trilha de auditoria conferida.
Com esta leva, o EDEN Core tem UI real para todo o backend construído
nesta sessão, exceto Assinatura Eletrônica e apuração/fechamento de
ponto (nenhum dos dois existe no backend ainda).
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
337
apps/core-web/src/routes/documents/TemplateDetailPage.tsx
Normal file
337
apps/core-web/src/routes/documents/TemplateDetailPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createDraft,
|
||||
getDocumentTemplate,
|
||||
publishDraft,
|
||||
updateDraft,
|
||||
} from "../../lib/api/documents.js";
|
||||
import type { DocumentTemplateVersion, DocumentTemplateWithVersions } 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";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function TemplateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [template, setTemplate] = useState<DocumentTemplateWithVersions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getDocumentTemplate(id, accessToken)
|
||||
.then((result) => setTemplate(result))
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o template."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Template" />
|
||||
<p className="text-sm text-default">Carregando template…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Template" />
|
||||
<Alert variant="error">{error ?? "Template não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const publishedVersion = template.versions.find((v) => v.id === template.current_version_id) ?? null;
|
||||
const draftVersion = template.versions.find((v) => v.status === "draft") ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} />
|
||||
<PageHeader
|
||||
title={template.name}
|
||||
description={<span className="font-mono text-xs">{template.key}</span>}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant={DOC_GROUP_VARIANT[template.doc_group]}>{DOC_GROUP_LABEL[template.doc_group]}</Badge>
|
||||
<Badge variant={template.active ? "success" : "neutral"}>{template.active ? "Ativo" : "Inativo"}</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
<Card title="Dados do template">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Key" value={template.key} />
|
||||
<Row label="Grupo" value={DOC_GROUP_LABEL[template.doc_group]} />
|
||||
<Row label="Ativo" value={template.active ? "Sim" : "Não"} />
|
||||
<Row label="Criado em" value={formatDateTimeBR(template.created_at)} />
|
||||
<Row label="Atualizado em" value={formatDateTimeBR(template.updated_at)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Versão publicada atual">
|
||||
{publishedVersion ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Versão" value={`v${publishedVersion.version}`} />
|
||||
<Row label="Publicado em" value={formatDateTimeBR(publishedVersion.published_at)} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-default">Nenhuma versão publicada ainda.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<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"
|
||||
dangerouslySetInnerHTML={{ __html: publishedVersion.html_content }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-default">
|
||||
Nenhuma versão publicada ainda — publique um rascunho para gerar o preview.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<DraftCard
|
||||
templateId={template.id}
|
||||
draftVersion={draftVersion}
|
||||
accessToken={accessToken}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-default">{label}</dt>
|
||||
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
templateId,
|
||||
draftVersion,
|
||||
accessToken,
|
||||
onChanged,
|
||||
}: {
|
||||
templateId: string;
|
||||
draftVersion: DocumentTemplateVersion | null;
|
||||
accessToken: string | null;
|
||||
onChanged: () => 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();
|
||||
} 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();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDraft(
|
||||
templateId,
|
||||
{ editorContent: { type: "doc", plainText }, cssContent: cssContent || undefined },
|
||||
accessToken,
|
||||
);
|
||||
setSaveSuccess(true);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o rascunho.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setPublishing(true);
|
||||
try {
|
||||
await publishDraft(templateId, accessToken);
|
||||
setEditorOpen(false);
|
||||
setConfirmingPublish(false);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível publicar o rascunho.");
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
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="space-y-3">
|
||||
<p className="text-sm text-default">Nenhum rascunho em edição no momento.</p>
|
||||
<Button onClick={handleCreateDraft} loading={creating}>
|
||||
Criar rascunho
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Editar rascunho">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{saveSuccess ? (
|
||||
<Alert variant="success" className="mb-3">
|
||||
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?">
|
||||
<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.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="danger" onClick={handlePublish} loading={publishing}>
|
||||
Confirmar publicação
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setConfirmingPublish(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user