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 { TicketFormPage } from "./routes/support/TicketFormPage.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() {
return (
@@ -112,6 +121,18 @@ export function App() {
<Route path="/support/tickets" element={<TicketsListPage />} />
<Route path="/support/tickets/new" element={<TicketFormPage />} />
<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>
</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: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" },
{ key: "documents", label: "Documentos", href: "/documents/templates" },
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
{ 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) }));
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",
};