feat(web): telas de Suporte e Fiscal no EDEN Core

Continuação direta, sem parar para autorização. 2 subagentes
eden-frontend em paralelo.

- Suporte: lista de tickets, criação, detalhe com painel de SLA
  (prazos, cumprido/violado/pendente, aviso "SLA pausado"), thread de
  comentários públicos/internos, histórico de status, transições
  limitadas aos alvos válidos com campos condicionais de causa raiz/
  resolução ao fechar.
- Fiscal: NCM/CFOP/Municípios (CRUD simples), Naturezas de Operação
  (só leitura, seed fixo), nova seção "Fiscal" em ProductFormPage
  (só ao editar) com aviso de que um novo perfil do mesmo componente
  substitui automaticamente o anterior — invariante confirmada ao vivo
  pelo próprio agente antes de entregar.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/
ProductFormPage.tsx/format.ts entre os 2 agentes. Ambos restauraram a
base manualmente (sem git merge) — diff -rq contra main confirmou zero
desvio de restauração nos dois casos antes de aceitar qualquer arquivo
como novo.

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); trilha de auditoria conferida no Postgres.

Com esta leva, o EDEN Core tem UI real para praticamente todo o
backend construído até agora, exceto HR-Timeclock e Documentos.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 05:53:20 -03:00
parent 7021994d8e
commit 828e6e8ddb
17 changed files with 2420 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
/**
* Client for `apps/api` `/v1/fiscal/*` — Módulo Fiscal MVP (5 tabelas de ~19 do
* legado: NCM, CFOP, Municípios, Naturezas de operação [somente leitura, seed
* fixo] e Perfil fiscal de produto). Shapes mirrored from the DTOs/entities
* documented for this leva, not invented.
*/
import { apiRequest } from "./http.js";
/** Response shape — snake_case, exactly as returned by the `fiscal_ncm` table. */
export interface FiscalNcm {
id: string;
code: string;
description: string;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: string | null;
metadata: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
/** Response shape — snake_case, exactly as returned by the `fiscal_cfop` table. */
export interface FiscalCfop {
id: string;
code: string;
description: string;
direction: string | null;
scope: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: string | null;
metadata: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
/** Response shape — snake_case, exactly as returned by the `fiscal_municipality` table. */
export interface FiscalMunicipality {
id: string;
code: string;
name: string;
state: string;
active: boolean;
source: string | null;
source_version: string | null;
created_at: string;
updated_at: string;
}
/** Read-only — seed fixo de 11 naturezas de operação. */
export interface FiscalOperationNature {
id: string;
code: string;
name: string;
description: string | null;
active: boolean;
created_at: string;
updated_at: string;
}
export type BillingComponent = "RECURRING" | "IMPLEMENTATION";
export type FiscalDocumentType = "NFCOM" | "NFSE" | "NFE" | "NONE";
/** Response shape — snake_case, exactly as returned by the `product_fiscal_profile` table. */
export interface ProductFiscalProfile {
id: string;
product_id: string;
billing_component: BillingComponent;
document_type: FiscalDocumentType;
ncm_id: string | null;
cfop_id: string | null;
municipality_id: string | null;
operation_nature_id: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
}
/** Request body — camelCase, matches `CreateNcmDto`/`UpdateNcmDto`. */
export interface CreateNcmInput {
code: string;
description: string;
validFrom?: string;
validUntil?: string;
active?: boolean;
source?: string;
sourceVersion?: string;
sourceUpdatedAt?: string;
metadata?: Record<string, unknown>;
}
export type UpdateNcmInput = Partial<CreateNcmInput>;
/** Request body — camelCase, matches `CreateCfopDto`/`UpdateCfopDto`. */
export interface CreateCfopInput {
code: string;
description: string;
direction?: string;
scope?: string;
validFrom?: string;
validUntil?: string;
active?: boolean;
source?: string;
sourceVersion?: string;
sourceUpdatedAt?: string;
metadata?: Record<string, unknown>;
}
export type UpdateCfopInput = Partial<CreateCfopInput>;
/**
* Request body — camelCase, matches `CreateMunicipalityDto`/`UpdateMunicipalityDto`.
* No `validFrom`/`validUntil`/`metadata`: um município não expira (eden.md).
*/
export interface CreateMunicipalityInput {
code: string;
name: string;
/** UF — exatamente 2 caracteres. */
state: string;
active?: boolean;
source?: string;
sourceVersion?: string;
}
export type UpdateMunicipalityInput = Partial<CreateMunicipalityInput>;
/** Request body — camelCase, matches `CreateProductFiscalProfileDto`. */
export interface CreateProductFiscalProfileInput {
billingComponent: BillingComponent;
documentType?: FiscalDocumentType;
ncmId?: string;
cfopId?: string;
municipalityId?: string;
operationNatureId?: string;
validFrom?: string;
validUntil?: string;
}
// ---- NCM ----
export function listNcm(accessToken: string): Promise<FiscalNcm[]> {
return apiRequest<FiscalNcm[]>("/fiscal/ncm", { method: "GET" }, { accessToken });
}
export function createNcm(input: CreateNcmInput, accessToken: string): Promise<FiscalNcm> {
return apiRequest<FiscalNcm>("/fiscal/ncm", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateNcm(id: string, input: UpdateNcmInput, accessToken: string): Promise<FiscalNcm> {
return apiRequest<FiscalNcm>(`/fiscal/ncm/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken });
}
// ---- CFOP ----
export function listCfop(accessToken: string): Promise<FiscalCfop[]> {
return apiRequest<FiscalCfop[]>("/fiscal/cfop", { method: "GET" }, { accessToken });
}
export function createCfop(input: CreateCfopInput, accessToken: string): Promise<FiscalCfop> {
return apiRequest<FiscalCfop>("/fiscal/cfop", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateCfop(id: string, input: UpdateCfopInput, accessToken: string): Promise<FiscalCfop> {
return apiRequest<FiscalCfop>(`/fiscal/cfop/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken });
}
// ---- Municípios ----
export function listMunicipalities(accessToken: string): Promise<FiscalMunicipality[]> {
return apiRequest<FiscalMunicipality[]>("/fiscal/municipalities", { method: "GET" }, { accessToken });
}
export function createMunicipality(
input: CreateMunicipalityInput,
accessToken: string,
): Promise<FiscalMunicipality> {
return apiRequest<FiscalMunicipality>(
"/fiscal/municipalities",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateMunicipality(
id: string,
input: UpdateMunicipalityInput,
accessToken: string,
): Promise<FiscalMunicipality> {
return apiRequest<FiscalMunicipality>(
`/fiscal/municipalities/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---- Naturezas de operação (somente leitura) ----
export function listOperationNatures(accessToken: string): Promise<FiscalOperationNature[]> {
return apiRequest<FiscalOperationNature[]>("/fiscal/operation-natures", { method: "GET" }, { accessToken });
}
// ---- Perfil fiscal de produto ----
export function listProductFiscalProfiles(
productId: string,
accessToken: string,
): Promise<ProductFiscalProfile[]> {
return apiRequest<ProductFiscalProfile[]>(
`/fiscal/products/${productId}/profiles`,
{ method: "GET" },
{ accessToken },
);
}
export function createProductFiscalProfile(
productId: string,
input: CreateProductFiscalProfileInput,
accessToken: string,
): Promise<ProductFiscalProfile> {
return apiRequest<ProductFiscalProfile>(
`/fiscal/products/${productId}/profiles`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,174 @@
/**
* Client for `apps/api` `/v1/support/tickets` (see
* apps/api/src/support/support.controller.ts, support.service.ts, dto/*,
* and the corresponding `packages/database` repository for the source of
* truth — shapes below are mirrored, not invented).
*
* Like Contracts/Quotes, the top-level response is the DB row passed
* straight through — snake_case, no repository-level mapper.
*/
import { apiRequest } from "./http.js";
export const TICKET_PRIORITIES = ["low", "medium", "high", "critical"] as const;
export type TicketPriority = (typeof TICKET_PRIORITIES)[number];
export type TicketStatus =
| "new"
| "triage"
| "in_progress"
| "waiting_customer"
| "waiting_third_party"
| "resolved"
| "closed"
| "cancelled";
/**
* Valid `toStatus` targets from the current status — terminal statuses map
* to `[]`. Mirrors the backend's own status machine exactly — the server
* is the real enforcement (409 on an invalid transition), this only
* drives which buttons the UI offers.
*/
export const TICKET_STATUS_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
new: ["triage", "cancelled"],
triage: ["in_progress", "cancelled"],
in_progress: ["waiting_customer", "waiting_third_party", "resolved", "cancelled"],
waiting_customer: ["in_progress", "cancelled"],
waiting_third_party: ["in_progress", "cancelled"],
resolved: ["closed", "cancelled"],
closed: [],
cancelled: [],
};
/** `SupportTicket` — response shape for `GET /support/tickets` (list) and `PATCH :id/status` (bare row, no comments/history/SLA). */
export interface SupportTicket {
id: string;
created_at: string;
updated_at: string;
protocol_number: string;
title: string;
description: string;
category: string;
subcategory: string | null;
priority: TicketPriority;
status: TicketStatus;
reseller_id: string | null;
client_registration_id: string | null;
contract_id: string | null;
owner_user_id: string | null;
opened_by_user_id: string | null;
root_cause: string | null;
resolution_notes: string | null;
first_response_at: string | null;
resolved_at: string | null;
closed_at: string | null;
}
export interface TicketComment {
id: string;
author_user_id: string | null;
body: string;
is_internal: boolean;
created_at: string;
}
export interface TicketStatusHistoryEntry {
id: string;
from_status: TicketStatus | null;
to_status: TicketStatus;
reason: string | null;
changed_by: string | null;
changed_at: string;
}
/**
* `slaTimer` — `null` when no SLA policy applies to this ticket. `_met`
* fields: `null` = event hasn't happened yet, `true`/`false` = met or
* violated. `paused_since` non-null means the timer is currently paused
* (ticket in `waiting_customer`/`waiting_third_party`).
*/
export interface TicketSlaTimer {
ticket_id: string;
first_response_due_at: string;
resolution_due_at: string;
first_response_met: boolean | null;
resolution_met: boolean | null;
paused_since: string | null;
accumulated_pause_seconds: number;
}
/** Response shape for `GET /support/tickets/:id` and `POST /support/tickets` — adds detail arrays + SLA timer. */
export interface TicketWithDetails extends SupportTicket {
comments: TicketComment[];
statusHistory: TicketStatusHistoryEntry[];
slaTimer: TicketSlaTimer | null;
}
/** Body of `POST /support/tickets` — matches `CreateTicketDto`. */
export interface CreateTicketInput {
title: string;
description: string;
category: string;
subcategory?: string;
priority?: TicketPriority;
/** Only actually applied server-side if the actor holds `all` edit scope — safe to always show and let the backend decide/ignore it. */
resellerId?: string;
clientRegistrationId?: string;
contractId?: string;
ownerUserId?: string;
}
/** Body of `PATCH /support/tickets/:id/status` — matches `UpdateTicketStatusDto`. `rootCause`/`resolutionNotes` only have effect when `toStatus === "closed"`. */
export interface UpdateTicketStatusInput {
toStatus: TicketStatus;
reason?: string;
rootCause?: string;
resolutionNotes?: string;
}
/** Body of `POST /support/tickets/:id/comments` — matches `CreateTicketCommentDto`. */
export interface CreateTicketCommentInput {
body: string;
isInternal?: boolean;
}
export function listTickets(accessToken: string): Promise<SupportTicket[]> {
return apiRequest<SupportTicket[]>("/support/tickets", { method: "GET" }, { accessToken });
}
export function getTicket(id: string, accessToken: string): Promise<TicketWithDetails> {
return apiRequest<TicketWithDetails>(`/support/tickets/${id}`, { method: "GET" }, { accessToken });
}
export function createTicket(input: CreateTicketInput, accessToken: string): Promise<TicketWithDetails> {
return apiRequest<TicketWithDetails>(
"/support/tickets",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Returns only the bare `SupportTicket` row (no comments/statusHistory/slaTimer) — reload via `getTicket` to refresh those. */
export function updateTicketStatus(
id: string,
input: UpdateTicketStatusInput,
accessToken: string,
): Promise<SupportTicket> {
return apiRequest<SupportTicket>(
`/support/tickets/${id}/status`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Returns only the newly created `TicketComment` row — reload via `getTicket` to refresh the thread. */
export function addTicketComment(
id: string,
input: CreateTicketCommentInput,
accessToken: string,
): Promise<TicketComment> {
return apiRequest<TicketComment>(
`/support/tickets/${id}/comments`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -21,6 +21,14 @@ export function formatDateBR(value: string | Date | null | undefined): string {
return new Intl.DateTimeFormat("pt-BR").format(date);
}
/** Same as `formatDateBR` but includes the time — for timestamps where the hour matters (e.g. SLA due dates). */
export function formatDateTimeBR(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(date);
}
/** CNPJ (14 digits) or CPF (11 digits) mask — falls back to the raw value for anything else. */
export function formatDocument(value: string | null | undefined): string {
if (!value) return "—";