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:
@@ -37,6 +37,13 @@ import { BillingRunsListPage } from "./routes/billing/BillingRunsListPage.js";
|
||||
import { BillingRunFormPage } from "./routes/billing/BillingRunFormPage.js";
|
||||
import { BillingRunDetailPage } from "./routes/billing/BillingRunDetailPage.js";
|
||||
import { InvoicesListPage } from "./routes/billing/InvoicesListPage.js";
|
||||
import { NcmListPage } from "./routes/fiscal/NcmListPage.js";
|
||||
import { CfopListPage } from "./routes/fiscal/CfopListPage.js";
|
||||
import { MunicipalitiesListPage } from "./routes/fiscal/MunicipalitiesListPage.js";
|
||||
import { OperationNaturesListPage } from "./routes/fiscal/OperationNaturesListPage.js";
|
||||
import { TicketsListPage } from "./routes/support/TicketsListPage.js";
|
||||
import { TicketFormPage } from "./routes/support/TicketFormPage.js";
|
||||
import { TicketDetailPage } from "./routes/support/TicketDetailPage.js";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -96,6 +103,15 @@ export function App() {
|
||||
<Route path="/billing/runs/new" element={<BillingRunFormPage />} />
|
||||
<Route path="/billing/runs/:id" element={<BillingRunDetailPage />} />
|
||||
<Route path="/billing/invoices" element={<InvoicesListPage />} />
|
||||
|
||||
<Route path="/fiscal/ncm" element={<NcmListPage />} />
|
||||
<Route path="/fiscal/cfop" element={<CfopListPage />} />
|
||||
<Route path="/fiscal/municipalities" element={<MunicipalitiesListPage />} />
|
||||
<Route path="/fiscal/operation-natures" element={<OperationNaturesListPage />} />
|
||||
|
||||
<Route path="/support/tickets" element={<TicketsListPage />} />
|
||||
<Route path="/support/tickets/new" element={<TicketFormPage />} />
|
||||
<Route path="/support/tickets/:id" element={<TicketDetailPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
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 "—";
|
||||
|
||||
@@ -26,6 +26,8 @@ export function AuthenticatedLayout() {
|
||||
{ key: "contracts", label: "Contratos", href: "/contracts" },
|
||||
{ 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" },
|
||||
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
|
||||
|
||||
async function handleLogout() {
|
||||
|
||||
241
apps/core-web/src/routes/fiscal/CfopListPage.tsx
Normal file
241
apps/core-web/src/routes/fiscal/CfopListPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { createCfop, listCfop, updateCfop } from "../../lib/api/fiscal.js";
|
||||
import type { CreateCfopInput, FiscalCfop } from "../../lib/api/fiscal.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { FiscalSubNav } from "./FiscalSubNav.js";
|
||||
|
||||
interface CfopFormState {
|
||||
code: string;
|
||||
description: string;
|
||||
direction: string;
|
||||
scope: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: CfopFormState = {
|
||||
code: "",
|
||||
description: "",
|
||||
direction: "",
|
||||
scope: "",
|
||||
validFrom: "",
|
||||
validUntil: "",
|
||||
active: true,
|
||||
};
|
||||
|
||||
/** ISO datetime/date → `yyyy-mm-dd` for `<input type="date">`, or "" when absent. */
|
||||
function toDateInputValue(value: string | null): string {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Catálogo simples de CFOP — mesmo padrão de `NcmListPage.tsx`, com os
|
||||
* campos adicionais `direction`/`scope` (texto livre, ex. "entrada"/"saída",
|
||||
* "estadual"/"interestadual").
|
||||
*/
|
||||
export function CfopListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [items, setItems] = useState<FiscalCfop[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<CfopFormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
function load() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listCfop(accessToken)
|
||||
.then(setItems)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os CFOPs."));
|
||||
}
|
||||
|
||||
useEffect(load, [accessToken]);
|
||||
|
||||
function updateField<K extends keyof CfopFormState>(field: K, value: CfopFormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: FiscalCfop) {
|
||||
setEditingId(item.id);
|
||||
setForm({
|
||||
code: item.code,
|
||||
description: item.description,
|
||||
direction: item.direction ?? "",
|
||||
scope: item.scope ?? "",
|
||||
validFrom: toDateInputValue(item.valid_from),
|
||||
validUntil: toDateInputValue(item.valid_until),
|
||||
active: item.active,
|
||||
});
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setFormError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: CreateCfopInput = {
|
||||
code: form.code.trim(),
|
||||
description: form.description.trim(),
|
||||
direction: form.direction.trim() || undefined,
|
||||
scope: form.scope.trim() || undefined,
|
||||
validFrom: form.validFrom || undefined,
|
||||
validUntil: form.validUntil || undefined,
|
||||
active: form.active,
|
||||
};
|
||||
if (editingId) {
|
||||
await updateCfop(editingId, payload, accessToken);
|
||||
} else {
|
||||
await createCfop(payload, accessToken);
|
||||
}
|
||||
closeForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "Não foi possível salvar o CFOP. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<FiscalCfop>> = [
|
||||
{ key: "code", header: "Código", render: (item) => <span className="font-semibold text-title">{item.code}</span> },
|
||||
{ key: "description", header: "Descrição", render: (item) => item.description },
|
||||
{ key: "direction", header: "Direção", render: (item) => item.direction ?? "—" },
|
||||
{ key: "scope", header: "Âmbito", render: (item) => item.scope ?? "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (item) => <Badge variant={item.active ? "success" : "neutral"}>{item.active ? "Ativo" : "Inativo"}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (item) => (
|
||||
<Button variant="secondary" size="sm" onClick={() => openEdit(item)} type="button">
|
||||
Editar
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Fiscal — CFOP"
|
||||
description="Código Fiscal de Operações e Prestações. Cadastro manual nesta leva, sem importação automática."
|
||||
actions={
|
||||
<Button onClick={openCreate} type="button">
|
||||
Novo CFOP
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiscalSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{formOpen ? (
|
||||
<Card title={editingId ? "Editar CFOP" : "Novo CFOP"} className="mb-4">
|
||||
{formError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{formError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Código" value={form.code} onChange={(e) => updateField("code", e.target.value)} required />
|
||||
<div className="flex items-end pb-2.5">
|
||||
<label className="flex items-center gap-2 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)}
|
||||
/>
|
||||
Ativo
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label="Descrição"
|
||||
value={form.description}
|
||||
onChange={(e) => updateField("description", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Direção"
|
||||
placeholder="entrada / saída"
|
||||
value={form.direction}
|
||||
onChange={(e) => updateField("direction", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Âmbito"
|
||||
placeholder="estadual / interestadual"
|
||||
value={form.scope}
|
||||
onChange={(e) => updateField("scope", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Válido a partir de"
|
||||
type="date"
|
||||
value={form.validFrom}
|
||||
onChange={(e) => updateField("validFrom", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Válido até"
|
||||
type="date"
|
||||
value={form.validUntil}
|
||||
onChange={(e) => updateField("validUntil", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={closeForm}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{items === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
) : (
|
||||
<Table columns={columns} data={items ?? []} rowKey={(item) => item.id} emptyMessage="Nenhum CFOP cadastrado." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/core-web/src/routes/fiscal/FiscalSubNav.tsx
Normal file
38
apps/core-web/src/routes/fiscal/FiscalSubNav.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/fiscal/ncm", label: "NCM" },
|
||||
{ href: "/fiscal/cfop", label: "CFOP" },
|
||||
{ href: "/fiscal/municipalities", label: "Municípios" },
|
||||
{ href: "/fiscal/operation-natures", label: "Naturezas de operação" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Only one top-level nav slot was allocated for Fiscal ("Fiscal" → /fiscal/ncm,
|
||||
* per AuthenticatedLayout.tsx) but the module has four catalog screens — this
|
||||
* in-page toggle follows the same pattern as `src/routes/crm/CrmSubNav.tsx`
|
||||
* (visual reference: `.tmp/theme-reference/categories.tsx` / `units.tsx`
|
||||
* header row, reimplemented with plain Tailwind, no theme JSX copied).
|
||||
*/
|
||||
export function FiscalSubNav() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="mb-4 inline-flex flex-wrap rounded-md border border-border-color bg-white p-1">
|
||||
{TABS.map((tab) => {
|
||||
const active = location.pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
to={tab.href}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
active ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
apps/core-web/src/routes/fiscal/MunicipalitiesListPage.tsx
Normal file
196
apps/core-web/src/routes/fiscal/MunicipalitiesListPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { createMunicipality, listMunicipalities, updateMunicipality } from "../../lib/api/fiscal.js";
|
||||
import type { CreateMunicipalityInput, FiscalMunicipality } from "../../lib/api/fiscal.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { FiscalSubNav } from "./FiscalSubNav.js";
|
||||
|
||||
interface MunicipalityFormState {
|
||||
code: string;
|
||||
name: string;
|
||||
state: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: MunicipalityFormState = { code: "", name: "", state: "", active: true };
|
||||
|
||||
/**
|
||||
* Catálogo de municípios (código IBGE + nome + UF) — mesmo padrão de
|
||||
* `NcmListPage.tsx`, sem vigência (`validFrom`/`validUntil`) nem `metadata`:
|
||||
* um município não expira (eden.md).
|
||||
*/
|
||||
export function MunicipalitiesListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [items, setItems] = useState<FiscalMunicipality[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<MunicipalityFormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
function load() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listMunicipalities(accessToken)
|
||||
.then(setItems)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os municípios."));
|
||||
}
|
||||
|
||||
useEffect(load, [accessToken]);
|
||||
|
||||
function updateField<K extends keyof MunicipalityFormState>(field: K, value: MunicipalityFormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: FiscalMunicipality) {
|
||||
setEditingId(item.id);
|
||||
setForm({ code: item.code, name: item.name, state: item.state, active: item.active });
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setFormError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: CreateMunicipalityInput = {
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
state: form.state.trim().toUpperCase(),
|
||||
active: form.active,
|
||||
};
|
||||
if (editingId) {
|
||||
await updateMunicipality(editingId, payload, accessToken);
|
||||
} else {
|
||||
await createMunicipality(payload, accessToken);
|
||||
}
|
||||
closeForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "Não foi possível salvar o município. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<FiscalMunicipality>> = [
|
||||
{ key: "code", header: "Código IBGE", render: (item) => <span className="font-semibold text-title">{item.code}</span> },
|
||||
{ key: "name", header: "Nome", render: (item) => item.name },
|
||||
{ key: "state", header: "UF", render: (item) => item.state },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (item) => <Badge variant={item.active ? "success" : "neutral"}>{item.active ? "Ativo" : "Inativo"}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (item) => (
|
||||
<Button variant="secondary" size="sm" onClick={() => openEdit(item)} type="button">
|
||||
Editar
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Fiscal — Municípios"
|
||||
description="Cadastro manual de municípios (código IBGE), nesta leva sem importação automática."
|
||||
actions={
|
||||
<Button onClick={openCreate} type="button">
|
||||
Novo município
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiscalSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{formOpen ? (
|
||||
<Card title={editingId ? "Editar município" : "Novo município"} className="mb-4">
|
||||
{formError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{formError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Input label="Código IBGE" value={form.code} onChange={(e) => updateField("code", e.target.value)} required />
|
||||
<Input
|
||||
label="UF"
|
||||
maxLength={2}
|
||||
placeholder="SP"
|
||||
value={form.state}
|
||||
onChange={(e) => updateField("state", e.target.value.toUpperCase())}
|
||||
required
|
||||
/>
|
||||
<div className="flex items-end pb-2.5">
|
||||
<label className="flex items-center gap-2 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)}
|
||||
/>
|
||||
Ativo
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={closeForm}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{items === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items ?? []}
|
||||
rowKey={(item) => item.id}
|
||||
emptyMessage="Nenhum município cadastrado."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
apps/core-web/src/routes/fiscal/NcmListPage.tsx
Normal file
221
apps/core-web/src/routes/fiscal/NcmListPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { createNcm, listNcm, updateNcm } from "../../lib/api/fiscal.js";
|
||||
import type { CreateNcmInput, FiscalNcm } from "../../lib/api/fiscal.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { FiscalSubNav } from "./FiscalSubNav.js";
|
||||
|
||||
interface NcmFormState {
|
||||
code: string;
|
||||
description: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: NcmFormState = { code: "", description: "", validFrom: "", validUntil: "", active: true };
|
||||
|
||||
/** ISO datetime/date → `yyyy-mm-dd` for `<input type="date">`, or "" when absent. */
|
||||
function toDateInputValue(value: string | null): string {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Catálogo simples de NCM — cadastro manual nesta leva (sem importação
|
||||
* automática). Layout: header + card de tabela, com um card de
|
||||
* formulário compacto acima da tabela para criar/editar (padrão
|
||||
* "catálogo simples código+descrição+ativo" de
|
||||
* `.tmp/theme-reference/categories.tsx` / `units.tsx`, reimplementado com
|
||||
* `@eden/ui` em vez do modal antd/Preline do tema).
|
||||
*/
|
||||
export function NcmListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [items, setItems] = useState<FiscalNcm[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<NcmFormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
function load() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listNcm(accessToken)
|
||||
.then(setItems)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os NCMs."));
|
||||
}
|
||||
|
||||
useEffect(load, [accessToken]);
|
||||
|
||||
function updateField<K extends keyof NcmFormState>(field: K, value: NcmFormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: FiscalNcm) {
|
||||
setEditingId(item.id);
|
||||
setForm({
|
||||
code: item.code,
|
||||
description: item.description,
|
||||
validFrom: toDateInputValue(item.valid_from),
|
||||
validUntil: toDateInputValue(item.valid_until),
|
||||
active: item.active,
|
||||
});
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setFormError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: CreateNcmInput = {
|
||||
code: form.code.trim(),
|
||||
description: form.description.trim(),
|
||||
validFrom: form.validFrom || undefined,
|
||||
validUntil: form.validUntil || undefined,
|
||||
active: form.active,
|
||||
};
|
||||
if (editingId) {
|
||||
await updateNcm(editingId, payload, accessToken);
|
||||
} else {
|
||||
await createNcm(payload, accessToken);
|
||||
}
|
||||
closeForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "Não foi possível salvar o NCM. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<FiscalNcm>> = [
|
||||
{ key: "code", header: "Código", render: (item) => <span className="font-semibold text-title">{item.code}</span> },
|
||||
{ key: "description", header: "Descrição", render: (item) => item.description },
|
||||
{
|
||||
key: "validity",
|
||||
header: "Vigência",
|
||||
render: (item) => `${formatDateBR(item.valid_from)} — ${item.valid_until ? formatDateBR(item.valid_until) : "atual"}`,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (item) => <Badge variant={item.active ? "success" : "neutral"}>{item.active ? "Ativo" : "Inativo"}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (item) => (
|
||||
<Button variant="secondary" size="sm" onClick={() => openEdit(item)} type="button">
|
||||
Editar
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Fiscal — NCM"
|
||||
description="Nomenclatura Comum do Mercosul. Cadastro manual nesta leva, sem importação automática."
|
||||
actions={
|
||||
<Button onClick={openCreate} type="button">
|
||||
Novo NCM
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<FiscalSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{formOpen ? (
|
||||
<Card title={editingId ? "Editar NCM" : "Novo NCM"} className="mb-4">
|
||||
{formError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{formError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Código" value={form.code} onChange={(e) => updateField("code", e.target.value)} required />
|
||||
<div className="flex items-end pb-2.5">
|
||||
<label className="flex items-center gap-2 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)}
|
||||
/>
|
||||
Ativo
|
||||
</label>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label="Descrição"
|
||||
value={form.description}
|
||||
onChange={(e) => updateField("description", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Válido a partir de"
|
||||
type="date"
|
||||
value={form.validFrom}
|
||||
onChange={(e) => updateField("validFrom", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Válido até"
|
||||
type="date"
|
||||
value={form.validUntil}
|
||||
onChange={(e) => updateField("validUntil", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={closeForm}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting}>
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{items === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
) : (
|
||||
<Table columns={columns} data={items ?? []} rowKey={(item) => item.id} emptyMessage="Nenhum NCM cadastrado." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/core-web/src/routes/fiscal/OperationNaturesListPage.tsx
Normal file
77
apps/core-web/src/routes/fiscal/OperationNaturesListPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Badge, Card, PageHeader, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { listOperationNatures } from "../../lib/api/fiscal.js";
|
||||
import type { FiscalOperationNature } from "../../lib/api/fiscal.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { FiscalSubNav } from "./FiscalSubNav.js";
|
||||
|
||||
/**
|
||||
* Naturezas de operação — somente leitura, seed fixo (11 naturezas), sem
|
||||
* criar/editar nesta leva.
|
||||
*/
|
||||
export function OperationNaturesListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [items, setItems] = useState<FiscalOperationNature[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listOperationNatures(accessToken)
|
||||
.then((data) => {
|
||||
if (!cancelled) setItems(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as naturezas de operação.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
const columns: Array<TableColumn<FiscalOperationNature>> = [
|
||||
{ key: "code", header: "Código", render: (item) => <span className="font-semibold text-title">{item.code}</span> },
|
||||
{ key: "name", header: "Nome", render: (item) => item.name },
|
||||
{ key: "description", header: "Descrição", render: (item) => item.description ?? "—" },
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (item) => <Badge variant={item.active ? "success" : "neutral"}>{item.active ? "Ativo" : "Inativo"}</Badge>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Fiscal — Naturezas de operação"
|
||||
description="Lista somente leitura (seed fixo do sistema)."
|
||||
/>
|
||||
|
||||
<FiscalSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{items === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={items ?? []}
|
||||
rowKey={(item) => item.id}
|
||||
emptyMessage="Nenhuma natureza de operação cadastrada."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
330
apps/core-web/src/routes/products/ProductFiscalSection.tsx
Normal file
330
apps/core-web/src/routes/products/ProductFiscalSection.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Alert, Badge, Button, Card, Select } from "@eden/ui";
|
||||
import type { SelectOption } from "@eden/ui";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createProductFiscalProfile,
|
||||
listCfop,
|
||||
listMunicipalities,
|
||||
listNcm,
|
||||
listOperationNatures,
|
||||
listProductFiscalProfiles,
|
||||
} from "../../lib/api/fiscal.js";
|
||||
import type {
|
||||
BillingComponent,
|
||||
CreateProductFiscalProfileInput,
|
||||
FiscalCfop,
|
||||
FiscalDocumentType,
|
||||
FiscalMunicipality,
|
||||
FiscalNcm,
|
||||
FiscalOperationNature,
|
||||
ProductFiscalProfile,
|
||||
} from "../../lib/api/fiscal.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
|
||||
const BILLING_COMPONENT_LABELS: Record<BillingComponent, string> = {
|
||||
RECURRING: "Recorrente",
|
||||
IMPLEMENTATION: "Implantação",
|
||||
};
|
||||
|
||||
const DOCUMENT_TYPE_LABELS: Record<FiscalDocumentType, string> = {
|
||||
NFCOM: "NFCom",
|
||||
NFSE: "NFS-e",
|
||||
NFE: "NF-e",
|
||||
NONE: "Nenhum",
|
||||
};
|
||||
|
||||
const BILLING_COMPONENT_OPTIONS: SelectOption[] = (Object.keys(BILLING_COMPONENT_LABELS) as BillingComponent[]).map(
|
||||
(value) => ({ value, label: BILLING_COMPONENT_LABELS[value] }),
|
||||
);
|
||||
|
||||
const DOCUMENT_TYPE_OPTIONS: SelectOption[] = (Object.keys(DOCUMENT_TYPE_LABELS) as FiscalDocumentType[]).map(
|
||||
(value) => ({ value, label: DOCUMENT_TYPE_LABELS[value] }),
|
||||
);
|
||||
|
||||
interface NewProfileState {
|
||||
billingComponent: BillingComponent;
|
||||
documentType: FiscalDocumentType;
|
||||
ncmId: string;
|
||||
cfopId: string;
|
||||
municipalityId: string;
|
||||
operationNatureId: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
const EMPTY_NEW_PROFILE: NewProfileState = {
|
||||
billingComponent: "RECURRING",
|
||||
documentType: "NONE",
|
||||
ncmId: "",
|
||||
cfopId: "",
|
||||
municipalityId: "",
|
||||
operationNatureId: "",
|
||||
validFrom: "",
|
||||
validUntil: "",
|
||||
};
|
||||
|
||||
function catalogOptions<T extends { id: string; code: string }>(items: T[], labelOf: (item: T) => string): SelectOption[] {
|
||||
return items.map((item) => ({ value: item.id, label: `${item.code} — ${labelOf(item)}` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Perfil fiscal do produto — sub-seção de `ProductFormPage.tsx`, só visível
|
||||
* ao editar um produto existente (precisa do `productId`). Mesma estrutura
|
||||
* "detail + editable sub-list" de `client-registrations/PartnersSection.tsx`
|
||||
* (lista com histórico + formulário de criação abaixo), adaptada ao invariante
|
||||
* do módulo fiscal: um novo perfil ativo para o mesmo
|
||||
* `(productId, billingComponent)` inativa automaticamente o anterior — nunca
|
||||
* é edição destrutiva, por isso não há "editar", só "criar novo".
|
||||
*/
|
||||
export function ProductFiscalSection({ productId, accessToken }: { productId: string; accessToken: string | null }) {
|
||||
const [profiles, setProfiles] = useState<ProductFiscalProfile[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [ncmList, setNcmList] = useState<FiscalNcm[]>([]);
|
||||
const [cfopList, setCfopList] = useState<FiscalCfop[]>([]);
|
||||
const [municipalityList, setMunicipalityList] = useState<FiscalMunicipality[]>([]);
|
||||
const [natureList, setNatureList] = useState<FiscalOperationNature[]>([]);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
|
||||
const [newProfile, setNewProfile] = useState<NewProfileState>(EMPTY_NEW_PROFILE);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const loadProfiles = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listProductFiscalProfiles(productId, accessToken)
|
||||
.then(setProfiles)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os perfis fiscais."));
|
||||
}, [productId, accessToken]);
|
||||
|
||||
useEffect(loadProfiles, [loadProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
setCatalogError(null);
|
||||
Promise.all([
|
||||
listNcm(accessToken),
|
||||
listCfop(accessToken),
|
||||
listMunicipalities(accessToken),
|
||||
listOperationNatures(accessToken),
|
||||
])
|
||||
.then(([ncm, cfop, municipalities, natures]) => {
|
||||
setNcmList(ncm);
|
||||
setCfopList(cfop);
|
||||
setMunicipalityList(municipalities);
|
||||
setNatureList(natures);
|
||||
})
|
||||
.catch((err) =>
|
||||
setCatalogError(
|
||||
err instanceof ApiError ? err.message : "Não foi possível carregar os catálogos fiscais (NCM/CFOP/município/natureza).",
|
||||
),
|
||||
);
|
||||
}, [accessToken]);
|
||||
|
||||
const ncmOptions = useMemo(() => catalogOptions(ncmList, (i) => i.description), [ncmList]);
|
||||
const cfopOptions = useMemo(() => catalogOptions(cfopList, (i) => i.description), [cfopList]);
|
||||
const municipalityOptions = useMemo(
|
||||
() => catalogOptions(municipalityList, (i) => `${i.name}/${i.state}`),
|
||||
[municipalityList],
|
||||
);
|
||||
const natureOptions = useMemo(() => catalogOptions(natureList, (i) => i.name), [natureList]);
|
||||
|
||||
const ncmLookup = useMemo(() => new Map(ncmList.map((i) => [i.id, i.code])), [ncmList]);
|
||||
const cfopLookup = useMemo(() => new Map(cfopList.map((i) => [i.id, i.code])), [cfopList]);
|
||||
const municipalityLookup = useMemo(
|
||||
() => new Map(municipalityList.map((i) => [i.id, `${i.name}/${i.state}`])),
|
||||
[municipalityList],
|
||||
);
|
||||
const natureLookup = useMemo(() => new Map(natureList.map((i) => [i.id, i.name])), [natureList]);
|
||||
|
||||
const activeForSameComponent = (profiles ?? []).find(
|
||||
(p) => p.active && p.billing_component === newProfile.billingComponent,
|
||||
);
|
||||
|
||||
function updateNewProfile<K extends keyof NewProfileState>(field: K, value: NewProfileState[K]) {
|
||||
setNewProfile((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
async function handleCreate(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const payload: CreateProductFiscalProfileInput = {
|
||||
billingComponent: newProfile.billingComponent,
|
||||
documentType: newProfile.documentType,
|
||||
ncmId: newProfile.ncmId || undefined,
|
||||
cfopId: newProfile.cfopId || undefined,
|
||||
municipalityId: newProfile.municipalityId || undefined,
|
||||
operationNatureId: newProfile.operationNatureId || undefined,
|
||||
validFrom: newProfile.validFrom || undefined,
|
||||
validUntil: newProfile.validUntil || undefined,
|
||||
};
|
||||
await createProductFiscalProfile(productId, payload, accessToken);
|
||||
setNewProfile(EMPTY_NEW_PROFILE);
|
||||
loadProfiles();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof ApiError ? err.message : "Não foi possível criar o perfil fiscal.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Fiscal">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{catalogError ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{catalogError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{profiles === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando perfis fiscais…</p>
|
||||
) : (profiles ?? []).length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum perfil fiscal cadastrado para este produto.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color">
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Componente</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Documento</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">NCM</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">CFOP</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Município</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Natureza</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Vigência</th>
|
||||
<th className="whitespace-nowrap px-4 py-3 text-xs font-semibold uppercase text-default">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(profiles ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||
.map((profile) => (
|
||||
<tr key={profile.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-4 py-3 text-sm text-title">{BILLING_COMPONENT_LABELS[profile.billing_component]}</td>
|
||||
<td className="px-4 py-3 text-sm text-title">{DOCUMENT_TYPE_LABELS[profile.document_type]}</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
{profile.ncm_id ? ncmLookup.get(profile.ncm_id) ?? profile.ncm_id : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
{profile.cfop_id ? cfopLookup.get(profile.cfop_id) ?? profile.cfop_id : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
{profile.municipality_id ? municipalityLookup.get(profile.municipality_id) ?? profile.municipality_id : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
{profile.operation_nature_id ? natureLookup.get(profile.operation_nature_id) ?? profile.operation_nature_id : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
{formatDateBR(profile.valid_from)} — {profile.valid_until ? formatDateBR(profile.valid_until) : "atual"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-title">
|
||||
<Badge variant={profile.active ? "success" : "neutral"}>{profile.active ? "Ativo" : "Inativo"}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t border-border-color pt-4">
|
||||
<p className="mb-3 text-sm font-semibold text-title">Novo perfil fiscal</p>
|
||||
{createError ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{createError}
|
||||
</Alert>
|
||||
) : null}
|
||||
{activeForSameComponent ? (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
Já existe um perfil ativo para "{BILLING_COMPONENT_LABELS[newProfile.billingComponent]}". Criar um novo
|
||||
perfil vai substituir (inativar) o perfil ativo atual automaticamente.
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" onSubmit={handleCreate}>
|
||||
<Select
|
||||
label="Componente de faturamento"
|
||||
options={BILLING_COMPONENT_OPTIONS}
|
||||
value={newProfile.billingComponent}
|
||||
onChange={(e) => updateNewProfile("billingComponent", e.target.value as BillingComponent)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Tipo de documento fiscal"
|
||||
options={DOCUMENT_TYPE_OPTIONS}
|
||||
value={newProfile.documentType}
|
||||
onChange={(e) => updateNewProfile("documentType", e.target.value as FiscalDocumentType)}
|
||||
/>
|
||||
<Select
|
||||
label="NCM"
|
||||
placeholder="Nenhum"
|
||||
options={ncmOptions}
|
||||
value={newProfile.ncmId}
|
||||
onChange={(e) => updateNewProfile("ncmId", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="CFOP"
|
||||
placeholder="Nenhum"
|
||||
options={cfopOptions}
|
||||
value={newProfile.cfopId}
|
||||
onChange={(e) => updateNewProfile("cfopId", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Município"
|
||||
placeholder="Nenhum"
|
||||
options={municipalityOptions}
|
||||
value={newProfile.municipalityId}
|
||||
onChange={(e) => updateNewProfile("municipalityId", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Natureza de operação"
|
||||
placeholder="Nenhuma"
|
||||
options={natureOptions}
|
||||
value={newProfile.operationNatureId}
|
||||
onChange={(e) => updateNewProfile("operationNatureId", e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900" htmlFor="fiscal-profile-valid-from">
|
||||
Válido a partir de
|
||||
</label>
|
||||
<input
|
||||
id="fiscal-profile-valid-from"
|
||||
type="date"
|
||||
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"
|
||||
value={newProfile.validFrom}
|
||||
onChange={(e) => updateNewProfile("validFrom", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900" htmlFor="fiscal-profile-valid-until">
|
||||
Válido até
|
||||
</label>
|
||||
<input
|
||||
id="fiscal-profile-valid-until"
|
||||
type="date"
|
||||
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"
|
||||
value={newProfile.validUntil}
|
||||
onChange={(e) => updateNewProfile("validUntil", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
Criar perfil fiscal
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { CreateProductInput } from "../../lib/api/products.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ProductFiscalSection } from "./ProductFiscalSection.js";
|
||||
|
||||
/** Money fields are edited/stored as integer cents so the mask never loses precision to floating point. */
|
||||
function centsToDisplay(cents: number): string {
|
||||
@@ -295,6 +296,17 @@ export function ProductFormPage() {
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Perfil fiscal só faz sentido para um produto já existente (a rota
|
||||
`/fiscal/products/:id/profiles` precisa do productId) — por isso fica
|
||||
fora do formulário de criação/edição e não é exibido em `/products/new`.
|
||||
*/}
|
||||
{!loading && isEditing && id ? (
|
||||
<div className="mt-4">
|
||||
<ProductFiscalSection productId={id} accessToken={accessToken} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
446
apps/core-web/src/routes/support/TicketDetailPage.tsx
Normal file
446
apps/core-web/src/routes/support/TicketDetailPage.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
TICKET_STATUS_TRANSITIONS,
|
||||
addTicketComment,
|
||||
getTicket,
|
||||
updateTicketStatus,
|
||||
} from "../../lib/api/support.js";
|
||||
import type { TicketStatus, TicketWithDetails } from "../../lib/api/support.js";
|
||||
import { formatDateBR, formatDateTimeBR } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import {
|
||||
SLA_PAUSED_STATUSES,
|
||||
TICKET_PRIORITY_LABEL,
|
||||
TICKET_PRIORITY_VARIANT,
|
||||
TICKET_STATUS_LABEL,
|
||||
TICKET_STATUS_VARIANT,
|
||||
} from "./ticketStatus.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/ticketDetails.tsx`
|
||||
* (header with subject + priority/status badges side by side, a
|
||||
* conversation thread with author/date/body, a right-hand "Ticket
|
||||
* Information" summary panel) plus `.tmp/theme-reference/slaManagement.tsx`
|
||||
* for how response/resolution deadlines and their status are laid out, and
|
||||
* this app's own `../contracts/ContractDetailPage.tsx` for the info +
|
||||
* status-transition + history-timeline column composition already
|
||||
* established with `@eden/ui`. Rebuilt with `@eden/ui` + plain Tailwind —
|
||||
* no theme JSX copied. The richest of the three Support screens: SLA
|
||||
* panel, public/internal comment thread with an add-comment form, status
|
||||
* history timeline, and the status-transition action with conditional
|
||||
* root-cause/resolution fields when closing.
|
||||
*/
|
||||
export function TicketDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken, user } = useAuth();
|
||||
|
||||
const [ticket, setTicket] = useState<TicketWithDetails | 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);
|
||||
getTicket(id, accessToken)
|
||||
.then(setTicket)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o ticket."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Ticket" />
|
||||
<p className="text-sm text-default">Carregando ticket…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !ticket) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Ticket" />
|
||||
<Alert variant="error">{error ?? "Ticket não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Suporte", href: "/support/tickets" }, { label: ticket.protocol_number }]} />
|
||||
<PageHeader
|
||||
title={`Ticket ${ticket.protocol_number}`}
|
||||
description={ticket.title}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TICKET_PRIORITY_VARIANT[ticket.priority]}>
|
||||
{TICKET_PRIORITY_LABEL[ticket.priority]}
|
||||
</Badge>
|
||||
<Badge variant={TICKET_STATUS_VARIANT[ticket.status]}>{TICKET_STATUS_LABEL[ticket.status]}</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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 ticket">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Categoria" value={ticket.category} />
|
||||
<Row label="Subcategoria" value={ticket.subcategory ?? "—"} />
|
||||
<Row label="Cliente" value={ticket.client_registration_id ?? "—"} />
|
||||
<Row label="Contrato" value={ticket.contract_id ?? "—"} />
|
||||
<Row label="Revenda" value={ticket.reseller_id ?? "—"} />
|
||||
<Row label="Responsável" value={ticket.owner_user_id ?? "—"} />
|
||||
<Row label="Aberto por" value={ticket.opened_by_user_id ?? "—"} />
|
||||
<Row label="Criado em" value={formatDateBR(ticket.created_at)} />
|
||||
<Row label="Atualizado em" value={formatDateBR(ticket.updated_at)} />
|
||||
<Row label="1ª resposta em" value={formatDateBR(ticket.first_response_at)} />
|
||||
<Row label="Resolvido em" value={formatDateBR(ticket.resolved_at)} />
|
||||
<Row label="Fechado em" value={formatDateBR(ticket.closed_at)} />
|
||||
</dl>
|
||||
{ticket.root_cause ? (
|
||||
<div className="mt-4 border-t border-border-color pt-4">
|
||||
<p className="text-xs font-semibold text-default">Causa raiz</p>
|
||||
<p className="mt-1 text-sm text-title">{ticket.root_cause}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{ticket.resolution_notes ? (
|
||||
<div className="mt-4 border-t border-border-color pt-4">
|
||||
<p className="text-xs font-semibold text-default">Notas de resolução</p>
|
||||
<p className="mt-1 text-sm text-title">{ticket.resolution_notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<SlaCard slaTimer={ticket.slaTimer} status={ticket.status} />
|
||||
|
||||
<StatusTransitionCard ticket={ticket} accessToken={accessToken} onChanged={load} />
|
||||
|
||||
<StatusHistoryCard ticket={ticket} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card title="Descrição">
|
||||
<p className="whitespace-pre-wrap text-sm text-title">{ticket.description}</p>
|
||||
</Card>
|
||||
|
||||
<CommentsCard ticket={ticket} accessToken={accessToken} currentUserId={user?.id} 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 MetBadge({ met, label }: { met: boolean | null; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-default">{label}</span>
|
||||
{met === null ? (
|
||||
<Badge variant="neutral">Aguardando</Badge>
|
||||
) : met ? (
|
||||
<Badge variant="success">Cumprido</Badge>
|
||||
) : (
|
||||
<Badge variant="danger">Violado</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlaCard({ slaTimer, status }: { slaTimer: TicketWithDetails["slaTimer"]; status: TicketStatus }) {
|
||||
if (!slaTimer) {
|
||||
return (
|
||||
<Card title="SLA">
|
||||
<p className="text-sm text-default">Nenhuma política de SLA aplicada a este ticket.</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isPaused = slaTimer.paused_since !== null && SLA_PAUSED_STATUSES.includes(status);
|
||||
|
||||
return (
|
||||
<Card title="SLA">
|
||||
{isPaused ? (
|
||||
<Alert variant="warning" className="mb-3">
|
||||
SLA pausado desde {formatDateTimeBR(slaTimer.paused_since)} — o contador só volta a correr quando o
|
||||
ticket sair de "{TICKET_STATUS_LABEL[status]}".
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-default">Prazo de 1ª resposta</p>
|
||||
<p className="text-sm font-semibold text-title">{formatDateTimeBR(slaTimer.first_response_due_at)}</p>
|
||||
</div>
|
||||
<MetBadge met={slaTimer.first_response_met} label="1ª resposta" />
|
||||
<div className="border-t border-border-color pt-3">
|
||||
<p className="text-xs text-default">Prazo de resolução</p>
|
||||
<p className="text-sm font-semibold text-title">{formatDateTimeBR(slaTimer.resolution_due_at)}</p>
|
||||
</div>
|
||||
<MetBadge met={slaTimer.resolution_met} label="Resolução" />
|
||||
{slaTimer.accumulated_pause_seconds > 0 ? (
|
||||
<p className="border-t border-border-color pt-3 text-xs text-default">
|
||||
Tempo pausado acumulado: {Math.round(slaTimer.accumulated_pause_seconds / 60)} min
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTransitionCard({
|
||||
ticket,
|
||||
accessToken,
|
||||
onChanged,
|
||||
}: {
|
||||
ticket: TicketWithDetails;
|
||||
accessToken: string | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const validTargets = TICKET_STATUS_TRANSITIONS[ticket.status] ?? [];
|
||||
const [toStatus, setToStatus] = useState<string>(validTargets[0] ?? "");
|
||||
const [reason, setReason] = useState("");
|
||||
const [rootCause, setRootCause] = useState("");
|
||||
const [resolutionNotes, setResolutionNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setToStatus(validTargets[0] ?? "");
|
||||
setReason("");
|
||||
setRootCause("");
|
||||
setResolutionNotes("");
|
||||
// Only re-sync when the ticket's own status changes (i.e. after a
|
||||
// successful transition) — not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticket.status]);
|
||||
|
||||
if (validTargets.length === 0) {
|
||||
return (
|
||||
<Card title="Status">
|
||||
<p className="text-sm text-default">
|
||||
Status <strong>{TICKET_STATUS_LABEL[ticket.status]}</strong> é final — nenhuma transição disponível.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isClosing = toStatus === "closed";
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken || !toStatus) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await updateTicketStatus(
|
||||
ticket.id,
|
||||
{
|
||||
toStatus: toStatus as TicketStatus,
|
||||
reason: reason || undefined,
|
||||
rootCause: isClosing ? rootCause || undefined : undefined,
|
||||
resolutionNotes: isClosing ? resolutionNotes || undefined : undefined,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível atualizar o status.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Alterar status">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<Select
|
||||
label="Novo status"
|
||||
options={validTargets.map((status) => ({ value: status, label: TICKET_STATUS_LABEL[status] }))}
|
||||
value={toStatus}
|
||||
onChange={(e) => setToStatus(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input label="Motivo (opcional)" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||
{isClosing ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Causa raiz (opcional)</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={3}
|
||||
value={rootCause}
|
||||
onChange={(e) => setRootCause(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">
|
||||
Notas de resolução (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={3}
|
||||
value={resolutionNotes}
|
||||
onChange={(e) => setResolutionNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="submit" loading={submitting}>
|
||||
Atualizar status
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusHistoryCard({ ticket }: { ticket: TicketWithDetails }) {
|
||||
const history = ticket.statusHistory;
|
||||
|
||||
return (
|
||||
<Card title="Histórico de status">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhuma alteração de status registrada ainda.</p>
|
||||
) : (
|
||||
<ol className="space-y-4">
|
||||
{history
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.changed_at).getTime() - new Date(a.changed_at).getTime())
|
||||
.map((entry) => (
|
||||
<li key={entry.id} className="relative border-s-2 border-border-color ps-4">
|
||||
<span className="absolute -start-[5px] top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
<p className="text-sm font-semibold text-title">
|
||||
{entry.from_status ? `${TICKET_STATUS_LABEL[entry.from_status]} → ` : ""}
|
||||
{TICKET_STATUS_LABEL[entry.to_status]}
|
||||
</p>
|
||||
<p className="text-xs text-default">{formatDateTimeBR(entry.changed_at)}</p>
|
||||
{entry.reason ? <p className="mt-1 text-sm text-default">{entry.reason}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsCard({
|
||||
ticket,
|
||||
accessToken,
|
||||
currentUserId,
|
||||
onChanged,
|
||||
}: {
|
||||
ticket: TicketWithDetails;
|
||||
accessToken: string | null;
|
||||
currentUserId: string | undefined;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [body, setBody] = useState("");
|
||||
const [isInternal, setIsInternal] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const comments = ticket.comments.slice().sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken || !body.trim()) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addTicketComment(ticket.id, { body: body.trim(), isInternal }, accessToken);
|
||||
setBody("");
|
||||
setIsInternal(false);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível adicionar o comentário.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Comentários">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-sm text-default">Nenhum comentário registrado ainda.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={
|
||||
comment.is_internal
|
||||
? "rounded-md border border-warning-200 bg-warning-transparent p-3"
|
||||
: "rounded-md border border-border-color bg-light p-3"
|
||||
}
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-title">
|
||||
{comment.author_user_id === currentUserId
|
||||
? "Você"
|
||||
: comment.author_user_id ?? "Sistema"}
|
||||
</p>
|
||||
<span className="text-xs text-default">{formatDateTimeBR(comment.created_at)}</span>
|
||||
{comment.is_internal ? <Badge variant="warning">Interno</Badge> : null}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm text-default">{comment.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 border-t border-border-color pt-5">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Adicionar comentário</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={isInternal}
|
||||
onChange={(e) => setIsInternal(e.target.checked)}
|
||||
/>
|
||||
Comentário interno (não é visível para o assinante/revenda)
|
||||
</label>
|
||||
<Button type="submit" loading={submitting} disabled={!body.trim()}>
|
||||
Adicionar comentário
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
202
apps/core-web/src/routes/support/TicketFormPage.tsx
Normal file
202
apps/core-web/src/routes/support/TicketFormPage.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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 { TICKET_PRIORITIES, createTicket } from "../../lib/api/support.js";
|
||||
import type { TicketPriority } from "../../lib/api/support.js";
|
||||
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
|
||||
import { listContracts } from "../../lib/api/contracts.js";
|
||||
import type { Contract } from "../../lib/api/contracts.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import { TICKET_PRIORITY_LABEL } from "./ticketStatus.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/tickets.tsx`
|
||||
* ("Add Ticket" modal fields) adapted to a full page — same shape already
|
||||
* used by `ContractFormPage`/`QuoteFormPage`. Rebuilt with `@eden/ui` +
|
||||
* plain Tailwind, no modal/antd dependency, no theme JSX copied.
|
||||
*/
|
||||
function registrationLabel(registration: ClientRegistration): string {
|
||||
const name =
|
||||
registration.person_type === "pj"
|
||||
? registration.pj_company_name ?? registration.pj_trade_name
|
||||
: registration.pf_full_name;
|
||||
return `#${registration.client_code} — ${name ?? "Sem nome"}`;
|
||||
}
|
||||
|
||||
export function TicketFormPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[] | null>(null);
|
||||
const [contracts, setContracts] = useState<Contract[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [subcategory, setSubcategory] = useState("");
|
||||
const [priority, setPriority] = useState<TicketPriority>("medium");
|
||||
const [clientRegistrationId, setClientRegistrationId] = useState("");
|
||||
const [contractId, setContractId] = useState("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
setLoadError(null);
|
||||
Promise.all([listClientRegistrations(accessToken), listContracts(accessToken)])
|
||||
.then(([registrations, contractList]) => {
|
||||
setClientRegistrations(registrations);
|
||||
setContracts(contractList);
|
||||
})
|
||||
.catch((err) =>
|
||||
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar clientes/contratos."),
|
||||
);
|
||||
}, [accessToken]);
|
||||
|
||||
const clientOptions = useMemo(
|
||||
() => (clientRegistrations ?? []).map((r) => ({ value: r.id, label: registrationLabel(r) })),
|
||||
[clientRegistrations],
|
||||
);
|
||||
const contractOptions = useMemo(
|
||||
() => (contracts ?? []).map((c) => ({ value: c.id, label: c.contract_number })),
|
||||
[contracts],
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
|
||||
if (title.trim().length < 3) {
|
||||
setError("O título deve ter ao menos 3 caracteres.");
|
||||
return;
|
||||
}
|
||||
if (description.trim().length < 3) {
|
||||
setError("A descrição deve ter ao menos 3 caracteres.");
|
||||
return;
|
||||
}
|
||||
if (!category.trim()) {
|
||||
setError("Informe a categoria.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ticket = await createTicket(
|
||||
{
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
category: category.trim(),
|
||||
subcategory: subcategory.trim() || undefined,
|
||||
priority,
|
||||
clientRegistrationId: clientRegistrationId || undefined,
|
||||
contractId: contractId || undefined,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/support/tickets/${ticket.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível criar o ticket.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Suporte", href: "/support/tickets" }, { label: "Novo ticket" }]} />
|
||||
<PageHeader title="Novo ticket" description="Abertura de chamado de suporte" />
|
||||
|
||||
{loadError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{loadError}
|
||||
</Alert>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{!clientRegistrations || !contracts ? (
|
||||
<p className="text-sm text-default">Carregando clientes e contratos…</p>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Título"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Descrição</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input label="Categoria" value={category} onChange={(e) => setCategory(e.target.value)} required />
|
||||
<Input
|
||||
label="Subcategoria (opcional)"
|
||||
value={subcategory}
|
||||
onChange={(e) => setSubcategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Prioridade"
|
||||
options={TICKET_PRIORITIES.map((p) => ({ value: p, label: TICKET_PRIORITY_LABEL[p] }))}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Cliente (opcional)"
|
||||
placeholder="Selecione o cliente"
|
||||
options={clientOptions}
|
||||
value={clientRegistrationId}
|
||||
onChange={(e) => setClientRegistrationId(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Contrato (opcional)"
|
||||
placeholder="Selecione o contrato"
|
||||
options={contractOptions}
|
||||
value={contractId}
|
||||
onChange={(e) => setContractId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border-color pt-5">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Criar ticket
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/support/tickets")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Link to="/support/tickets" className="ms-auto text-sm text-default hover:text-primary">
|
||||
Voltar para a lista
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/core-web/src/routes/support/TicketsListPage.tsx
Normal file
172
apps/core-web/src/routes/support/TicketsListPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useCallback, 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 { getTicket, listTickets } from "../../lib/api/support.js";
|
||||
import type { SupportTicket, TicketStatus } from "../../lib/api/support.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import {
|
||||
TICKET_PRIORITY_LABEL,
|
||||
TICKET_PRIORITY_VARIANT,
|
||||
TICKET_STATUS_LABEL,
|
||||
TICKET_STATUS_VARIANT,
|
||||
} from "./ticketStatus.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/tickets.tsx`
|
||||
* (header with title + "Add" action, flush data table in a bordered white
|
||||
* card, colored priority/status pills) — same shape already used by
|
||||
* `ContractsListPage`. Rebuilt with `@eden/ui` + plain Tailwind, no
|
||||
* antd/Preline dependency, no theme JSX copied.
|
||||
*/
|
||||
const TERMINAL_STATUSES: TicketStatus[] = ["closed", "cancelled"];
|
||||
|
||||
/** `null` = not checked yet, `undefined` = checked, no SLA policy applies. */
|
||||
type SlaOverdueMap = Record<string, boolean | undefined>;
|
||||
|
||||
export function TicketsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [tickets, setTickets] = useState<SupportTicket[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [overdueByTicket, setOverdueByTicket] = useState<SlaOverdueMap>({});
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listTickets(accessToken)
|
||||
.then(setTickets)
|
||||
.catch((err) => {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os tickets.");
|
||||
});
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// `SupportTicket` (the list row shape) doesn't carry `slaTimer` — that's
|
||||
// only returned by `GET /support/tickets/:id`. To show a real (not
|
||||
// fabricated) "SLA vencido" indicator, fetch the SLA timer for each
|
||||
// still-open ticket in the background — terminal tickets (closed/
|
||||
// cancelled) don't need the badge, keeping this bounded to the active
|
||||
// queue rather than every historical ticket.
|
||||
useEffect(() => {
|
||||
if (!accessToken || !tickets) return;
|
||||
const openTickets = tickets.filter((t) => !TERMINAL_STATUSES.includes(t.status));
|
||||
let cancelled = false;
|
||||
Promise.all(
|
||||
openTickets.map((t) =>
|
||||
getTicket(t.id, accessToken)
|
||||
.then((detail) => [t.id, isSlaOverdue(detail.slaTimer)] as const)
|
||||
.catch(() => [t.id, undefined] as const),
|
||||
),
|
||||
).then((entries) => {
|
||||
if (cancelled) return;
|
||||
setOverdueByTicket(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, tickets]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Suporte"
|
||||
description="Tickets de atendimento ao cliente"
|
||||
actions={
|
||||
<Link to="/support/tickets/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Novo ticket
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{tickets === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando tickets…</p>
|
||||
) : tickets && tickets.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhum ticket cadastrado ainda.</p>
|
||||
) : tickets ? (
|
||||
<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">Protocolo</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Título</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Categoria</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Prioridade</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">SLA</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Aberto em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((ticket) => {
|
||||
const overdue = overdueByTicket[ticket.id];
|
||||
return (
|
||||
<tr key={ticket.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/support/tickets/${ticket.id}`}
|
||||
className="font-semibold text-title hover:text-primary"
|
||||
>
|
||||
{ticket.protocol_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{ticket.title}</td>
|
||||
<td className="px-5 py-3 text-default">
|
||||
{ticket.category}
|
||||
{ticket.subcategory ? (
|
||||
<span className="block text-xs text-default">{ticket.subcategory}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={TICKET_PRIORITY_VARIANT[ticket.priority]}>
|
||||
{TICKET_PRIORITY_LABEL[ticket.priority]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={TICKET_STATUS_VARIANT[ticket.status]}>
|
||||
{TICKET_STATUS_LABEL[ticket.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{overdue === true ? (
|
||||
<Badge variant="danger">SLA vencido</Badge>
|
||||
) : TERMINAL_STATUSES.includes(ticket.status) ? (
|
||||
<span className="text-default">—</span>
|
||||
) : overdue === undefined ? (
|
||||
<span className="text-default">—</span>
|
||||
) : (
|
||||
<Badge variant="success">Em dia</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{formatDateBR(ticket.created_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isSlaOverdue(slaTimer: { first_response_due_at: string; resolution_due_at: string; first_response_met: boolean | null; resolution_met: boolean | null } | null): boolean {
|
||||
if (!slaTimer) return false;
|
||||
if (slaTimer.first_response_met === false || slaTimer.resolution_met === false) return true;
|
||||
const now = Date.now();
|
||||
if (slaTimer.first_response_met === null && new Date(slaTimer.first_response_due_at).getTime() < now) return true;
|
||||
if (slaTimer.resolution_met === null && new Date(slaTimer.resolution_due_at).getTime() < now) return true;
|
||||
return false;
|
||||
}
|
||||
42
apps/core-web/src/routes/support/ticketStatus.ts
Normal file
42
apps/core-web/src/routes/support/ticketStatus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import type { TicketPriority, TicketStatus } from "../../lib/api/support.js";
|
||||
|
||||
/** Shared pt-BR labels/badge colors for `TicketStatus` — used by the list, detail, and status-transition UI. */
|
||||
export const TICKET_STATUS_LABEL: Record<TicketStatus, string> = {
|
||||
new: "Novo",
|
||||
triage: "Triagem",
|
||||
in_progress: "Em andamento",
|
||||
waiting_customer: "Aguardando cliente",
|
||||
waiting_third_party: "Aguardando terceiro",
|
||||
resolved: "Resolvido",
|
||||
closed: "Fechado",
|
||||
cancelled: "Cancelado",
|
||||
};
|
||||
|
||||
export const TICKET_STATUS_VARIANT: Record<TicketStatus, BadgeVariant> = {
|
||||
new: "info",
|
||||
triage: "warning",
|
||||
in_progress: "primary",
|
||||
waiting_customer: "warning",
|
||||
waiting_third_party: "warning",
|
||||
resolved: "success",
|
||||
closed: "neutral",
|
||||
cancelled: "danger",
|
||||
};
|
||||
|
||||
/** Statuses where the backend's SLA timer is paused (see `slaTimer.paused_since`). */
|
||||
export const SLA_PAUSED_STATUSES: TicketStatus[] = ["waiting_customer", "waiting_third_party"];
|
||||
|
||||
export const TICKET_PRIORITY_LABEL: Record<TicketPriority, string> = {
|
||||
low: "Baixa",
|
||||
medium: "Média",
|
||||
high: "Alta",
|
||||
critical: "Crítica",
|
||||
};
|
||||
|
||||
export const TICKET_PRIORITY_VARIANT: Record<TicketPriority, BadgeVariant> = {
|
||||
low: "neutral",
|
||||
medium: "info",
|
||||
high: "warning",
|
||||
critical: "danger",
|
||||
};
|
||||
@@ -381,3 +381,26 @@ Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`ede
|
||||
|
||||
### Pendências
|
||||
Nenhuma tela ainda para Support/HR-Timeclock/Fiscal/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login; a lacuna de `reference_type` no ledger de movimentos de instalação/devolução (achado acima) precisa de correção no backend numa leva futura.
|
||||
|
||||
---
|
||||
|
||||
## Suporte e Fiscal no EDEN Core
|
||||
|
||||
Data: 2026-09-04. Continuação direta, sem parar para autorização. 2 subagentes `eden-frontend` em paralelo, referência do tema DreamsERP (`support/tickets.tsx`/`ticketDetails.tsx`/`slaManagement.tsx` para Suporte; `inventory/categories.tsx`/`units.tsx`/`brands.tsx` para os catálogos fiscais — padrão "catálogo simples" quase idêntico).
|
||||
|
||||
### Suporte (`apps/core-web/src/routes/support/`)
|
||||
|
||||
Lista de tickets, criação, e detalhe rico: painel de SLA (prazos de primeira resposta/resolução, indicador cumprido/violado/pendente, aviso "SLA pausado" quando aplicável), thread de comentários públicos/internos com formulário de resposta, histórico de status (timeline), transições limitadas aos alvos válidos com campos condicionais de causa raiz/resolução ao fechar. Tradeoff documentado pelo próprio agente: a listagem não traz o `slaTimer` (só o detalhe traz), então o indicador de SLA vencido na lista busca o detalhe em background só para tickets não-terminais.
|
||||
|
||||
### Fiscal (`apps/core-web/src/routes/fiscal/`)
|
||||
|
||||
NCM/CFOP/Municípios (CRUD simples com formulário inline) e Naturezas de Operação (só leitura, as 11 seedadas). Nova seção "Fiscal" dentro de `ProductFormPage` (só ao editar um produto existente): histórico de perfis fiscais do produto com aviso claro de que criar um novo perfil do mesmo componente substitui automaticamente o anterior — confirmado ao vivo pelo próprio agente antes de entregar.
|
||||
|
||||
### Integração
|
||||
|
||||
Os dois agentes 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. Reconciliação manual de `App.tsx`/`AuthenticatedLayout.tsx`/`ProductFormPage.tsx`/`format.ts` (este último ganhou `formatDateTimeBR`, puramente aditivo).
|
||||
|
||||
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): criação de ticket e listagem de naturezas de operação; trilha de auditoria conferida no Postgres.
|
||||
|
||||
### Pendências
|
||||
Nenhuma tela ainda para HR-Timeclock/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login. Com esta leva, o EDEN Core tem UI real para praticamente todo o backend construído até agora, exceto HR-Timeclock e Documentos.
|
||||
|
||||
Reference in New Issue
Block a user