From 012c00fa4d2e5349d3262bc21ec8cdf6c88e2dab Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Fri, 4 Sep 2026 06:14:23 -0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20telas=20de=20Documentos=20e=20Pont?= =?UTF-8?q?o=20Eletr=C3=B4nico=20=E2=80=94=20cobertura=20completa=20do=20E?= =?UTF-8?q?DEN=20Core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ú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 --- apps/core-web/src/App.tsx | 21 ++ apps/core-web/src/lib/api/documents.ts | 197 ++++++++++ apps/core-web/src/lib/api/hr-timeclock.ts | 327 ++++++++++++++++ .../src/routes/AuthenticatedLayout.tsx | 2 + .../routes/documents/GenerationFormPage.tsx | 350 ++++++++++++++++++ .../routes/documents/GenerationsListPage.tsx | 152 ++++++++ .../routes/documents/TemplateDetailPage.tsx | 337 +++++++++++++++++ .../src/routes/documents/TemplateFormPage.tsx | 111 ++++++ .../routes/documents/TemplatesListPage.tsx | 112 ++++++ .../src/routes/documents/documentLabels.ts | 25 ++ .../src/routes/hr-timeclock/AfdImportPage.tsx | 199 ++++++++++ .../hr-timeclock/AfdRecordsListPage.tsx | 184 +++++++++ .../routes/hr-timeclock/DeviceFormPage.tsx | 304 +++++++++++++++ .../routes/hr-timeclock/DevicesListPage.tsx | 112 ++++++ .../routes/hr-timeclock/HrTimeclockSubNav.tsx | 44 +++ .../routes/hr-timeclock/hrTimeclockStatus.ts | 74 ++++ docs/progress.md | 26 ++ 17 files changed, 2577 insertions(+) create mode 100644 apps/core-web/src/lib/api/documents.ts create mode 100644 apps/core-web/src/lib/api/hr-timeclock.ts create mode 100644 apps/core-web/src/routes/documents/GenerationFormPage.tsx create mode 100644 apps/core-web/src/routes/documents/GenerationsListPage.tsx create mode 100644 apps/core-web/src/routes/documents/TemplateDetailPage.tsx create mode 100644 apps/core-web/src/routes/documents/TemplateFormPage.tsx create mode 100644 apps/core-web/src/routes/documents/TemplatesListPage.tsx create mode 100644 apps/core-web/src/routes/documents/documentLabels.ts create mode 100644 apps/core-web/src/routes/hr-timeclock/AfdImportPage.tsx create mode 100644 apps/core-web/src/routes/hr-timeclock/AfdRecordsListPage.tsx create mode 100644 apps/core-web/src/routes/hr-timeclock/DeviceFormPage.tsx create mode 100644 apps/core-web/src/routes/hr-timeclock/DevicesListPage.tsx create mode 100644 apps/core-web/src/routes/hr-timeclock/HrTimeclockSubNav.tsx create mode 100644 apps/core-web/src/routes/hr-timeclock/hrTimeclockStatus.ts diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index ff7471b..09a6cbc 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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() { } /> } /> } /> + + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> diff --git a/apps/core-web/src/lib/api/documents.ts b/apps/core-web/src/lib/api/documents.ts new file mode 100644 index 0000000..4001f38 --- /dev/null +++ b/apps/core-web/src/lib/api/documents.ts @@ -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; + html_content: string | null; + css_content: string | null; + page_config: Record | 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; +} + +export interface CreateGenerationInput { + templateId: string; + clientRegistrationId?: string; + resellerId?: string; + context: Record; +} + +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; + 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 { + return apiRequest("/documents/templates", { method: "GET" }, { accessToken }); +} + +export function getDocumentTemplate(id: string, accessToken: string): Promise { + return apiRequest(`/documents/templates/${id}`, { method: "GET" }, { accessToken }); +} + +export function createDocumentTemplate( + input: CreateDocumentTemplateInput, + accessToken: string, +): Promise { + return apiRequest( + "/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 { + return apiRequest( + `/documents/templates/${templateId}/draft`, + { method: "POST" }, + { accessToken }, + ); +} + +export function updateDraft( + templateId: string, + input: UpdateDraftInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 { + return apiRequest( + `/documents/templates/${templateId}/publish`, + { method: "POST" }, + { accessToken }, + ); +} + +export function listGenerations(accessToken: string): Promise { + return apiRequest("/documents/generations", { method: "GET" }, { accessToken }); +} + +export function createGeneration( + input: CreateGenerationInput, + accessToken: string, +): Promise { + return apiRequest( + "/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(); + 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); +} diff --git a/apps/core-web/src/lib/api/hr-timeclock.ts b/apps/core-web/src/lib/api/hr-timeclock.ts new file mode 100644 index 0000000..05de2aa --- /dev/null +++ b/apps/core-web/src/lib/api/hr-timeclock.ts @@ -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 & { active?: boolean }; + +export function listTimeclockDevices(accessToken: string, includeInactive?: boolean): Promise { + const query = includeInactive ? "?includeInactive=true" : ""; + return apiRequest(`/hr-timeclock/devices${query}`, { method: "GET" }, { accessToken }); +} + +export function getTimeclockDevice(id: string, accessToken: string): Promise { + return apiRequest(`/hr-timeclock/devices/${id}`, { method: "GET" }, { accessToken }); +} + +export function createTimeclockDevice( + input: CreateTimeclockDeviceInput, + accessToken: string, +): Promise { + return apiRequest( + "/hr-timeclock/devices", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function updateTimeclockDevice( + id: string, + input: UpdateTimeclockDeviceInput, + accessToken: string, +): Promise { + return apiRequest( + `/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; + 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; + byValidationStatus: Record; + 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 { + 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 { + return apiRequest(`/hr-timeclock/afd-imports/${id}`, { method: "GET" }, { accessToken }); +} + +/** + * Reads a `File` chosen via `` 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 { + 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; + 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 { + 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( + `/hr-timeclock/afd-records${query ? `?${query}` : ""}`, + { method: "GET" }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 01ac51c..08ad72f 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { diff --git a/apps/core-web/src/routes/documents/GenerationFormPage.tsx b/apps/core-web/src/routes/documents/GenerationFormPage.tsx new file mode 100644 index 0000000..1295f46 --- /dev/null +++ b/apps/core-web/src/routes/documents/GenerationFormPage.tsx @@ -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` + * sent to `POST /documents/generations`. + */ +export function GenerationFormPage() { + const { accessToken } = useAuth(); + const navigate = useNavigate(); + + const [templates, setTemplates] = useState(null); + const [clientRegistrations, setClientRegistrations] = useState([]); + const [resellers, setResellers] = useState([]); + const [loadError, setLoadError] = useState(null); + + const [templateId, setTemplateId] = useState(""); + const [templateVariables, setTemplateVariables] = useState([]); + const [loadingVariables, setLoadingVariables] = useState(false); + + const [originType, setOriginType] = useState(""); + const [originId, setOriginId] = useState(""); + + const [variableValues, setVariableValues] = useState>({}); + const [manualRows, setManualRows] = useState([{ key: "", value: "" }]); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(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) { + 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 ( +
+ + {result.deduped ? ( + + Uma geração equivalente já existia — reaproveitada em vez de criar uma nova. + + ) : ( + + Documento gerado com sucesso. + + )} + {result.missingVariables.length > 0 ? ( + + {result.missingVariables.join(", ")} + + ) : null} + + +
+ + +
+ + Ver todas as gerações + + +
+
+ ); + } + + return ( +
+ + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!templates ? ( +

Carregando templates, clientes e revendas…

+ ) : ( +
+ { + setOriginType(e.target.value as OriginType); + setOriginId(""); + }} + required + /> + {originType === "cliente" ? ( + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={originId} + onChange={(e) => setOriginId(e.target.value)} + required + /> + ) : null} +
+
+ +
+

Contexto (variáveis)

+ {loadingVariables ? ( +

Carregando variáveis do template…

+ ) : templateVariables.length > 0 ? ( +
+ {templateVariables.map((variable) => ( + setVariableValues((prev) => ({ ...prev, [variable]: e.target.value }))} + /> + ))} +
+ ) : ( +
+

+ {templateId + ? "Nenhuma variável detectada automaticamente no template — preencha manualmente chave/valor." + : "Selecione um template para detectar as variáveis automaticamente, ou preencha manualmente."} +

+ {manualRows.map((row, index) => ( +
+ updateManualRow(index, { key: e.target.value })} + /> + updateManualRow(index, { value: e.target.value })} + /> + {manualRows.length > 1 ? ( + + ) : null} +
+ ))} + +
+ )} +
+ +
+ + +
+ + )} + + + ); +} diff --git a/apps/core-web/src/routes/documents/GenerationsListPage.tsx b/apps/core-web/src/routes/documents/GenerationsListPage.tsx new file mode 100644 index 0000000..ee6b0b4 --- /dev/null +++ b/apps/core-web/src/routes/documents/GenerationsListPage.tsx @@ -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(null); + const [templates, setTemplates] = useState([]); + const [clientRegistrations, setClientRegistrations] = useState([]); + const [resellers, setResellers] = useState([]); + const [error, setError] = useState(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> = [ + { + key: "template", + header: "Template", + render: (generation) => ( +
+

{templateById.get(generation.template_id)?.name ?? "—"}

+

+ {templateById.get(generation.template_id)?.key ?? generation.template_id} +

+
+ ), + }, + { + key: "origin", + header: "Origem", + render: (generation) => { + if (generation.client_registration_id) { + const registration = clientById.get(generation.client_registration_id); + return ( +
+ Cliente +

+ {registration ? clientName(registration) : generation.client_registration_id} +

+
+ ); + } + if (generation.reseller_id) { + const reseller = resellerById.get(generation.reseller_id); + return ( +
+ Revenda +

{reseller?.name ?? generation.reseller_id}

+
+ ); + } + return "—"; + }, + }, + { + key: "status", + header: "Status", + render: (generation) => ( + + {GENERATION_STATUS_LABEL[generation.status]} + + ), + }, + { + key: "createdAt", + header: "Data", + render: (generation) => formatDateTimeBR(generation.created_at), + }, + ]; + + return ( +
+ + Gerar documento + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {generations === null && !error ? ( +

Carregando…

+ ) : ( + generation.id} + emptyMessage="Nenhum documento gerado ainda." + /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/documents/TemplateDetailPage.tsx b/apps/core-web/src/routes/documents/TemplateDetailPage.tsx new file mode 100644 index 0000000..1bdad64 --- /dev/null +++ b/apps/core-web/src/routes/documents/TemplateDetailPage.tsx @@ -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 `