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

@@ -44,6 +44,15 @@ import { OperationNaturesListPage } from "./routes/fiscal/OperationNaturesListPa
import { TicketsListPage } from "./routes/support/TicketsListPage.js"; import { TicketsListPage } from "./routes/support/TicketsListPage.js";
import { TicketFormPage } from "./routes/support/TicketFormPage.js"; import { TicketFormPage } from "./routes/support/TicketFormPage.js";
import { TicketDetailPage } from "./routes/support/TicketDetailPage.js"; import { TicketDetailPage } from "./routes/support/TicketDetailPage.js";
import { TemplatesListPage } from "./routes/documents/TemplatesListPage.js";
import { TemplateFormPage } from "./routes/documents/TemplateFormPage.js";
import { TemplateDetailPage } from "./routes/documents/TemplateDetailPage.js";
import { GenerationsListPage } from "./routes/documents/GenerationsListPage.js";
import { GenerationFormPage } from "./routes/documents/GenerationFormPage.js";
import { DevicesListPage } from "./routes/hr-timeclock/DevicesListPage.js";
import { DeviceFormPage } from "./routes/hr-timeclock/DeviceFormPage.js";
import { AfdImportPage } from "./routes/hr-timeclock/AfdImportPage.js";
import { AfdRecordsListPage } from "./routes/hr-timeclock/AfdRecordsListPage.js";
export function App() { export function App() {
return ( return (
@@ -112,6 +121,18 @@ export function App() {
<Route path="/support/tickets" element={<TicketsListPage />} /> <Route path="/support/tickets" element={<TicketsListPage />} />
<Route path="/support/tickets/new" element={<TicketFormPage />} /> <Route path="/support/tickets/new" element={<TicketFormPage />} />
<Route path="/support/tickets/:id" element={<TicketDetailPage />} /> <Route path="/support/tickets/:id" element={<TicketDetailPage />} />
<Route path="/documents/templates" element={<TemplatesListPage />} />
<Route path="/documents/templates/new" element={<TemplateFormPage />} />
<Route path="/documents/templates/:id" element={<TemplateDetailPage />} />
<Route path="/documents/generations" element={<GenerationsListPage />} />
<Route path="/documents/generations/new" element={<GenerationFormPage />} />
<Route path="/hr-timeclock/devices" element={<DevicesListPage />} />
<Route path="/hr-timeclock/devices/new" element={<DeviceFormPage />} />
<Route path="/hr-timeclock/devices/:id/edit" element={<DeviceFormPage />} />
<Route path="/hr-timeclock/afd-imports" element={<AfdImportPage />} />
<Route path="/hr-timeclock/afd-records" element={<AfdRecordsListPage />} />
</Route> </Route>
</Route> </Route>
</Routes> </Routes>

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

View File

@@ -24,10 +24,12 @@ export function AuthenticatedLayout() {
{ key: "quotes", label: "Ofertas", href: "/quotes" }, { key: "quotes", label: "Ofertas", href: "/quotes" },
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" }, { key: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" }, { key: "contracts", label: "Contratos", href: "/contracts" },
{ key: "documents", label: "Documentos", href: "/documents/templates" },
{ key: "inventory", label: "Estoque", href: "/inventory/assets" }, { key: "inventory", label: "Estoque", href: "/inventory/assets" },
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" }, { key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" }, { key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
{ key: "support", label: "Suporte", href: "/support/tickets" }, { key: "support", label: "Suporte", href: "/support/tickets" },
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) })); ].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
async function handleLogout() { async function handleLogout() {

View File

@@ -0,0 +1,350 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import {
createGeneration,
extractTemplateVariables,
getDocumentTemplate,
listDocumentTemplates,
} from "../../lib/api/documents.js";
import type { DocumentGenerationResult, DocumentTemplate } from "../../lib/api/documents.js";
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
type OriginType = "cliente" | "revenda";
function clientName(registration: ClientRegistration): string {
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
return `#${registration.client_code} — ${name ?? "sem nome"}`;
}
interface KeyValueRow {
key: string;
value: string;
}
/**
* Creation only. Context fields are built two ways, per the task brief:
* ideally auto-extracted from the published version's `{{variavel}}`
* placeholders (via `extractTemplateVariables` on the fetched template
* detail's `plainText`) — one labeled `Input` per variable found; falling
* back to a manual key/value editor (add/remove rows) when the template
* has no extractable variables (or the detail fetch/extraction comes back
* empty). Both paths funnel into the same `context: Record<string,string>`
* sent to `POST /documents/generations`.
*/
export function GenerationFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [templates, setTemplates] = useState<DocumentTemplate[] | null>(null);
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
const [resellers, setResellers] = useState<Reseller[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [templateId, setTemplateId] = useState("");
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
const [loadingVariables, setLoadingVariables] = useState(false);
const [originType, setOriginType] = useState<OriginType | "">("");
const [originId, setOriginId] = useState("");
const [variableValues, setVariableValues] = useState<Record<string, string>>({});
const [manualRows, setManualRows] = useState<KeyValueRow[]>([{ key: "", value: "" }]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DocumentGenerationResult | null>(null);
useEffect(() => {
if (!accessToken) return;
setLoadError(null);
Promise.all([listDocumentTemplates(accessToken), listClientRegistrations(accessToken), listResellers(accessToken)])
.then(([templateList, registrationList, resellerList]) => {
setTemplates(templateList);
setClientRegistrations(registrationList);
setResellers(resellerList);
})
.catch((err) =>
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar templates/clientes/revendas."),
);
}, [accessToken]);
// Only published templates (`current_version_id` set) make sense as a generation source.
const publishableTemplates = useMemo(() => (templates ?? []).filter((t) => t.current_version_id), [templates]);
useEffect(() => {
if (!templateId || !accessToken) {
setTemplateVariables([]);
setVariableValues({});
return;
}
let cancelled = false;
setLoadingVariables(true);
getDocumentTemplate(templateId, accessToken)
.then((detail) => {
if (cancelled) return;
const published = detail.versions.find((v) => v.id === detail.current_version_id);
const content = published?.editor_content as { plainText?: string } | undefined;
const variables = content?.plainText ? extractTemplateVariables(content.plainText) : [];
setTemplateVariables(variables);
setVariableValues(Object.fromEntries(variables.map((v) => [v, ""])));
})
.catch(() => {
// Extraction is a convenience — on failure, fall back silently to the manual key/value editor.
if (!cancelled) {
setTemplateVariables([]);
setVariableValues({});
}
})
.finally(() => {
if (!cancelled) setLoadingVariables(false);
});
return () => {
cancelled = true;
};
}, [templateId, accessToken]);
function updateManualRow(index: number, patch: Partial<KeyValueRow>) {
setManualRows((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
function addManualRow() {
setManualRows((prev) => [...prev, { key: "", value: "" }]);
}
function removeManualRow(index: number) {
setManualRows((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
if (!templateId) {
setError("Selecione o template.");
return;
}
if (!originType || !originId) {
setError("Selecione a origem (cliente ou revenda).");
return;
}
const context =
templateVariables.length > 0
? variableValues
: Object.fromEntries(
manualRows
.filter((row) => row.key.trim().length > 0)
.map((row) => [row.key.trim(), row.value]),
);
setSubmitting(true);
try {
const generation = await createGeneration(
{
templateId,
clientRegistrationId: originType === "cliente" ? originId : undefined,
resellerId: originType === "revenda" ? originId : undefined,
context,
},
accessToken,
);
setResult(generation);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível gerar o documento.");
} finally {
setSubmitting(false);
}
}
if (result) {
return (
<div>
<PageHeader title="Documento gerado" />
{result.deduped ? (
<Alert variant="info" className="mb-4">
Uma geração equivalente já existia — reaproveitada em vez de criar uma nova.
</Alert>
) : (
<Alert variant="success" className="mb-4">
Documento gerado com sucesso.
</Alert>
)}
{result.missingVariables.length > 0 ? (
<Alert variant="warning" className="mb-4" title="Variáveis sem valor no contexto">
{result.missingVariables.join(", ")}
</Alert>
) : null}
<Card title="HTML gerado">
<div
className="rounded-md border border-border-color p-4 text-sm leading-relaxed text-title [&_p]:mb-2"
dangerouslySetInnerHTML={{ __html: result.rendered_html }}
/>
</Card>
<div className="mt-4 flex items-center gap-2">
<Link to="/documents/generations" className={PRIMARY_LINK_BUTTON_CLASS}>
Ver todas as gerações
</Link>
<Button type="button" variant="secondary" onClick={() => navigate("/documents/generations/new")}>
Gerar outro documento
</Button>
</div>
</div>
);
}
return (
<div>
<Breadcrumb items={[{ label: "Documentos gerados", href: "/documents/generations" }, { label: "Gerar documento" }]} />
<PageHeader title="Gerar documento" description="A partir de um template publicado" />
{loadError ? (
<Alert variant="error" className="mb-4">
{loadError}
</Alert>
) : null}
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{!templates ? (
<p className="text-sm text-default">Carregando templates, clientes e revendas…</p>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<Select
label="Template"
options={publishableTemplates.map((t) => ({ value: t.id, label: `${t.name} (${t.key})` }))}
placeholder={
publishableTemplates.length === 0
? "Nenhum template publicado disponível"
: "Selecione o template publicado"
}
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
required
disabled={publishableTemplates.length === 0}
/>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Origem</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Tipo de origem"
options={[
{ value: "cliente", label: "Cliente" },
{ value: "revenda", label: "Revenda" },
]}
placeholder="Selecione o tipo"
value={originType}
onChange={(e) => {
setOriginType(e.target.value as OriginType);
setOriginId("");
}}
required
/>
{originType === "cliente" ? (
<Select
label="Cliente"
options={clientRegistrations.map((c) => ({ value: c.id, label: clientName(c) }))}
placeholder="Selecione o cliente"
value={originId}
onChange={(e) => setOriginId(e.target.value)}
required
/>
) : originType === "revenda" ? (
<Select
label="Revenda"
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
placeholder="Selecione a revenda"
value={originId}
onChange={(e) => setOriginId(e.target.value)}
required
/>
) : null}
</div>
</div>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Contexto (variáveis)</h2>
{loadingVariables ? (
<p className="text-sm text-default">Carregando variáveis do template…</p>
) : templateVariables.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{templateVariables.map((variable) => (
<Input
key={variable}
label={`{{${variable}}}`}
value={variableValues[variable] ?? ""}
onChange={(e) => setVariableValues((prev) => ({ ...prev, [variable]: e.target.value }))}
/>
))}
</div>
) : (
<div className="space-y-3">
<p className="text-xs text-default">
{templateId
? "Nenhuma variável detectada automaticamente no template — preencha manualmente chave/valor."
: "Selecione um template para detectar as variáveis automaticamente, ou preencha manualmente."}
</p>
{manualRows.map((row, index) => (
<div key={index} className="flex items-end gap-2">
<Input
label="Chave"
value={row.key}
onChange={(e) => updateManualRow(index, { key: e.target.value })}
/>
<Input
label="Valor"
value={row.value}
onChange={(e) => updateManualRow(index, { value: e.target.value })}
/>
{manualRows.length > 1 ? (
<button
type="button"
onClick={() => removeManualRow(index)}
className="mb-1 rounded-md border border-border-color px-2 py-2 text-xs font-semibold text-danger hover:bg-light"
>
Remover
</button>
) : null}
</div>
))}
<button
type="button"
onClick={addManualRow}
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
>
+ Adicionar campo
</button>
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-border-color pt-5">
<Button type="submit" loading={submitting}>
Gerar documento
</Button>
<Button type="button" variant="secondary" onClick={() => navigate("/documents/generations")}>
Cancelar
</Button>
</div>
</form>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Badge, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js";
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { formatDateTimeBR } from "../../lib/format.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import { GENERATION_STATUS_LABEL, GENERATION_STATUS_VARIANT } from "./documentLabels.js";
function clientName(registration: ClientRegistration): string {
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
return name ?? `Cadastro #${registration.client_code}`;
}
/**
* Read-only list — no edit here (generations aren't editable per the API
* contract). Layout matches `TemplatesListPage`/`ContractsListPage`:
* bordered white card, flush `Table`, colored status badge.
*/
export function GenerationsListPage() {
const { accessToken } = useAuth();
const [generations, setGenerations] = useState<DocumentGeneration[] | null>(null);
const [templates, setTemplates] = useState<DocumentTemplate[]>([]);
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
const [resellers, setResellers] = useState<Reseller[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
Promise.all([
listGenerations(accessToken),
listDocumentTemplates(accessToken),
listClientRegistrations(accessToken),
listResellers(accessToken),
])
.then(([generationList, templateList, registrationList, resellerList]) => {
if (cancelled) return;
setGenerations(generationList);
setTemplates(templateList);
setClientRegistrations(registrationList);
setResellers(resellerList);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar as gerações.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const templateById = useMemo(() => new Map(templates.map((t) => [t.id, t])), [templates]);
const clientById = useMemo(() => new Map(clientRegistrations.map((c) => [c.id, c])), [clientRegistrations]);
const resellerById = useMemo(() => new Map(resellers.map((r) => [r.id, r])), [resellers]);
const columns: Array<TableColumn<DocumentGeneration>> = [
{
key: "template",
header: "Template",
render: (generation) => (
<div>
<p className="font-semibold text-title">{templateById.get(generation.template_id)?.name ?? "—"}</p>
<p className="font-mono text-xs text-default">
{templateById.get(generation.template_id)?.key ?? generation.template_id}
</p>
</div>
),
},
{
key: "origin",
header: "Origem",
render: (generation) => {
if (generation.client_registration_id) {
const registration = clientById.get(generation.client_registration_id);
return (
<div>
<Badge variant="primary">Cliente</Badge>
<p className="mt-1 text-xs text-default">
{registration ? clientName(registration) : generation.client_registration_id}
</p>
</div>
);
}
if (generation.reseller_id) {
const reseller = resellerById.get(generation.reseller_id);
return (
<div>
<Badge variant="info">Revenda</Badge>
<p className="mt-1 text-xs text-default">{reseller?.name ?? generation.reseller_id}</p>
</div>
);
}
return "—";
},
},
{
key: "status",
header: "Status",
render: (generation) => (
<Badge variant={GENERATION_STATUS_VARIANT[generation.status]}>
{GENERATION_STATUS_LABEL[generation.status]}
</Badge>
),
},
{
key: "createdAt",
header: "Data",
render: (generation) => formatDateTimeBR(generation.created_at),
},
];
return (
<div>
<PageHeader
title="Documentos gerados"
description="Gerações de documentos a partir de templates publicados"
actions={
<Link to="/documents/generations/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Gerar documento
</Link>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{generations === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table
columns={columns}
data={generations ?? []}
rowKey={(generation) => generation.id}
emptyMessage="Nenhum documento gerado ainda."
/>
)}
</Card>
</div>
);
}

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

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { createDocumentTemplate, DOCUMENT_TEMPLATE_KEY_PATTERN } from "../../lib/api/documents.js";
import type { DocGroup } from "../../lib/api/documents.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import { DOC_GROUP_LABEL } from "./documentLabels.js";
const DOC_GROUP_OPTIONS: { value: DocGroup; label: string }[] = (
Object.keys(DOC_GROUP_LABEL) as DocGroup[]
).map((value) => ({ value, label: DOC_GROUP_LABEL[value] }));
/** Creation only — key/name/docGroup, no editor content here (that's set on the draft, in `TemplateDetailPage`). */
export function TemplateFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [key, setKey] = useState("");
const [name, setName] = useState("");
const [docGroup, setDocGroup] = useState<DocGroup | "">("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const keyValid = key.length === 0 || DOCUMENT_TEMPLATE_KEY_PATTERN.test(key);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
if (!DOCUMENT_TEMPLATE_KEY_PATTERN.test(key)) {
setError(
"A key deve começar com letra minúscula e conter apenas letras minúsculas, números e underscore (ex.: client_software_contract).",
);
return;
}
if (!docGroup) {
setError("Selecione o grupo do documento.");
return;
}
setSubmitting(true);
try {
const template = await createDocumentTemplate(
{ key: key.trim(), name: name.trim(), docGroup },
accessToken,
);
navigate(`/documents/templates/${template.id}`, { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar o template.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: "Novo template" }]} />
<PageHeader title="Novo template de documento" description="Templates de contrato e outros documentos" />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
label="Key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="client_software_contract"
error={
!keyValid
? "Deve começar com letra minúscula e conter apenas letras minúsculas, números e underscore."
: undefined
}
hint={
keyValid
? "Deve começar com letra minúscula; apenas letras minúsculas, números e underscore, ex.: client_software_contract."
: undefined
}
required
/>
<Input label="Nome" value={name} onChange={(e) => setName(e.target.value)} required />
<Select
label="Grupo do documento"
options={DOC_GROUP_OPTIONS}
placeholder="Selecione o grupo"
value={docGroup}
onChange={(e) => setDocGroup(e.target.value as DocGroup)}
required
/>
<div className="flex items-center gap-2 border-t border-border-color pt-5">
<Button type="submit" loading={submitting}>
Criar template
</Button>
<Button type="button" variant="secondary" onClick={() => navigate("/documents/templates")}>
Cancelar
</Button>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { listDocumentTemplates } from "../../lib/api/documents.js";
import type { DocumentTemplate } from "../../lib/api/documents.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import { DOC_GROUP_LABEL, DOC_GROUP_VARIANT } from "./documentLabels.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/documentRepository.tsx`
* (header with title + "Add New" action, flush data table in a bordered
* white card, colored status pill) — same shape already used by
* `ContractsListPage`/`QuotesListPage`. The theme's Ant Design table,
* dropdown-menu row actions, and modals were not carried over — rebuilt
* with `@eden/ui` + plain Tailwind, row click navigates to the detail page
* instead.
*/
export function TemplatesListPage() {
const { accessToken } = useAuth();
const [templates, setTemplates] = useState<DocumentTemplate[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listDocumentTemplates(accessToken)
.then((result) => {
if (!cancelled) setTemplates(result);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os templates.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
return (
<div>
<PageHeader
title="Templates de documentos"
description="Templates de contrato e outros documentos gerados a partir de variáveis"
actions={
<Link to="/documents/templates/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Novo template
</Link>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card bodyClassName="p-0">
{templates === null && !error ? (
<p className="p-5 text-sm text-default">Carregando templates…</p>
) : templates && templates.length === 0 ? (
<p className="p-5 text-sm text-default">Nenhum template cadastrado ainda.</p>
) : templates ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="px-5 py-3 font-semibold text-title">Key</th>
<th className="px-5 py-3 font-semibold text-title">Nome</th>
<th className="px-5 py-3 font-semibold text-title">Grupo</th>
<th className="px-5 py-3 font-semibold text-title">Versão publicada</th>
<th className="px-5 py-3 font-semibold text-title">Ativo</th>
</tr>
</thead>
<tbody>
{templates.map((template) => (
<tr key={template.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3">
<Link
to={`/documents/templates/${template.id}`}
className="font-mono text-xs font-semibold text-title hover:text-primary"
>
{template.key}
</Link>
</td>
<td className="px-5 py-3 text-default">{template.name}</td>
<td className="px-5 py-3">
<Badge variant={DOC_GROUP_VARIANT[template.doc_group]}>
{DOC_GROUP_LABEL[template.doc_group]}
</Badge>
</td>
<td className="px-5 py-3">
<Badge variant={template.current_version_id ? "success" : "neutral"}>
{template.current_version_id ? "Sim" : "Não"}
</Badge>
</td>
<td className="px-5 py-3">
<Badge variant={template.active ? "success" : "neutral"}>
{template.active ? "Ativo" : "Inativo"}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { BadgeVariant } from "@eden/ui";
import type { DocGroup, GenerationStatus } from "../../lib/api/documents.js";
/** Shared pt-BR labels/badge colors for `DocGroup` — used by the templates list, form, and generation form. */
export const DOC_GROUP_LABEL: Record<DocGroup, string> = {
cliente: "Cliente",
revenda: "Revenda",
};
export const DOC_GROUP_VARIANT: Record<DocGroup, BadgeVariant> = {
cliente: "primary",
revenda: "info",
};
export const GENERATION_STATUS_LABEL: Record<GenerationStatus, string> = {
generating: "Gerando",
final: "Concluída",
error: "Erro",
};
export const GENERATION_STATUS_VARIANT: Record<GenerationStatus, BadgeVariant> = {
generating: "warning",
final: "success",
error: "danger",
};

View File

@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { Alert, Badge, Button, Card, PageHeader, Select } from "@eden/ui";
import {
createAfdImport,
listTimeclockDevices,
readFileAsLatin1,
} from "../../lib/api/hr-timeclock.js";
import type { AfdImportResult, TimeclockDevice } from "../../lib/api/hr-timeclock.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { HrTimeclockSubNav } from "./HrTimeclockSubNav.js";
import { AFD_BATCH_STATUS_LABEL, AFD_BATCH_STATUS_VARIANT } from "./hrTimeclockStatus.js";
/**
* Layout reference: adapted from the CRUD-form Card pattern shared with
* `DeviceFormPage.tsx` — the DreamsERP theme has no direct "raw file
* upload + parse summary" screen to derive from, so this reuses that
* pattern's Card/Input-adjacent spacing rather than copying JSX from an
* unrelated theme page.
*
* eden.md módulo 6 §4.4/§4.5 — AFD (Arquivo Fonte de Dados) import. This
* leva's transport sends the file content as a JSON string, not a real
* multipart upload (see CreateAfdImportDto's doc comment).
*/
export function AfdImportPage() {
const { accessToken } = useAuth();
const [devices, setDevices] = useState<TimeclockDevice[] | null>(null);
const [devicesError, setDevicesError] = useState<string | null>(null);
const [deviceId, setDeviceId] = useState("");
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AfdImportResult | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listTimeclockDevices(accessToken)
.then((data) => {
if (!cancelled) setDevices(data);
})
.catch((err) => {
if (cancelled) return;
setDevicesError(err instanceof ApiError ? err.message : "Não foi possível carregar os dispositivos.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const deviceOptions = useMemo(
() => (devices ?? []).map((device) => ({ value: device.id, label: `${device.name} — ${device.host}` })),
[devices],
);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
setFile(event.target.files?.[0] ?? null);
setResult(null);
setError(null);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setResult(null);
if (!deviceId) {
setError("Selecione o dispositivo de origem do arquivo.");
return;
}
if (!file) {
setError("Selecione o arquivo AFD (.txt) a importar.");
return;
}
setSubmitting(true);
try {
// ENCODING: the real AFD file (Portaria MTP 671/2021) is always
// ISO-8859-1/latin1, never UTF-8. `readFileAsLatin1` decodes it via
// `new Response(file).arrayBuffer()` + `new TextDecoder("iso-8859-1")`
// — never `file.text()`/`FileReader.readAsText()` without an explicit
// encoding, since both default to UTF-8 and would silently corrupt
// any byte ≥ 0x80, breaking the server's CRC-16/KERMIT validation for
// the affected lines. See lib/api/hr-timeclock.ts for the full note.
const fileContent = await readFileAsLatin1(file);
const imported = await createAfdImport({ deviceId, fileContent }, accessToken);
setResult(imported);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível importar o arquivo AFD.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader
title="Ponto Eletrônico"
description="Importação de arquivo AFD (marcação de ponto bruta) de um dispositivo Control iD."
/>
<HrTimeclockSubNav />
{devicesError ? (
<Alert variant="error" className="mb-4">
{devicesError}
</Alert>
) : null}
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card title="Importar arquivo AFD">
{devices === null && !devicesError ? (
<p className="text-sm text-default">Carregando dispositivos…</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<Select
label="Dispositivo"
placeholder="Selecione o dispositivo"
options={deviceOptions}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
required
/>
<div className="flex flex-col gap-1">
<label htmlFor="afd-file" className="text-sm font-semibold text-gray-900">
Arquivo AFD
</label>
<input
id="afd-file"
type="file"
accept=".txt,.afd"
onChange={handleFileChange}
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title file:mr-3 file:rounded-md file:border-0 file:bg-light file:px-3 file:py-1.5 file:text-sm file:font-semibold file:text-title"
/>
<p className="text-xs text-default">Arquivo bruto exportado do relógio de ponto (formato Portaria MTP 671/2021).</p>
</div>
<div className="flex items-center gap-2 border-t border-border-color pt-5">
<Button type="submit" loading={submitting}>
Importar
</Button>
</div>
</form>
)}
</Card>
{result ? (
<Card title="Resultado da importação" className="mt-4">
{result.alreadyImported ? (
<Alert variant="info" title="Arquivo já importado anteriormente">
{result.message} Lote existente: <span className="font-mono">{result.importId}</span> — status{" "}
<Badge variant={AFD_BATCH_STATUS_VARIANT[result.status]}>{AFD_BATCH_STATUS_LABEL[result.status]}</Badge>
</Alert>
) : (
<div className="space-y-4">
<Alert variant={result.batch.status === "INVALID" ? "error" : result.batch.status === "WARNING" ? "warning" : "success"}>
Importação concluída — status do lote:{" "}
<Badge variant={AFD_BATCH_STATUS_VARIANT[result.batch.status]}>{AFD_BATCH_STATUS_LABEL[result.batch.status]}</Badge>
</Alert>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<SummaryStat label="Total de linhas" value={result.summary.totalLines} />
<SummaryStat label="Linhas processadas" value={result.summary.parsedCount} />
<SummaryStat label="Falha de parse" value={result.summary.failureCount} />
<SummaryStat label="Inseridas" value={result.batch.inserted_count} />
<SummaryStat label="Válidas" value={result.batch.valid_count} />
<SummaryStat label="Com aviso" value={result.batch.warning_count} />
<SummaryStat label="Inválidas" value={result.batch.invalid_count} />
<SummaryStat label="Layout não suportado" value={result.batch.unsupported_count} />
</div>
<p className="text-sm text-default">
NSR: {result.batch.first_nsr ?? "—"} até {result.batch.last_nsr ?? "—"}
</p>
</div>
)}
</Card>
) : null}
</div>
);
}
function SummaryStat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md border border-border-color p-3">
<p className="text-xs font-semibold uppercase text-default">{label}</p>
<p className="text-lg font-bold text-title">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useMemo, useState } from "react";
import { Alert, Badge, Card, PageHeader, Select, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { AFD_RECORD_TYPES, listAfdRecords, listTimeclockDevices } from "../../lib/api/hr-timeclock.js";
import type { AfdRecord, AfdRecordType, ListAfdRecordsResult, TimeclockDevice } from "../../lib/api/hr-timeclock.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { formatDateTimeBR } from "../../lib/format.js";
import { HrTimeclockSubNav } from "./HrTimeclockSubNav.js";
import {
AFD_CRC_LABEL,
AFD_CRC_VARIANT,
AFD_RECORD_TYPE_LABEL,
AFD_VALIDATION_STATUS_LABEL,
AFD_VALIDATION_STATUS_VARIANT,
} from "./hrTimeclockStatus.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/attendance.tsx`
* (a filterable attendance/punch table) adapted to a plain `@eden/ui`
* `Table` + `Select` filters, matching the read-only ledger pattern already
* used by `../inventory/MovementsListPage.tsx` — no antd/Datatable
* dependency, no theme JSX copied.
*
* READ-ONLY by design (eden.md módulo 6 §12): `afd_records` is raw punch
* data, immutable by law — there is intentionally no edit/delete action
* anywhere on this screen, for any role.
*/
export function AfdRecordsListPage() {
const { accessToken } = useAuth();
const [result, setResult] = useState<ListAfdRecordsResult | null>(null);
const [devices, setDevices] = useState<TimeclockDevice[]>([]);
const [deviceFilter, setDeviceFilter] = useState("");
const [recordTypeFilter, setRecordTypeFilter] = useState<AfdRecordType | "">("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listTimeclockDevices(accessToken, true)
.then((data) => {
if (!cancelled) setDevices(data);
})
.catch(() => {
/* device filter is best-effort — the records list below still loads independently */
});
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listAfdRecords(accessToken, {
deviceId: deviceFilter || undefined,
recordType: recordTypeFilter || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
limit: 200,
})
.then((data) => {
if (!cancelled) setResult(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os registros de ponto.");
});
return () => {
cancelled = true;
};
}, [accessToken, deviceFilter, recordTypeFilter, dateFrom, dateTo]);
function deviceName(deviceId: string): string {
return devices.find((d) => d.id === deviceId)?.name ?? deviceId;
}
const deviceOptions = useMemo(() => devices.map((d) => ({ value: d.id, label: d.name })), [devices]);
const recordTypeOptions = useMemo(
() => AFD_RECORD_TYPES.map((type) => ({ value: type, label: AFD_RECORD_TYPE_LABEL[type] ?? type })),
[],
);
const columns: Array<TableColumn<AfdRecord>> = [
{ key: "nsr", header: "NSR", render: (record) => record.nsr ?? "—" },
{ key: "type", header: "Tipo", render: (record) => AFD_RECORD_TYPE_LABEL[record.record_type] ?? record.record_type },
{ key: "device", header: "Dispositivo", render: (record) => deviceName(record.device_id) },
{ key: "event", header: "Data/hora do evento", render: (record) => formatDateTimeBR(record.event_datetime) },
{ key: "cpf", header: "CPF (bruto)", render: (record) => record.employee_cpf_raw ?? "—" },
{
key: "crc",
header: "CRC",
render: (record) => <Badge variant={AFD_CRC_VARIANT[record.crc_valid]}>{AFD_CRC_LABEL[record.crc_valid]}</Badge>,
},
{
key: "validation",
header: "Validação",
render: (record) => (
<Badge variant={AFD_VALIDATION_STATUS_VARIANT[record.validation_status]}>
{AFD_VALIDATION_STATUS_LABEL[record.validation_status]}
</Badge>
),
},
];
return (
<div>
<PageHeader
title="Ponto Eletrônico"
description="Registros brutos de marcação de ponto (AFD) — dado imutável por lei, sem edição."
/>
<HrTimeclockSubNav />
<Card className="mb-4" title="Filtros">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Select
label="Dispositivo"
placeholder="Todos"
options={deviceOptions}
value={deviceFilter}
onChange={(e) => setDeviceFilter(e.target.value)}
/>
<Select
label="Tipo de registro"
placeholder="Todos"
options={recordTypeOptions}
value={recordTypeFilter}
onChange={(e) => setRecordTypeFilter(e.target.value as AfdRecordType | "")}
/>
<div className="flex flex-col gap-1">
<label htmlFor="afd-date-from" className="text-sm font-semibold text-gray-900">
De
</label>
<input
id="afd-date-from"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="afd-date-to" className="text-sm font-semibold text-gray-900">
Até
</label>
<input
id="afd-date-to"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
</Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card
title={result ? `Registros (${result.records.length} de ${result.total})` : "Registros"}
>
{result === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table
columns={columns}
data={result?.records ?? []}
rowKey={(record) => record.id}
emptyMessage="Nenhum registro encontrado."
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,304 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import {
createTimeclockDevice,
getTimeclockDevice,
updateTimeclockDevice,
} from "../../lib/api/hr-timeclock.js";
import type { CreateTimeclockDeviceInput } from "../../lib/api/hr-timeclock.js";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
interface DeviceFormState {
companyId: string;
name: string;
description: string;
branch: string;
host: string;
port: string;
useHttps: boolean;
username: string;
/** Always starts empty, on both create and edit — never pre-filled with a placeholder for the existing credential (the API never returns the password, even encrypted). */
password: string;
timezone: string;
active: boolean;
}
const EMPTY_FORM: DeviceFormState = {
companyId: "",
name: "",
description: "",
branch: "",
host: "",
port: "443",
useHttps: true,
username: "",
password: "",
timezone: "America/Sao_Paulo",
active: true,
};
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/integrationsSettings.tsx`
* (external-integration connection config: host/port/credentials grouped in
* a card) adapted to a plain `@eden/ui` form, matching the CRUD-form pattern
* already used by `../resellers/ResellerFormPage.tsx` — no theme JSX copied.
*
* Reused for both `/hr-timeclock/devices/new` and `/hr-timeclock/devices/:id/edit`.
*/
export function DeviceFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[]>([]);
const [hasCredential, setHasCredential] = useState(false);
const [form, setForm] = useState<DeviceFormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditing);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listCompanies(accessToken)
.then((data) => {
if (!cancelled) setCompanies(data);
})
.catch(() => {
/* company selector is best-effort — companyId is optional on the device */
});
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => {
if (!id || !accessToken) return;
let cancelled = false;
getTimeclockDevice(id, accessToken)
.then((device) => {
if (cancelled) return;
setHasCredential(device.hasCredential);
setForm({
companyId: device.company_id ?? "",
name: device.name,
description: device.description ?? "",
branch: device.branch ?? "",
host: device.host,
port: String(device.port),
useHttps: device.use_https,
username: device.username,
password: "",
timezone: device.timezone,
active: device.active,
});
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar o dispositivo.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, accessToken]);
function updateField<K extends keyof DeviceFormState>(field: K, value: DeviceFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
const companyOptions = useMemo(
() => companies.map((company) => ({ value: company.id, label: company.company_name })),
[companies],
);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
const port = form.port.trim() === "" ? undefined : Number(form.port);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
setError("A porta deve ser um número inteiro entre 1 e 65535.");
return;
}
if (!isEditing && form.password.trim() === "") {
setError("Informe a senha do dispositivo.");
return;
}
setSubmitting(true);
try {
const basePayload: CreateTimeclockDeviceInput = {
companyId: toOptional(form.companyId),
name: form.name.trim(),
description: toOptional(form.description),
branch: toOptional(form.branch),
host: form.host.trim(),
port,
useHttps: form.useHttps,
username: form.username.trim(),
password: form.password, // overwritten/omitted below depending on create vs. edit
timezone: toOptional(form.timezone),
};
if (isEditing && id) {
// password: only sent (and re-encrypted server-side) when the user
// actually typed a new one — an empty field means "keep the current credential".
const { password, ...rest } = basePayload;
await updateTimeclockDevice(
id,
{ ...rest, ...(form.password.trim() !== "" ? { password: form.password } : {}), active: form.active },
accessToken,
);
} else {
await createTimeclockDevice(basePayload, accessToken);
}
navigate("/hr-timeclock/devices", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o dispositivo. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<Breadcrumb
items={[
{ label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
{ label: isEditing ? "Editar dispositivo" : "Novo dispositivo" },
]}
/>
<PageHeader
title={isEditing ? "Editar dispositivo" : "Novo dispositivo"}
description="Cadastro de relógio de ponto Control iD (eden.md módulo 6 §1.1/§2)."
actions={
<>
<Button variant="secondary" onClick={() => navigate("/hr-timeclock/devices")} type="button">
Cancelar
</Button>
<Button type="submit" form="device-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="device-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Identificação">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
<Select
label="Empresa (opcional)"
placeholder="Selecione a empresa"
options={companyOptions}
value={form.companyId}
onChange={(e) => updateField("companyId", e.target.value)}
/>
<Input label="Filial (opcional)" value={form.branch} onChange={(e) => updateField("branch", e.target.value)} />
<div className="sm:col-span-2">
<Input
label="Descrição (opcional)"
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
</div>
</Card>
<Card title="Conexão">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Host / IP" value={form.host} onChange={(e) => updateField("host", e.target.value)} required />
<Input
label="Porta"
type="number"
min={1}
max={65535}
value={form.port}
onChange={(e) => updateField("port", e.target.value)}
/>
<Input
label="Fuso horário"
value={form.timezone}
onChange={(e) => updateField("timezone", e.target.value)}
hint="Padrão: America/Sao_Paulo"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-900">HTTPS</span>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.useHttps}
onChange={(e) => updateField("useHttps", e.target.checked)}
/>
Usar HTTPS para conexão com o dispositivo
</label>
</div>
</div>
</Card>
<Card title="Credenciais">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Usuário" value={form.username} onChange={(e) => updateField("username", e.target.value)} required />
<Input
label={isEditing ? "Nova senha" : "Senha"}
type="password"
value={form.password}
onChange={(e) => updateField("password", e.target.value)}
required={!isEditing}
autoComplete="new-password"
hint={
isEditing
? hasCredential
? "Deixe em branco para manter a credencial atual configurada."
: "Nenhuma credencial configurada ainda — informe uma senha para configurar."
: undefined
}
/>
</div>
</Card>
{isEditing ? (
<Card title="Status">
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.active}
onChange={(e) => updateField("active", e.target.checked)}
/>
Dispositivo ativo
</label>
</Card>
) : null}
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listTimeclockDevices } from "../../lib/api/hr-timeclock.js";
import type { TimeclockDevice } from "../../lib/api/hr-timeclock.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { HrTimeclockSubNav } from "./HrTimeclockSubNav.js";
import { DEVICE_STATUS_LABEL, DEVICE_STATUS_VARIANT } from "./hrTimeclockStatus.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/integrationsSettings.tsx`
* (external-integration config screen: host/credential rows with a status
* indicator) adapted to a plain `@eden/ui` `Table`, matching the CRUD-list
* pattern already used by `../resellers/ResellersListPage.tsx` — no modal/
* antd/toggle-switch dependency, no theme JSX copied.
*/
export function DevicesListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [devices, setDevices] = useState<TimeclockDevice[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listTimeclockDevices(accessToken, true)
.then((data) => {
if (!cancelled) setDevices(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os equipamentos de ponto.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const columns: Array<TableColumn<TimeclockDevice>> = [
{ key: "name", header: "Nome", render: (device) => <span className="font-semibold text-title">{device.name}</span> },
{
key: "host",
header: "Endereço",
render: (device) => `${device.use_https ? "https" : "http"}://${device.host}:${device.port}`,
},
{ key: "username", header: "Usuário", render: (device) => device.username },
{
key: "credential",
header: "Credencial",
render: (device) => (
<Badge variant={device.hasCredential ? "success" : "warning"}>
{device.hasCredential ? "Configurada" : "Não configurada"}
</Badge>
),
},
{
key: "status",
header: "Status",
render: (device) => <Badge variant={DEVICE_STATUS_VARIANT[device.status]}>{DEVICE_STATUS_LABEL[device.status]}</Badge>,
},
{
key: "active",
header: "Ativo",
render: (device) => <Badge variant={device.active ? "success" : "neutral"}>{device.active ? "Sim" : "Não"}</Badge>,
},
{
key: "actions",
header: "",
className: "text-right",
render: (device) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/hr-timeclock/devices/${device.id}/edit`)}>
Editar
</Button>
),
},
];
return (
<div>
<PageHeader
title="Ponto Eletrônico"
description="Equipamentos Control iD cadastrados para importação de ponto (eden.md módulo 6)."
actions={<Button onClick={() => navigate("/hr-timeclock/devices/new")}>Novo dispositivo</Button>}
/>
<HrTimeclockSubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{devices === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table
columns={columns}
data={devices ?? []}
rowKey={(device) => device.id}
emptyMessage="Nenhum dispositivo cadastrado."
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Link, useLocation } from "react-router-dom";
interface SubNavItem {
href: string;
label: string;
}
const ITEMS: SubNavItem[] = [
{ href: "/hr-timeclock/devices", label: "Dispositivos" },
{ href: "/hr-timeclock/afd-imports", label: "Importar AFD" },
{ href: "/hr-timeclock/afd-records", label: "Registros" },
];
/**
* Only one top-level nav slot was allocated for Ponto Eletrônico
* ("Ponto Eletrônico" → /hr-timeclock/devices, per AuthenticatedLayout.tsx)
* but the module has three screens — same in-page tab pattern as
* `../crm/CrmSubNav.tsx` / `../inventory/InventorySubNav.tsx`
* (`inline-flex bg-white border border-border-color rounded-md p-1`,
* derived from the DreamsERP theme's list/grid view switch) lets users move
* between Dispositivos/Importar AFD/Registros without more top-level entries.
*/
export function HrTimeclockSubNav() {
const location = useLocation();
return (
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
{ITEMS.map((item) => {
const active = location.pathname.startsWith(item.href);
return (
<Link
key={item.href}
to={item.href}
className={`rounded px-3 py-1.5 text-sm font-semibold ${
active ? "bg-dark text-white" : "text-default hover:bg-light"
}`}
>
{item.label}
</Link>
);
})}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import type { BadgeVariant } from "@eden/ui";
import type {
AfdCrcValidity,
AfdImportBatchStatus,
AfdValidationStatus,
TimeclockDeviceStatus,
} from "../../lib/api/hr-timeclock.js";
/** Shared pt-BR labels/badge colors for the timeclock device status field. */
export const DEVICE_STATUS_LABEL: Record<TimeclockDeviceStatus, string> = {
UNKNOWN: "Desconhecido",
ONLINE: "Online",
OFFLINE: "Offline",
ERROR: "Erro",
};
export const DEVICE_STATUS_VARIANT: Record<TimeclockDeviceStatus, BadgeVariant> = {
UNKNOWN: "neutral",
ONLINE: "success",
OFFLINE: "neutral",
ERROR: "danger",
};
export const AFD_CRC_LABEL: Record<AfdCrcValidity, string> = {
VALID: "CRC válido",
INVALID: "CRC inválido",
NOT_APPLICABLE: "Sem CRC",
NOT_VALIDATED: "Não validado",
};
export const AFD_CRC_VARIANT: Record<AfdCrcValidity, BadgeVariant> = {
VALID: "success",
INVALID: "danger",
NOT_APPLICABLE: "neutral",
NOT_VALIDATED: "warning",
};
export const AFD_VALIDATION_STATUS_LABEL: Record<AfdValidationStatus, string> = {
VALID: "Válido",
WARNING: "Aviso",
INVALID: "Inválido",
UNSUPPORTED_LAYOUT: "Layout não suportado",
};
export const AFD_VALIDATION_STATUS_VARIANT: Record<AfdValidationStatus, BadgeVariant> = {
VALID: "success",
WARNING: "warning",
INVALID: "danger",
UNSUPPORTED_LAYOUT: "neutral",
};
export const AFD_BATCH_STATUS_LABEL: Record<AfdImportBatchStatus, string> = {
VALID: "Válido",
WARNING: "Com avisos",
INVALID: "Inválido",
};
export const AFD_BATCH_STATUS_VARIANT: Record<AfdImportBatchStatus, BadgeVariant> = {
VALID: "success",
WARNING: "warning",
INVALID: "danger",
};
/** eden.md módulo 6 — AFD record type codes (type "8" does not exist in the layout). */
export const AFD_RECORD_TYPE_LABEL: Record<string, string> = {
"1": "1 — Cabeçalho",
"2": "2 — Alteração de dados",
"3": "3 — Marcação de ponto",
"4": "4 — Ajuste de hora com REP",
"5": "5 — Ajuste de hora sem REP",
"6": "6 — Inclusão de marcação",
"7": "7 — Assinatura da marcação",
"9": "9 — Trailer",
};

View File

@@ -404,3 +404,29 @@ Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`ede
### Pendências ### Pendências
Nenhuma tela ainda para HR-Timeclock/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login. Com esta leva, o EDEN Core tem UI real para praticamente todo o backend construído até agora, exceto HR-Timeclock e Documentos. Nenhuma tela ainda para HR-Timeclock/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login. Com esta leva, o EDEN Core tem UI real para praticamente todo o backend construído até agora, exceto HR-Timeclock e Documentos.
---
## Documentos e Ponto Eletrônico no EDEN Core — cobertura completa do backend construído até agora
Data: 2026-09-04. Última leva desta sequência, sem parar para autorização. 2 subagentes `eden-frontend` em paralelo, referência do tema (`documents/documentRepository.tsx`/`documentWorkflows.tsx` para Documentos; `system/integrationsSettings.tsx` para dispositivos de ponto, `hrm/attendance.tsx` para registros AFD).
### Documentos (`apps/core-web/src/routes/documents/`)
Templates com ciclo draft→publish (edição por textarea de texto com placeholders `{{variavel}}`, já que o editor visual Tiptap não existe nesta leva de backend), preview do HTML publicado, geração de documento com extração automática das variáveis do template via regex para gerar um campo de formulário por variável, mostrando `missingVariables` quando aplicável.
**Achado real do próprio agente, corrigindo meu brief**: a regra de validação de `key` do template aceita minúsculas+dígitos+underscore, não só minúsculas+underscore como eu tinha descrito — corrigido no frontend antes de entregar, confirmado contra a mensagem de erro real do backend.
### Ponto Eletrônico — fundação (`apps/core-web/src/routes/hr-timeclock/`)
Dispositivos Control iD (CRUD, senha nunca retornada — só `hasCredential`, campo de senha vazio na edição significa "manter atual"), import de arquivo AFD (decodificação ISO-8859-1 explícita, não UTF-8, documentada no código — detalhe real e não óbvio), lista só-leitura de registros AFD (sem nenhuma ação de edição, imutabilidade legal respeitada na UI também).
**2 discrepâncias reais encontradas pelo próprio agente entre meu brief e o comportamento real da API, corrigidas antes de entregar**: reimportar o mesmo arquivo retorna **409** com corpo estruturado (`{message, alreadyImported, importId, status}`), não 200 como eu tinha descrito; `GET /hr-timeclock/afd-records` devolve um envelope paginado (`{records, total, limit, offset}`), não um array simples.
### Integração
Ambos os agentes reconciliados manualmente em `App.tsx`/`AuthenticatedLayout.tsx` (Documents entrou primeiro, HR-Timeclock por cima). 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): criação de dispositivo confirmando ausência de senha na resposta, criação de template; trilha de auditoria conferida no Postgres.
### Estado do EDEN Core ao final desta sequência
UI real para: Empresas, Produtos (+ perfil fiscal), Revendas, CRM (Leads/Oportunidades), Ofertas, Cadastro de Cliente, Contratos, Estoque, Faturamento, Suporte, Fiscal, Documentos (fundação), Ponto Eletrônico (fundação). Cobertura completa de todo o backend construído nesta sessão, exceto Assinatura Eletrônica (deliberadamente não construída — módulo próprio, ainda não existe no backend) e apuração/fechamento de ponto (idem). `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela de negócio própria ainda.