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:
220
apps/core-web/src/lib/api/fiscal.ts
Normal file
220
apps/core-web/src/lib/api/fiscal.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
174
apps/core-web/src/lib/api/support.ts
Normal file
174
apps/core-web/src/lib/api/support.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@@ -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 "—";
|
||||
|
||||
Reference in New Issue
Block a user