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:
@@ -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>
|
||||
|
||||
197
apps/core-web/src/lib/api/documents.ts
Normal file
197
apps/core-web/src/lib/api/documents.ts
Normal 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);
|
||||
}
|
||||
327
apps/core-web/src/lib/api/hr-timeclock.ts
Normal file
327
apps/core-web/src/lib/api/hr-timeclock.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
350
apps/core-web/src/routes/documents/GenerationFormPage.tsx
Normal file
350
apps/core-web/src/routes/documents/GenerationFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/core-web/src/routes/documents/GenerationsListPage.tsx
Normal file
152
apps/core-web/src/routes/documents/GenerationsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
337
apps/core-web/src/routes/documents/TemplateDetailPage.tsx
Normal file
337
apps/core-web/src/routes/documents/TemplateDetailPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createDraft,
|
||||
getDocumentTemplate,
|
||||
publishDraft,
|
||||
updateDraft,
|
||||
} from "../../lib/api/documents.js";
|
||||
import type { DocumentTemplateVersion, DocumentTemplateWithVersions } from "../../lib/api/documents.js";
|
||||
import { formatDateTimeBR } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import { DOC_GROUP_LABEL, DOC_GROUP_VARIANT } from "./documentLabels.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/documentRepository.tsx`
|
||||
* (breadcrumb + title/status-badge header, bordered white "detail" cards)
|
||||
* and `.tmp/theme-reference/documentWorkflows.tsx` for the
|
||||
* draft-in-progress/active status pill language — the theme's Ant Design
|
||||
* table + off-canvas modals were not usable here, so the draft editor is a
|
||||
* plain in-page card (textarea + Save/Publish), same composition style as
|
||||
* `ContractDetailPage`'s `StatusTransitionCard`.
|
||||
*
|
||||
* No rich Tiptap editor and no PDF in this leva: `editorContent` is always
|
||||
* `{ type: "doc", plainText }` and the only free-form field is a `<textarea>`
|
||||
* with `{{variavel}}` placeholders. `html_content` is the server-computed
|
||||
* preview of that merge, rendered read-only.
|
||||
*/
|
||||
export function TemplateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [template, setTemplate] = useState<DocumentTemplateWithVersions | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getDocumentTemplate(id, accessToken)
|
||||
.then((result) => setTemplate(result))
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o template."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Template" />
|
||||
<p className="text-sm text-default">Carregando template…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !template) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Template" />
|
||||
<Alert variant="error">{error ?? "Template não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const publishedVersion = template.versions.find((v) => v.id === template.current_version_id) ?? null;
|
||||
const draftVersion = template.versions.find((v) => v.status === "draft") ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Documentos", href: "/documents/templates" }, { label: template.name }]} />
|
||||
<PageHeader
|
||||
title={template.name}
|
||||
description={<span className="font-mono text-xs">{template.key}</span>}
|
||||
actions={
|
||||
<>
|
||||
<Badge variant={DOC_GROUP_VARIANT[template.doc_group]}>{DOC_GROUP_LABEL[template.doc_group]}</Badge>
|
||||
<Badge variant={template.active ? "success" : "neutral"}>{template.active ? "Ativo" : "Inativo"}</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
<Card title="Dados do template">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Key" value={template.key} />
|
||||
<Row label="Grupo" value={DOC_GROUP_LABEL[template.doc_group]} />
|
||||
<Row label="Ativo" value={template.active ? "Sim" : "Não"} />
|
||||
<Row label="Criado em" value={formatDateTimeBR(template.created_at)} />
|
||||
<Row label="Atualizado em" value={formatDateTimeBR(template.updated_at)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Versão publicada atual">
|
||||
{publishedVersion ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Versão" value={`v${publishedVersion.version}`} />
|
||||
<Row label="Publicado em" value={formatDateTimeBR(publishedVersion.published_at)} />
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-sm text-default">Nenhuma versão publicada ainda.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card title="Preview da versão publicada">
|
||||
{publishedVersion?.html_content ? (
|
||||
<div
|
||||
className="rounded-md border border-border-color p-4 text-sm leading-relaxed text-title [&_p]:mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: publishedVersion.html_content }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-default">
|
||||
Nenhuma versão publicada ainda — publique um rascunho para gerar o preview.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<DraftCard
|
||||
templateId={template.id}
|
||||
draftVersion={draftVersion}
|
||||
accessToken={accessToken}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-default">{label}</dt>
|
||||
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftCard({
|
||||
templateId,
|
||||
draftVersion,
|
||||
accessToken,
|
||||
onChanged,
|
||||
}: {
|
||||
templateId: string;
|
||||
draftVersion: DocumentTemplateVersion | null;
|
||||
accessToken: string | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [plainText, setPlainText] = useState("");
|
||||
const [cssContent, setCssContent] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmingPublish, setConfirmingPublish] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
function openEditorFor(version: DocumentTemplateVersion) {
|
||||
const content = version.editor_content as { plainText?: string } | undefined;
|
||||
setPlainText(content?.plainText ?? "");
|
||||
setCssContent(version.css_content ?? "");
|
||||
setEditorOpen(true);
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
}
|
||||
|
||||
async function handleCreateDraft() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const version = await createDraft(templateId, accessToken);
|
||||
openEditorFor(version);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
setError("Já existe um rascunho para este template — recarregando para editá-lo.");
|
||||
onChanged();
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível criar o rascunho.");
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSaveSuccess(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateDraft(
|
||||
templateId,
|
||||
{ editorContent: { type: "doc", plainText }, cssContent: cssContent || undefined },
|
||||
accessToken,
|
||||
);
|
||||
setSaveSuccess(true);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o rascunho.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setPublishing(true);
|
||||
try {
|
||||
await publishDraft(templateId, accessToken);
|
||||
setEditorOpen(false);
|
||||
setConfirmingPublish(false);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível publicar o rascunho.");
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorOpen) {
|
||||
return (
|
||||
<Card title="Rascunho">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{draftVersion ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-default">
|
||||
Existe um rascunho em edição (v{draftVersion.version}, criado em{" "}
|
||||
{formatDateTimeBR(draftVersion.created_at)}).
|
||||
</p>
|
||||
<Button onClick={() => openEditorFor(draftVersion)}>Editar rascunho existente</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-default">Nenhum rascunho em edição no momento.</p>
|
||||
<Button onClick={handleCreateDraft} loading={creating}>
|
||||
Criar rascunho
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Editar rascunho">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{saveSuccess ? (
|
||||
<Alert variant="success" className="mb-3">
|
||||
Rascunho salvo.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSave}>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="draft-plain-text">
|
||||
Conteúdo do documento
|
||||
</label>
|
||||
<textarea
|
||||
id="draft-plain-text"
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 font-mono text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={14}
|
||||
value={plainText}
|
||||
onChange={(e) => setPlainText(e.target.value)}
|
||||
placeholder={"Prezado(a) {{nome_cliente}},\n\nEste contrato refere-se a {{plano_contratado}}..."}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default">
|
||||
Use <code>{"{{variavel}}"}</code> para marcar trechos que serão substituídos na geração do documento.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-semibold text-title">CSS (avançado, opcional)</summary>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-border-color bg-white px-3 py-2.5 font-mono text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={6}
|
||||
value={cssContent}
|
||||
onChange={(e) => setCssContent(e.target.value)}
|
||||
/>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border-color pt-4">
|
||||
<Button type="submit" loading={saving}>
|
||||
Salvar rascunho
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setEditorOpen(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
{!confirmingPublish ? (
|
||||
<Button type="button" variant="danger" onClick={() => setConfirmingPublish(true)}>
|
||||
Publicar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{confirmingPublish ? (
|
||||
<Alert variant="warning" title="Publicar esta versão?">
|
||||
<p className="mb-3">
|
||||
Ao publicar, esta versão passa a ser a versão ativa do template e não poderá mais ser editada — a
|
||||
publicação é permanente.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="danger" onClick={handlePublish} loading={publishing}>
|
||||
Confirmar publicação
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setConfirmingPublish(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
111
apps/core-web/src/routes/documents/TemplateFormPage.tsx
Normal file
111
apps/core-web/src/routes/documents/TemplateFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/core-web/src/routes/documents/TemplatesListPage.tsx
Normal file
112
apps/core-web/src/routes/documents/TemplatesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/core-web/src/routes/documents/documentLabels.ts
Normal file
25
apps/core-web/src/routes/documents/documentLabels.ts
Normal 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",
|
||||
};
|
||||
199
apps/core-web/src/routes/hr-timeclock/AfdImportPage.tsx
Normal file
199
apps/core-web/src/routes/hr-timeclock/AfdImportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/core-web/src/routes/hr-timeclock/AfdRecordsListPage.tsx
Normal file
184
apps/core-web/src/routes/hr-timeclock/AfdRecordsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
304
apps/core-web/src/routes/hr-timeclock/DeviceFormPage.tsx
Normal file
304
apps/core-web/src/routes/hr-timeclock/DeviceFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/core-web/src/routes/hr-timeclock/DevicesListPage.tsx
Normal file
112
apps/core-web/src/routes/hr-timeclock/DevicesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/core-web/src/routes/hr-timeclock/HrTimeclockSubNav.tsx
Normal file
44
apps/core-web/src/routes/hr-timeclock/HrTimeclockSubNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/core-web/src/routes/hr-timeclock/hrTimeclockStatus.ts
Normal file
74
apps/core-web/src/routes/hr-timeclock/hrTimeclockStatus.ts
Normal 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",
|
||||
};
|
||||
Reference in New Issue
Block a user