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:
2026-09-04 06:14:23 -03:00
parent 828e6e8ddb
commit 012c00fa4d
17 changed files with 2577 additions and 0 deletions

View 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>
);
}