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

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

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

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

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001); trilha de auditoria conferida no Postgres.

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

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

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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() {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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",
};

View File

@@ -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.