diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index eff1969..ff7471b 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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() { } /> } /> } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> diff --git a/apps/core-web/src/lib/api/fiscal.ts b/apps/core-web/src/lib/api/fiscal.ts new file mode 100644 index 0000000..e76cdbd --- /dev/null +++ b/apps/core-web/src/lib/api/fiscal.ts @@ -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 | 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 | 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; +} +export type UpdateNcmInput = Partial; + +/** 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; +} +export type UpdateCfopInput = Partial; + +/** + * 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; + +/** 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 { + return apiRequest("/fiscal/ncm", { method: "GET" }, { accessToken }); +} +export function createNcm(input: CreateNcmInput, accessToken: string): Promise { + return apiRequest("/fiscal/ncm", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} +export function updateNcm(id: string, input: UpdateNcmInput, accessToken: string): Promise { + return apiRequest(`/fiscal/ncm/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken }); +} + +// ---- CFOP ---- +export function listCfop(accessToken: string): Promise { + return apiRequest("/fiscal/cfop", { method: "GET" }, { accessToken }); +} +export function createCfop(input: CreateCfopInput, accessToken: string): Promise { + return apiRequest("/fiscal/cfop", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} +export function updateCfop(id: string, input: UpdateCfopInput, accessToken: string): Promise { + return apiRequest(`/fiscal/cfop/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken }); +} + +// ---- Municípios ---- +export function listMunicipalities(accessToken: string): Promise { + return apiRequest("/fiscal/municipalities", { method: "GET" }, { accessToken }); +} +export function createMunicipality( + input: CreateMunicipalityInput, + accessToken: string, +): Promise { + return apiRequest( + "/fiscal/municipalities", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} +export function updateMunicipality( + id: string, + input: UpdateMunicipalityInput, + accessToken: string, +): Promise { + return apiRequest( + `/fiscal/municipalities/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +// ---- Naturezas de operação (somente leitura) ---- +export function listOperationNatures(accessToken: string): Promise { + return apiRequest("/fiscal/operation-natures", { method: "GET" }, { accessToken }); +} + +// ---- Perfil fiscal de produto ---- +export function listProductFiscalProfiles( + productId: string, + accessToken: string, +): Promise { + return apiRequest( + `/fiscal/products/${productId}/profiles`, + { method: "GET" }, + { accessToken }, + ); +} +export function createProductFiscalProfile( + productId: string, + input: CreateProductFiscalProfileInput, + accessToken: string, +): Promise { + return apiRequest( + `/fiscal/products/${productId}/profiles`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/support.ts b/apps/core-web/src/lib/api/support.ts new file mode 100644 index 0000000..f9ff17f --- /dev/null +++ b/apps/core-web/src/lib/api/support.ts @@ -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 = { + 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 { + return apiRequest("/support/tickets", { method: "GET" }, { accessToken }); +} + +export function getTicket(id: string, accessToken: string): Promise { + return apiRequest(`/support/tickets/${id}`, { method: "GET" }, { accessToken }); +} + +export function createTicket(input: CreateTicketInput, accessToken: string): Promise { + return apiRequest( + "/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 { + return apiRequest( + `/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 { + return apiRequest( + `/support/tickets/${id}/comments`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts index c30f27b..4ec1ef9 100644 --- a/apps/core-web/src/lib/format.ts +++ b/apps/core-web/src/lib/format.ts @@ -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 "—"; diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index cc792dc..01ac51c 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { diff --git a/apps/core-web/src/routes/fiscal/CfopListPage.tsx b/apps/core-web/src/routes/fiscal/CfopListPage.tsx new file mode 100644 index 0000000..76b202e --- /dev/null +++ b/apps/core-web/src/routes/fiscal/CfopListPage.tsx @@ -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 ``, 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(null); + const [error, setError] = useState(null); + + const [formOpen, setFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(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(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> = [ + { key: "code", header: "Código", render: (item) => {item.code} }, + { 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) => {item.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (item) => ( + + ), + }, + ]; + + return ( +
+ + Novo CFOP + + } + /> + + + + {error ? ( + + {error} + + ) : null} + + {formOpen ? ( + + {formError ? ( + + {formError} + + ) : null} +
+
+ updateField("code", e.target.value)} required /> +
+ +
+
+ updateField("description", e.target.value)} + required + /> +
+ updateField("direction", e.target.value)} + /> + updateField("scope", e.target.value)} + /> + updateField("validFrom", e.target.value)} + /> + updateField("validUntil", e.target.value)} + /> +
+
+ + +
+
+
+ ) : null} + + + {items === null && !error ? ( +

Carregando…

+ ) : ( + item.id} emptyMessage="Nenhum CFOP cadastrado." /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/fiscal/FiscalSubNav.tsx b/apps/core-web/src/routes/fiscal/FiscalSubNav.tsx new file mode 100644 index 0000000..74c6e3e --- /dev/null +++ b/apps/core-web/src/routes/fiscal/FiscalSubNav.tsx @@ -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 ( +
+ {TABS.map((tab) => { + const active = location.pathname.startsWith(tab.href); + return ( + + {tab.label} + + ); + })} +
+ ); +} diff --git a/apps/core-web/src/routes/fiscal/MunicipalitiesListPage.tsx b/apps/core-web/src/routes/fiscal/MunicipalitiesListPage.tsx new file mode 100644 index 0000000..5c7d387 --- /dev/null +++ b/apps/core-web/src/routes/fiscal/MunicipalitiesListPage.tsx @@ -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(null); + const [error, setError] = useState(null); + + const [formOpen, setFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(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(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> = [ + { key: "code", header: "Código IBGE", render: (item) => {item.code} }, + { key: "name", header: "Nome", render: (item) => item.name }, + { key: "state", header: "UF", render: (item) => item.state }, + { + key: "status", + header: "Status", + render: (item) => {item.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (item) => ( + + ), + }, + ]; + + return ( +
+ + Novo município + + } + /> + + + + {error ? ( + + {error} + + ) : null} + + {formOpen ? ( + + {formError ? ( + + {formError} + + ) : null} +
+
+ updateField("code", e.target.value)} required /> + updateField("state", e.target.value.toUpperCase())} + required + /> +
+ +
+
+ updateField("name", e.target.value)} required /> +
+
+
+ + +
+ +
+ ) : null} + + + {items === null && !error ? ( +

Carregando…

+ ) : ( +
item.id} + emptyMessage="Nenhum município cadastrado." + /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/fiscal/NcmListPage.tsx b/apps/core-web/src/routes/fiscal/NcmListPage.tsx new file mode 100644 index 0000000..9dc0616 --- /dev/null +++ b/apps/core-web/src/routes/fiscal/NcmListPage.tsx @@ -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 ``, 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(null); + const [error, setError] = useState(null); + + const [formOpen, setFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(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(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> = [ + { key: "code", header: "Código", render: (item) => {item.code} }, + { 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) => {item.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (item) => ( + + ), + }, + ]; + + return ( +
+ + Novo NCM + + } + /> + + + + {error ? ( + + {error} + + ) : null} + + {formOpen ? ( + + {formError ? ( + + {formError} + + ) : null} +
+
+ updateField("code", e.target.value)} required /> +
+ +
+
+ updateField("description", e.target.value)} + required + /> +
+ updateField("validFrom", e.target.value)} + /> + updateField("validUntil", e.target.value)} + /> +
+
+ + +
+ +
+ ) : null} + + + {items === null && !error ? ( +

Carregando…

+ ) : ( +
item.id} emptyMessage="Nenhum NCM cadastrado." /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/fiscal/OperationNaturesListPage.tsx b/apps/core-web/src/routes/fiscal/OperationNaturesListPage.tsx new file mode 100644 index 0000000..b31c16e --- /dev/null +++ b/apps/core-web/src/routes/fiscal/OperationNaturesListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (item) => {item.code} }, + { key: "name", header: "Nome", render: (item) => item.name }, + { key: "description", header: "Descrição", render: (item) => item.description ?? "—" }, + { + key: "status", + header: "Status", + render: (item) => {item.active ? "Ativo" : "Inativo"}, + }, + ]; + + return ( +
+ + + + + {error ? ( + + {error} + + ) : null} + + + {items === null && !error ? ( +

Carregando…

+ ) : ( +
item.id} + emptyMessage="Nenhuma natureza de operação cadastrada." + /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFiscalSection.tsx b/apps/core-web/src/routes/products/ProductFiscalSection.tsx new file mode 100644 index 0000000..aa5daea --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFiscalSection.tsx @@ -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 = { + RECURRING: "Recorrente", + IMPLEMENTATION: "Implantação", +}; + +const DOCUMENT_TYPE_LABELS: Record = { + 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(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(null); + const [error, setError] = useState(null); + + const [ncmList, setNcmList] = useState([]); + const [cfopList, setCfopList] = useState([]); + const [municipalityList, setMunicipalityList] = useState([]); + const [natureList, setNatureList] = useState([]); + const [catalogError, setCatalogError] = useState(null); + + const [newProfile, setNewProfile] = useState(EMPTY_NEW_PROFILE); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(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(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 ( + + {error ? ( + + {error} + + ) : null} + {catalogError ? ( + + {catalogError} + + ) : null} + + {profiles === null && !error ? ( +

Carregando perfis fiscais…

+ ) : (profiles ?? []).length === 0 ? ( +

Nenhum perfil fiscal cadastrado para este produto.

+ ) : ( +
+
+ + + + + + + + + + + + + + {(profiles ?? []) + .slice() + .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) + .map((profile) => ( + + + + + + + + + + + ))} + +
ComponenteDocumentoNCMCFOPMunicípioNaturezaVigênciaStatus
{BILLING_COMPONENT_LABELS[profile.billing_component]}{DOCUMENT_TYPE_LABELS[profile.document_type]} + {profile.ncm_id ? ncmLookup.get(profile.ncm_id) ?? profile.ncm_id : "—"} + + {profile.cfop_id ? cfopLookup.get(profile.cfop_id) ?? profile.cfop_id : "—"} + + {profile.municipality_id ? municipalityLookup.get(profile.municipality_id) ?? profile.municipality_id : "—"} + + {profile.operation_nature_id ? natureLookup.get(profile.operation_nature_id) ?? profile.operation_nature_id : "—"} + + {formatDateBR(profile.valid_from)} — {profile.valid_until ? formatDateBR(profile.valid_until) : "atual"} + + {profile.active ? "Ativo" : "Inativo"} +
+
+ )} + +
+

Novo perfil fiscal

+ {createError ? ( + + {createError} + + ) : null} + {activeForSameComponent ? ( + + Já existe um perfil ativo para "{BILLING_COMPONENT_LABELS[newProfile.billingComponent]}". Criar um novo + perfil vai substituir (inativar) o perfil ativo atual automaticamente. + + ) : null} +
+ updateNewProfile("documentType", e.target.value as FiscalDocumentType)} + /> + updateNewProfile("cfopId", e.target.value)} + /> + updateNewProfile("operationNatureId", e.target.value)} + /> +
+ + updateNewProfile("validFrom", e.target.value)} + /> +
+
+ + updateNewProfile("validUntil", e.target.value)} + /> +
+
+ +
+
+
+ + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx index 8c8d9b9..955f5cd 100644 --- a/apps/core-web/src/routes/products/ProductFormPage.tsx +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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() { )} + + {/* + 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 ? ( +
+ +
+ ) : null} ); } diff --git a/apps/core-web/src/routes/support/TicketDetailPage.tsx b/apps/core-web/src/routes/support/TicketDetailPage.tsx new file mode 100644 index 0000000..f453112 --- /dev/null +++ b/apps/core-web/src/routes/support/TicketDetailPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +

Carregando ticket…

+
+ ); + } + + if (error || !ticket) { + return ( +
+ + {error ?? "Ticket não encontrado."} +
+ ); + } + + return ( +
+ + + + {TICKET_PRIORITY_LABEL[ticket.priority]} + + {TICKET_STATUS_LABEL[ticket.status]} +
+ } + /> + +
+
+ +
+ + + + + + + + + + + + +
+ {ticket.root_cause ? ( +
+

Causa raiz

+

{ticket.root_cause}

+
+ ) : null} + {ticket.resolution_notes ? ( +
+

Notas de resolução

+

{ticket.resolution_notes}

+
+ ) : null} +
+ + + + + + +
+ +
+ +

{ticket.description}

+
+ + +
+
+ + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function MetBadge({ met, label }: { met: boolean | null; label: string }) { + return ( +
+ {label} + {met === null ? ( + Aguardando + ) : met ? ( + Cumprido + ) : ( + Violado + )} +
+ ); +} + +function SlaCard({ slaTimer, status }: { slaTimer: TicketWithDetails["slaTimer"]; status: TicketStatus }) { + if (!slaTimer) { + return ( + +

Nenhuma política de SLA aplicada a este ticket.

+
+ ); + } + + const isPaused = slaTimer.paused_since !== null && SLA_PAUSED_STATUSES.includes(status); + + return ( + + {isPaused ? ( + + SLA pausado desde {formatDateTimeBR(slaTimer.paused_since)} — o contador só volta a correr quando o + ticket sair de "{TICKET_STATUS_LABEL[status]}". + + ) : null} +
+
+

Prazo de 1ª resposta

+

{formatDateTimeBR(slaTimer.first_response_due_at)}

+
+ +
+

Prazo de resolução

+

{formatDateTimeBR(slaTimer.resolution_due_at)}

+
+ + {slaTimer.accumulated_pause_seconds > 0 ? ( +

+ Tempo pausado acumulado: {Math.round(slaTimer.accumulated_pause_seconds / 60)} min +

+ ) : null} +
+
+ ); +} + +function StatusTransitionCard({ + ticket, + accessToken, + onChanged, +}: { + ticket: TicketWithDetails; + accessToken: string | null; + onChanged: () => void; +}) { + const validTargets = TICKET_STATUS_TRANSITIONS[ticket.status] ?? []; + const [toStatus, setToStatus] = useState(validTargets[0] ?? ""); + const [reason, setReason] = useState(""); + const [rootCause, setRootCause] = useState(""); + const [resolutionNotes, setResolutionNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +

+ Status {TICKET_STATUS_LABEL[ticket.status]} é final — nenhuma transição disponível. +

+
+ ); + } + + 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 ( + + {error ? ( + + {error} + + ) : null} +
+ setReason(e.target.value)} /> + {isClosing ? ( + <> +
+ +