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,197 @@
/**
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
*
* This leva has NO real visual editor and NO real PDF: `editorContent` is a
* simplified JSONB with a single root-level `plainText` string carrying
* `{{variavel}}` placeholders (no rich node tree), and "generation" just
* merges variables into `renderedHtml` — `pdf_s3_key`/`pdf_sha256`/
* `pdf_size_bytes` are always `null` here. Don't build UI implying more.
*
* Like Contracts/Resellers, template/version/generation responses are
* snake_case straight off the DB row — except the two computed fields on a
* generation response (`missingVariables`, `deduped`), which come back
* camelCase exactly as documented in the task brief; mirrored as-is below,
* not "fixed" to snake_case.
*/
import { apiRequest } from "./http.js";
export type DocGroup = "cliente" | "revenda";
export type DocumentVersionStatus = "draft" | "published";
export type GenerationStatus = "generating" | "final" | "error";
/**
* Regex the server validates `key` against — confirmed live against the API
* ("key deve começar com letra minúscula e conter apenas letras
* minúsculas, números e underscore"): starts with a lowercase letter,
* followed by lowercase letters/digits/underscore, e.g.
* `client_software_contract`. Shown as a hint in the create form.
*/
export const DOCUMENT_TEMPLATE_KEY_PATTERN = /^[a-z][a-z0-9_]*$/;
/** Simplified editor content for this leva — always `{ type: "doc", plainText }`, never a rich node tree. */
export interface SimpleEditorContent {
type: "doc";
plainText: string;
}
/** Response shape for `GET /documents/templates` (list) and the bare fields of the detail response. */
export interface DocumentTemplate {
id: string;
created_at: string;
updated_at: string;
key: string;
name: string;
doc_group: DocGroup;
active: boolean;
current_version_id: string | null;
created_by: string | null;
}
export interface DocumentTemplateVersion {
id: string;
template_id: string;
version: number;
status: DocumentVersionStatus;
editor_content: SimpleEditorContent | Record<string, unknown>;
html_content: string | null;
css_content: string | null;
page_config: Record<string, unknown> | null;
created_by: string | null;
published_at: string | null;
published_by: string | null;
created_at: string;
}
/** Response shape for `GET /documents/templates/:id` — adds the version list. */
export interface DocumentTemplateWithVersions extends DocumentTemplate {
versions: DocumentTemplateVersion[];
}
/** Body of `POST /documents/templates` — matches `CreateDocumentTemplateDto` exactly. */
export interface CreateDocumentTemplateInput {
key: string;
name: string;
docGroup: DocGroup;
}
/** Body of `PATCH /documents/templates/:id/draft` — matches `UpdateDraftDto`. `pageConfig` is deliberately omitted from the UI. */
export interface UpdateDraftInput {
editorContent?: SimpleEditorContent;
cssContent?: string;
pageConfig?: Record<string, unknown>;
}
export interface CreateGenerationInput {
templateId: string;
clientRegistrationId?: string;
resellerId?: string;
context: Record<string, string>;
}
export interface DocumentGeneration {
id: string;
created_at: string;
template_id: string;
template_version_id: string;
client_registration_id: string | null;
reseller_id: string | null;
render_context: Record<string, unknown>;
rendered_html: string;
pdf_s3_key: string | null;
pdf_sha256: string | null;
pdf_size_bytes: number | null;
status: GenerationStatus;
error_message: string | null;
generated_by: string | null;
}
/**
* Response of `POST /documents/generations` only — confirmed against the
* live API that `GET /documents/generations` (list) does NOT include these
* two computed fields, so they're kept off the bare `DocumentGeneration`
* shared by both responses. camelCase per API contract, not "fixed" to
* snake_case.
*/
export interface DocumentGenerationResult extends DocumentGeneration {
/** Variables referenced in the template but absent from the submitted `context`. */
missingVariables: string[];
/** `true` when an existing generation was reused instead of creating a new one. */
deduped: boolean;
}
export function listDocumentTemplates(accessToken: string): Promise<DocumentTemplate[]> {
return apiRequest<DocumentTemplate[]>("/documents/templates", { method: "GET" }, { accessToken });
}
export function getDocumentTemplate(id: string, accessToken: string): Promise<DocumentTemplateWithVersions> {
return apiRequest<DocumentTemplateWithVersions>(`/documents/templates/${id}`, { method: "GET" }, { accessToken });
}
export function createDocumentTemplate(
input: CreateDocumentTemplateInput,
accessToken: string,
): Promise<DocumentTemplate> {
return apiRequest<DocumentTemplate>(
"/documents/templates",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Only succeeds if the template has no draft yet — 409 (`ApiError.status === 409`) when one already exists. */
export function createDraft(templateId: string, accessToken: string): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/draft`,
{ method: "POST" },
{ accessToken },
);
}
export function updateDraft(
templateId: string,
input: UpdateDraftInput,
accessToken: string,
): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/draft`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Marks the current draft as published — permanent, the resulting version is never editable again. */
export function publishDraft(templateId: string, accessToken: string): Promise<DocumentTemplateVersion> {
return apiRequest<DocumentTemplateVersion>(
`/documents/templates/${templateId}/publish`,
{ method: "POST" },
{ accessToken },
);
}
export function listGenerations(accessToken: string): Promise<DocumentGeneration[]> {
return apiRequest<DocumentGeneration[]>("/documents/generations", { method: "GET" }, { accessToken });
}
export function createGeneration(
input: CreateGenerationInput,
accessToken: string,
): Promise<DocumentGenerationResult> {
return apiRequest<DocumentGenerationResult>(
"/documents/generations",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** 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>();
const pattern = /\{\{(\w+)\}\}/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(plainText)) !== null) {
const variable = match[1];
if (variable) found.add(variable);
}
return Array.from(found);
}

View File

@@ -0,0 +1,327 @@
/**
* Client for `apps/api` `/v1/hr-timeclock/*` (see
* apps/api/src/hr-timeclock/{timeclock-devices,afd-imports,afd-records}.controller.ts,
* their DTOs, and packages/database/src/repositories/hr-timeclock.ts for the
* source of truth — shapes below are mirrored, not invented).
*
* eden.md módulo 6 — this leva covers only the Control iD device registry and
* AFD (Arquivo Fonte de Dados) raw import/read. Apuração/fechamento/banco de
* horas do not exist yet.
*/
import { API_BASE_URL, ApiError, apiRequest } from "./http.js";
// ── Devices ────────────────────────────────────────────────────────────────
export type TimeclockDeviceStatus = "UNKNOWN" | "ONLINE" | "OFFLINE" | "ERROR";
/**
* Response shape — snake_case, exactly as returned by `TimeclockDevicesService.toView`.
* `password_encrypted` is NEVER included, not even encrypted — only the
* `hasCredential` boolean flag is exposed.
*/
export interface TimeclockDevice {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
company_id: string | null;
name: string;
description: string | null;
branch: string | null;
host: string;
port: number;
use_https: boolean;
username: string;
serial_number: string | null;
model: string | null;
firmware_version: string | null;
timezone: string;
status: TimeclockDeviceStatus;
last_seen_at: string | null;
last_sync_at: string | null;
last_error: string | null;
last_afd_nsr: string | null;
active: boolean;
hasCredential: boolean;
}
/** Request body — camelCase, matches `CreateTimeclockDeviceDto` exactly. */
export interface CreateTimeclockDeviceInput {
companyId?: string;
name: string;
description?: string;
branch?: string;
host: string;
port?: number;
useHttps?: boolean;
username: string;
/** Plaintext — encrypted server-side, never returned afterwards. */
password: string;
timezone?: string;
}
/**
* Matches `UpdateTimeclockDeviceDto`: everything optional, plus `active`.
* `password` is only re-encrypted/changed when present — omit it (never
* send an empty string) to leave the current credential untouched.
*/
export type UpdateTimeclockDeviceInput = Partial<CreateTimeclockDeviceInput> & { active?: boolean };
export function listTimeclockDevices(accessToken: string, includeInactive?: boolean): Promise<TimeclockDevice[]> {
const query = includeInactive ? "?includeInactive=true" : "";
return apiRequest<TimeclockDevice[]>(`/hr-timeclock/devices${query}`, { method: "GET" }, { accessToken });
}
export function getTimeclockDevice(id: string, accessToken: string): Promise<TimeclockDevice> {
return apiRequest<TimeclockDevice>(`/hr-timeclock/devices/${id}`, { method: "GET" }, { accessToken });
}
export function createTimeclockDevice(
input: CreateTimeclockDeviceInput,
accessToken: string,
): Promise<TimeclockDevice> {
return apiRequest<TimeclockDevice>(
"/hr-timeclock/devices",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateTimeclockDevice(
id: string,
input: UpdateTimeclockDeviceInput,
accessToken: string,
): Promise<TimeclockDevice> {
return apiRequest<TimeclockDevice>(
`/hr-timeclock/devices/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
// ── AFD imports ──────────────────────────────────────────────────────────
export type AfdImportBatchStatus = "VALID" | "WARNING" | "INVALID";
export interface AfdImportBatch {
id: string;
device_id: string;
company_id: string | null;
source: "manual_upload";
file_hash: string;
file_size: number;
record_count: number;
inserted_count: number;
valid_count: number;
warning_count: number;
invalid_count: number;
unsupported_count: number;
parse_failure_count: number;
status: AfdImportBatchStatus;
first_nsr: string | null;
last_nsr: string | null;
summary: Record<string, unknown>;
imported_by: string | null;
imported_at: string;
}
/** Mirrors `AfdParseSummary` from apps/api/src/hr-timeclock/afd-parser.ts. */
export interface AfdParseSummary {
totalLines: number;
parsedCount: number;
failureCount: number;
byRecordType: Record<string, number>;
byValidationStatus: Record<string, number>;
firstNsr: number | null;
lastNsr: number | null;
}
export interface CreateAfdImportInput {
deviceId: string;
/** Full raw file content, decoded as ISO-8859-1/latin1 — see `readFileAsLatin1` below. */
fileContent: string;
}
/**
* Successful import (first time this exact file content — by SHA-256 —
* was submitted).
*/
export interface AfdImportSuccess {
alreadyImported: false;
batch: AfdImportBatch;
summary: AfdParseSummary;
}
/**
* `AfdImportsService.import` rejects a duplicate file (same SHA-256) with a
* 409 `ConflictException` carrying `{ message, alreadyImported: true,
* importId, status }` in its body — it does NOT return 200 with a summary,
* unlike a naive reading of the response shape might suggest. This client
* normalizes both outcomes into one `AfdImportResult` union so the UI never
* has to special-case a thrown error for the expected "already imported"
* path.
*/
export interface AfdImportAlreadyImported {
alreadyImported: true;
importId: string;
status: AfdImportBatchStatus;
message: string;
}
export type AfdImportResult = AfdImportSuccess | AfdImportAlreadyImported;
interface AfdImportConflictBody {
message?: string;
alreadyImported?: boolean;
importId?: string;
status?: AfdImportBatchStatus;
}
/**
* Posts an AFD import. Handled with a dedicated `fetch` (rather than the
* shared `apiRequest`) because the "already imported" case is a 409 whose
* body carries structured fields (`importId`, `status`) that the generic
* `ApiError` (message + status only) would otherwise discard — callers need
* `importId` to link to the existing batch.
*/
export async function createAfdImport(input: CreateAfdImportInput, accessToken: string): Promise<AfdImportResult> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/hr-timeclock/afd-imports`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(input),
});
} catch {
throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente.");
}
const text = await response.text();
const body = text ? JSON.parse(text) : undefined;
if (response.status === 409) {
const conflict = body as AfdImportConflictBody | undefined;
if (conflict?.alreadyImported && conflict.importId && conflict.status) {
return {
alreadyImported: true,
importId: conflict.importId,
status: conflict.status,
message: conflict.message ?? "Este arquivo já foi importado anteriormente.",
};
}
}
if (!response.ok) {
const message =
body && typeof body === "object" && typeof (body as { message?: unknown }).message === "string"
? (body as { message: string }).message
: "Ocorreu um erro inesperado ao importar o arquivo.";
throw new ApiError(response.status, message);
}
return { alreadyImported: false, ...(body as { batch: AfdImportBatch; summary: AfdParseSummary }) };
}
export function getAfdImportBatch(id: string, accessToken: string): Promise<AfdImportBatch> {
return apiRequest<AfdImportBatch>(`/hr-timeclock/afd-imports/${id}`, { method: "GET" }, { accessToken });
}
/**
* Reads a `File` chosen via `<input type="file">` and decodes it as
* ISO-8859-1 (latin1) — NEVER `file.text()` / `FileReader.readAsText`
* without an explicit encoding, since both default to UTF-8. The real AFD
* file (Portaria MTP 671/2021) is always latin1; decoding it as UTF-8
* silently corrupts any byte ≥ 0x80 and makes the server's CRC-16/KERMIT
* validation legitimately fail for the affected lines (see
* apps/api/src/hr-timeclock/afd-parser.ts's header comment and
* CreateAfdImportDto.fileContent's doc comment — this is a real,
* non-obvious transport detail, not a style preference).
*/
export async function readFileAsLatin1(file: File): Promise<string> {
const buffer = await new Response(file).arrayBuffer();
return new TextDecoder("iso-8859-1").decode(buffer);
}
// ── AFD records (read-only) ─────────────────────────────────────────────
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
/** AFD record types this leva supports listing/filtering by ("8" does not exist in the layout). */
export const AFD_RECORD_TYPES = ["1", "2", "3", "4", "5", "6", "7", "9"] as const;
export type AfdRecordType = (typeof AFD_RECORD_TYPES)[number];
/**
* `afd_records` is IMUTÁVEL por lei (eden.md módulo 6 §12) — this type has
* no corresponding create/update input on purpose. The API itself has no
* POST/PATCH/PUT/DELETE route for it (rows only ever come from
* `createAfdImport` above) and the DB additionally rejects UPDATE/DELETE
* via trigger. Never add an edit/delete action for this data in the UI.
*/
export interface AfdRecord {
id: string;
created_at: string;
afd_import_batch_id: string;
device_id: string;
company_id: string | null;
nsr: string | null;
record_type: string;
event_datetime: string | null;
event_datetime_raw: string | null;
employee_cpf_raw: string | null;
employee_id: string | null;
raw_line: string;
crc_received: string | null;
crc_calculated: string | null;
crc_valid: AfdCrcValidity;
parsed_data: Record<string, unknown>;
validation_status: AfdValidationStatus;
validation_notes: string | null;
}
export interface ListAfdRecordsFilters {
deviceId?: string;
employeeId?: string;
recordType?: AfdRecordType;
/** Inclusive lower bound (ISO date/datetime string). */
dateFrom?: string;
/** Inclusive upper bound at the DAY level. */
dateTo?: string;
limit?: number;
offset?: number;
}
/**
* `AfdRecordsService.list` (apps/api/src/hr-timeclock/afd-records.service.ts)
* returns a paginated envelope, NOT a bare array — verified against the
* running API: `{ records, total, limit, offset }` (default `limit` 50 when
* unset). Do not simplify this back to `AfdRecord[]`, the endpoint really
* wraps it.
*/
export interface ListAfdRecordsResult {
records: AfdRecord[];
total: number;
limit: number;
offset: number;
}
export function listAfdRecords(accessToken: string, filters?: ListAfdRecordsFilters): Promise<ListAfdRecordsResult> {
const params = new URLSearchParams();
if (filters?.deviceId) params.set("deviceId", filters.deviceId);
if (filters?.employeeId) params.set("employeeId", filters.employeeId);
if (filters?.recordType) params.set("recordType", filters.recordType);
if (filters?.dateFrom) params.set("dateFrom", filters.dateFrom);
if (filters?.dateTo) params.set("dateTo", filters.dateTo);
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
const query = params.toString();
return apiRequest<ListAfdRecordsResult>(
`/hr-timeclock/afd-records${query ? `?${query}` : ""}`,
{ method: "GET" },
{ accessToken },
);
}