diff --git a/src/App.tsx b/src/App.tsx
index b809005..57ebb7c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,14 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ThemeProvider } from "next-themes";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect";
-import { GlobalFunctions } from "./GlobalFunctions";
import React from "react";
import IntelligenceIAApp from "./modules/intelligence-ia/App";
+import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
const queryClient = new QueryClient();
@@ -33,8 +33,8 @@ const App = () => (
element={ }
/>
- {/* Módulo Intelligence IA como subpath*/}
} />
+ } />
} />
diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx
new file mode 100644
index 0000000..ec04f9f
--- /dev/null
+++ b/src/components/auth/AuthGate.tsx
@@ -0,0 +1,163 @@
+import { useEffect, type ReactNode } from "react";
+import { Loader2 } from "lucide-react";
+import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
+import { useAuthAccess } from "@/contexts/AuthAccessContext";
+import { authMeService } from "@/services/fechamento/authMe";
+import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
+
+type AuthGateProps = {
+ children: ReactNode;
+};
+
+const CORE_URL_FALLBACK = "https://core.hgtx.com.br";
+
+export function AuthGate({ children }: AuthGateProps) {
+ const {
+ loading,
+ hasAccess,
+ reason,
+ errorMessage,
+ setAuthState,
+ } = useAuthAccess();
+
+ useEffect(() => {
+ let mounted = true;
+
+ const bootstrapAccess = async () => {
+ const isLogado = GlobalFunctions.isUsuarioLogado();
+ if (!isLogado) {
+ setAuthState({ loading: false, isAuthenticated: false, hasAccess: false });
+ window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || CORE_URL_FALLBACK);
+ return;
+ }
+
+ let userEmail = GlobalFunctions.getUsuarioLogado().email;
+ if (!userEmail) {
+ const transferEmail = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
+ if (typeof transferEmail === "string" && transferEmail.trim()) {
+ userEmail = transferEmail.trim();
+ }
+ }
+
+ if (!userEmail) {
+ try {
+ await GlobalFunctions.getToken();
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ userEmail = GlobalFunctions.getUsuarioLogado().email;
+ } catch (error) {
+ console.error("Erro ao obter token para validação de acesso:", error);
+ }
+ }
+
+ if (!userEmail) {
+ setAuthState({
+ loading: false,
+ isAuthenticated: false,
+ hasAccess: false,
+ reason: "erro",
+ errorMessage: "Não foi possível identificar o e-mail do usuário logado.",
+ });
+ return;
+ }
+
+ try {
+ const me = await authMeService.getMe(userEmail);
+ if (!mounted) {
+ return;
+ }
+
+ if (!me) {
+ const estabelecimentoCodigo = String(
+ GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
+ ).trim();
+ if (estabelecimentoCodigo) {
+ try {
+ const bootstrap = await authMeService.getBootstrapStatus(estabelecimentoCodigo);
+ if (bootstrap.requiresBootstrap) {
+ setAuthState({
+ loading: false,
+ isAuthenticated: true,
+ hasAccess: false,
+ reason: "bootstrap",
+ papel: null,
+ me: null,
+ errorMessage: estabelecimentoCodigo,
+ });
+ return;
+ }
+ } catch {
+ // Mantém fluxo padrão de não cadastrado caso não consiga consultar bootstrap.
+ }
+ }
+ setAuthState({
+ loading: false,
+ isAuthenticated: true,
+ hasAccess: false,
+ reason: "nao_cadastrado",
+ papel: null,
+ me: null,
+ errorMessage: null,
+ });
+ return;
+ }
+
+ if (!me.estaAtivo) {
+ setAuthState({
+ loading: false,
+ isAuthenticated: true,
+ hasAccess: false,
+ reason: "inativo",
+ papel: me.papel,
+ me,
+ errorMessage: null,
+ });
+ return;
+ }
+
+ setAuthState({
+ loading: false,
+ isAuthenticated: true,
+ hasAccess: true,
+ papel: me.papel,
+ me,
+ reason: null,
+ errorMessage: null,
+ });
+ } catch (error) {
+ if (!mounted) {
+ return;
+ }
+ setAuthState({
+ loading: false,
+ isAuthenticated: true,
+ hasAccess: false,
+ reason: "erro",
+ errorMessage: error instanceof Error ? error.message : "Erro ao validar acesso.",
+ });
+ }
+ };
+
+ bootstrapAccess();
+
+ return () => {
+ mounted = false;
+ };
+ }, [setAuthState]);
+
+ if (loading) {
+ return (
+
+
+
+
Validando acesso...
+
+
+ );
+ }
+
+ if (!hasAccess) {
+ return ;
+ }
+
+ return <>{children}>;
+}
diff --git a/src/components/auth/NoAccessScreen.tsx b/src/components/auth/NoAccessScreen.tsx
new file mode 100644
index 0000000..93250ba
--- /dev/null
+++ b/src/components/auth/NoAccessScreen.tsx
@@ -0,0 +1,125 @@
+import { useState } from "react";
+import { Loader2, ShieldX } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
+import { authMeService } from "@/services/fechamento/authMe";
+
+type NoAccessScreenProps = {
+ reason: AccessBlockReason | null;
+ errorMessage?: string | null;
+};
+
+function getMessage(reason: AccessBlockReason | null): string {
+ if (reason === "inativo") {
+ return "Seu usuário está inativo neste módulo. Solicite a reativação ao administrador.";
+ }
+ if (reason === "erro") {
+ return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
+ }
+ if (reason === "bootstrap") {
+ return "Primeiro acesso detectado. Cadastre a unidade e o primeiro usuário administrador para iniciar o sistema.";
+ }
+ return "Você não tem acesso a este módulo no momento. Solicite acesso ao administrador.";
+}
+
+export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
+ const [unidadeNome, setUnidadeNome] = useState("");
+ const [adminNome, setAdminNome] = useState("");
+ const [adminEmail, setAdminEmail] = useState("");
+ const [saving, setSaving] = useState(false);
+ const estabelecimentoId = (errorMessage ?? "").trim();
+
+ const handleBootstrap = async () => {
+ if (!estabelecimentoId) {
+ return;
+ }
+ if (!unidadeNome.trim() || !adminNome.trim() || !adminEmail.trim()) {
+ alert("Preencha nome da unidade, nome do admin e e-mail.");
+ return;
+ }
+ try {
+ setSaving(true);
+ await authMeService.bootstrapInitialize({
+ estabelecimentoId,
+ unidadeNome: unidadeNome.trim(),
+ adminNome: adminNome.trim(),
+ adminEmail: adminEmail.trim(),
+ });
+ window.location.reload();
+ } catch (error) {
+ alert(error instanceof Error ? error.message : "Erro ao inicializar ambiente.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Você não tem acesso a este módulo
+
+
+ {getMessage(reason)}
+ {reason === "bootstrap" ? (
+
+ ) : null}
+ {reason !== "bootstrap" && errorMessage ? (
+ {errorMessage}
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/fechamento/UnidadeGate.tsx b/src/components/fechamento/UnidadeGate.tsx
new file mode 100644
index 0000000..1c69e4d
--- /dev/null
+++ b/src/components/fechamento/UnidadeGate.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { Link, useLocation } from "react-router-dom";
+import { Building2, Loader2, MapPinOff } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { useAuthAccess } from "@/contexts/AuthAccessContext";
+import {
+ getEstabelecimentoCodigoFromTransfer,
+ lookupUnidadeByEstabelecimento,
+} from "@/services/fechamento/unidadeContext";
+
+type GatePhase =
+ | "loading"
+ | "ready"
+ | "sem_codigo_transfer"
+ | "unidade_nao_cadastrada"
+ | "usuario_outra_unidade";
+
+type UnidadeGateProps = {
+ children: ReactNode;
+};
+
+function isConfiguracoesPath(pathname: string): boolean {
+ return pathname.includes("configuracoes");
+}
+
+export function UnidadeGate({ children }: UnidadeGateProps) {
+ const { me, papel } = useAuthAccess();
+ const location = useLocation();
+ const [phase, setPhase] = useState("loading");
+ const [codigoTransfer, setCodigoTransfer] = useState("");
+
+ useEffect(() => {
+ let mounted = true;
+
+ const run = async () => {
+ const codigo = getEstabelecimentoCodigoFromTransfer();
+ if (!mounted) return;
+ setCodigoTransfer(codigo);
+
+ if (!codigo) {
+ setPhase("sem_codigo_transfer");
+ return;
+ }
+
+ try {
+ const row = await lookupUnidadeByEstabelecimento(codigo);
+ if (!mounted) return;
+
+ if (!row) {
+ setPhase("unidade_nao_cadastrada");
+ return;
+ }
+
+ if (papel !== "admin" && me?.unidadeId && me.unidadeId !== row.id) {
+ setPhase("usuario_outra_unidade");
+ return;
+ }
+
+ setPhase("ready");
+ } catch {
+ if (!mounted) return;
+ setPhase("unidade_nao_cadastrada");
+ }
+ };
+
+ void run();
+ return () => {
+ mounted = false;
+ };
+ }, [me?.unidadeId, location.pathname, papel]);
+
+ const isAdmin = papel === "admin";
+ const onConfiguracoes = isConfiguracoesPath(location.pathname);
+
+ if (phase === "loading") {
+ return (
+
+
+
+
Verificando unidade do estabelecimento...
+
+
+ );
+ }
+
+ if (phase === "ready") {
+ return <>{children}>;
+ }
+
+ if (phase === "unidade_nao_cadastrada" && isAdmin && onConfiguracoes) {
+ return <>{children}>;
+ }
+
+ if (phase === "sem_codigo_transfer") {
+ return (
+
+
+
+
+
+
+ Estabelecimento não identificado
+
+
+
+ O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
+ Fechamento HGTX a partir do Codex com o estabelecimento carregado no transfer.
+
+
+
+
+ );
+ }
+
+ if (phase === "unidade_nao_cadastrada") {
+ return (
+
+
+
+
+
+
+ Unidade ainda não cadastrada
+
+
+
+ Não existe unidade no Commander para o estabelecimento{" "}
+ {codigoTransfer || "—"} .
+
+ {isAdmin ? (
+ <>
+
+ Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
+
+
+ Ir para Configurações
+
+ >
+ ) : (
+
+ Peça a um administrador para cadastrar a unidade deste estabelecimento no Commander. Informe o
+ código: {codigoTransfer}
+
+ )}
+
+
+
+ );
+ }
+
+ if (phase === "usuario_outra_unidade") {
+ return (
+
+
+
+ Estabelecimento diferente do seu cadastro
+
+
+
+ Você está vinculado a outra unidade no Commander do que o estabelecimento aberto neste contexto
+ (código {codigoTransfer} ).
+
+
+ Abra o módulo com o estabelecimento correspondente ao seu usuário ou solicite ajuste ao
+ administrador.
+
+
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/src/contexts/AuthAccessContext.tsx b/src/contexts/AuthAccessContext.tsx
new file mode 100644
index 0000000..287da6f
--- /dev/null
+++ b/src/contexts/AuthAccessContext.tsx
@@ -0,0 +1,60 @@
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
+import type { MeData, PapelUsuario } from "@/services/fechamento/authMe";
+
+export type AccessBlockReason = "nao_cadastrado" | "inativo" | "erro" | "bootstrap";
+
+type AuthAccessState = {
+ loading: boolean;
+ isAuthenticated: boolean;
+ hasAccess: boolean;
+ papel: PapelUsuario | null;
+ me: MeData | null;
+ reason: AccessBlockReason | null;
+ errorMessage: string | null;
+};
+
+type AuthAccessContextValue = AuthAccessState & {
+ setAuthState: (state: Partial) => void;
+ resetAuthState: () => void;
+};
+
+const initialState: AuthAccessState = {
+ loading: true,
+ isAuthenticated: false,
+ hasAccess: false,
+ papel: null,
+ me: null,
+ reason: null,
+ errorMessage: null,
+};
+
+const AuthAccessContext = createContext(undefined);
+
+export function AuthAccessProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState(initialState);
+ const setAuthState = useCallback((partial: Partial) => {
+ setState((prev) => ({ ...prev, ...partial }));
+ }, []);
+ const resetAuthState = useCallback(() => {
+ setState(initialState);
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ ...state,
+ setAuthState,
+ resetAuthState,
+ }),
+ [state, setAuthState, resetAuthState],
+ );
+
+ return {children} ;
+}
+
+export function useAuthAccess() {
+ const ctx = useContext(AuthAccessContext);
+ if (!ctx) {
+ throw new Error("useAuthAccess deve ser usado dentro de AuthAccessProvider.");
+ }
+ return ctx;
+}
diff --git a/src/modules/fechamento-hgtx/App.tsx b/src/modules/fechamento-hgtx/App.tsx
new file mode 100644
index 0000000..5f43d27
--- /dev/null
+++ b/src/modules/fechamento-hgtx/App.tsx
@@ -0,0 +1,69 @@
+import { Navigate, Route, Routes } from "react-router-dom";
+import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
+import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
+import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
+import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
+import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
+import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
+import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
+import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
+import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
+import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
+import { AuthGate } from "@/components/auth/AuthGate";
+import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
+
+function RequireAdminRoute({ children }: { children: JSX.Element }) {
+ const { papel } = useAuthAccess();
+
+ if (papel === "admin") {
+ return children;
+ }
+
+ return ;
+}
+
+const FechamentoHgtxApp = () => {
+ return (
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+
+
+
+
+ );
+};
+
+export default FechamentoHgtxApp;
diff --git a/src/modules/fechamento-hgtx/components/layout/AppSidebar.tsx b/src/modules/fechamento-hgtx/components/layout/AppSidebar.tsx
new file mode 100644
index 0000000..cb421ee
--- /dev/null
+++ b/src/modules/fechamento-hgtx/components/layout/AppSidebar.tsx
@@ -0,0 +1,127 @@
+import { useState } from "react";
+import { NavLink } from "react-router-dom";
+import {
+ ChevronLeft,
+ ClipboardList,
+ Landmark,
+ Menu,
+ Settings,
+ Users,
+ Wallet,
+ X,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useAuthAccess } from "@/contexts/AuthAccessContext";
+
+const navItems = [
+ { title: "Fechamentos", path: "", icon: ClipboardList, onlyAdmin: false },
+ { title: "Banco de Pontos", path: "banco-pontos", icon: Landmark, onlyAdmin: false },
+ { title: "Parceiros", path: "parceiros", icon: Wallet, onlyAdmin: true },
+ { title: "Usuários", path: "usuarios", icon: Users, onlyAdmin: true },
+ { title: "Configurações", path: "configuracoes", icon: Settings, onlyAdmin: true },
+];
+
+export function AppSidebar() {
+ const { papel } = useAuthAccess();
+ const [collapsed, setCollapsed] = useState(false);
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const isAdmin = papel === "admin";
+ const allowedNavItems = navItems.filter((item) => isAdmin || !item.onlyAdmin);
+
+ const SidebarContent = () => (
+ <>
+
+
+
+
+ {!collapsed && (
+
+
Fechamento HGTX
+
+ {isAdmin ? "Painel Admin" : "Painel Parceiro"}
+
+
+ )}
+
+
+
+ {allowedNavItems.map((item) => (
+ setMobileOpen(false)}
+ className={({ isActive }) =>
+ cn(
+ "nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
+ isActive
+ ? "bg-sidebar-accent font-medium text-primary"
+ : "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
+ )
+ }
+ >
+
+ {!collapsed && {item.title} }
+
+ ))}
+
+
+
+ setCollapsed(!collapsed)}
+ className="nav-item flex w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent hover:text-foreground lg:justify-start"
+ >
+
+ {!collapsed && Recolher }
+
+
+ >
+ );
+
+ return (
+ <>
+ setMobileOpen(true)}
+ className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
+ >
+
+
+
+ {mobileOpen && (
+ setMobileOpen(false)}
+ />
+ )}
+
+
+ setMobileOpen(false)}
+ className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
+ >
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/modules/fechamento-hgtx/components/layout/MainLayout.tsx b/src/modules/fechamento-hgtx/components/layout/MainLayout.tsx
new file mode 100644
index 0000000..1f08922
--- /dev/null
+++ b/src/modules/fechamento-hgtx/components/layout/MainLayout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from "react";
+import { AppSidebar } from "@/modules/fechamento-hgtx/components/layout/AppSidebar";
+
+type MainLayoutProps = {
+ children: ReactNode;
+};
+
+export function MainLayout({ children }: MainLayoutProps) {
+ return (
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/BancoPontos.tsx b/src/modules/fechamento-hgtx/pages/BancoPontos.tsx
new file mode 100644
index 0000000..820ff05
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/BancoPontos.tsx
@@ -0,0 +1,23 @@
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+export default function BancoPontos() {
+ return (
+
+
+
Banco de Pontos
+
+ Consulte saldos por parceiro e extrato de créditos/débitos.
+
+
+
+
+
+ Estrutura inicial pronta
+
+
+ Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx b/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx
new file mode 100644
index 0000000..812e617
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx
@@ -0,0 +1,406 @@
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, Download, ExternalLink, FileSpreadsheet, FolderKanban, RefreshCcw } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
+import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
+
+function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
+ if (row.parceiroCodinome?.trim()) {
+ return `${row.parceiroNome} (${row.parceiroCodinome.trim()})`;
+ }
+ return row.parceiroNome;
+}
+
+function formatHoras(minutos: number | null): string {
+ if (!minutos || minutos <= 0) return "0h";
+ const horas = Math.floor(minutos / 60);
+ const mins = Math.round(minutos % 60);
+ if (horas === 0) return `${mins}min`;
+ if (mins === 0) return `${horas}h`;
+ return `${horas}h ${mins}min`;
+}
+
+export default function CompetenciaFechamentos() {
+ const { id: competenciaId = "" } = useParams();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(true);
+ const [fechamentos, setFechamentos] = useState
([]);
+ const [importing, setImporting] = useState(false);
+ const [exportingFechamentoId, setExportingFechamentoId] = useState(null);
+ const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
+ const [reprocessMode, setReprocessMode] = useState<
+ "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
+ >("reprocessar_tudo");
+ const [selectedParceiroIds, setSelectedParceiroIds] = useState([]);
+
+ const loadFechamentos = async () => {
+ try {
+ setLoading(true);
+ const data = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId);
+ setFechamentos(data);
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
+ toast.error(message);
+ setFechamentos([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleImportarAsana = async () => {
+ setImporting(true);
+ try {
+ const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
+ toast.success(
+ `Importação concluída: ${resultado.tarefasImportadas} tasks, ${resultado.fechamentosCriados} fechamentos criados.`,
+ );
+ await loadFechamentos();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
+ toast.error(message);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleExecutarReprocessamento = async () => {
+ if (reprocessMode === "reprocessar_alguns" && selectedParceiroIds.length === 0) {
+ toast.error("Selecione ao menos um fechamento/parceiro para reprocessar.");
+ return;
+ }
+
+ setImporting(true);
+ try {
+ const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
+ modo: reprocessMode,
+ parceiroIds: reprocessMode === "reprocessar_alguns" ? selectedParceiroIds : undefined,
+ });
+ if (reprocessMode === "buscar_novos_fechamentos") {
+ toast.success(
+ `Busca concluída: ${resultado.fechamentosCriados} novos fechamento(s) criado(s), sem alterar os atuais.`,
+ );
+ } else {
+ toast.success(
+ `Reprocessamento concluído: ${resultado.tarefasImportadas} tasks processadas e ${resultado.fechamentosCriados} fechamento(s) criado(s).`,
+ );
+ }
+ setIsReprocessModalOpen(false);
+ await loadFechamentos();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
+ toast.error(message);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleExportar = async (fechamentoId: string) => {
+ try {
+ setExportingFechamentoId(fechamentoId);
+ const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename ?? `fechamento-${fechamentoId}.xlsx`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ toast.success("Planilha exportada com sucesso.");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
+ toast.error(message);
+ } finally {
+ setExportingFechamentoId(null);
+ }
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+
+ if (competenciaId) {
+ void loadFechamentos();
+ }
+
+ return () => {
+ cancelled = true;
+ };
+ }, [competenciaId]);
+
+ useEffect(() => {
+ setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
+ }, [fechamentos]);
+
+ return (
+
+
+
+
navigate("/fechamento-hgtx")}>
+
+ Voltar
+
+
+
+
+ Fechamentos da Competência
+
+ {!loading && (
+
+
+ {fechamentos.length} fechamento(s)
+
+
setIsReprocessModalOpen(true)}
+ disabled={importing}
+ >
+
+ Reprocessar Asana
+
+
+ )}
+
+
+
+ {!loading && fechamentos.length === 0 ? (
+
+
+ Nenhum fechamento encontrado
+
+ Importe as tasks do Asana para criar os fechamentos automaticamente.
+
+
+
+ void handleImportarAsana()} disabled={importing}>
+
+ {importing ? "Importando..." : "Importar tasks do Asana"}
+
+
+
+ ) : (
+
+
+
+
+ Logo
+ Nome
+ Pontuação Total
+ Horas Total
+ Status
+ Ações
+
+
+
+ {loading ? (
+
+
+ Carregando fechamentos...
+
+
+ ) : (
+ fechamentos.map((row) => (
+
+
+ {row.parceiroLogoUrl ? (
+
+ ) : (
+ "—"
+ )}
+
+ {getDisplayNome(row)}
+ {row.pontuacaoTotalEntregue}
+ {formatHoras(row.horasTotal * 60)}
+
+
+ {row.status === "fechado" ? "Fechado" : "Em aberto"}
+
+
+
+
+ void handleExportar(row.id)}
+ disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
+ title="Exportar planilha financeira (XLSX)"
+ >
+
+ {exportingFechamentoId === row.id ? "Exportando..." : "Exportar"}
+
+
+ navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
+ state: { competenciaId, status: row.status },
+ })
+ }
+ >
+
+ Acessar
+
+
+
+
+ ))
+ )}
+
+
+
+ )}
+
+
+
+
+
+ Reprocessar Asana
+
+ Escolha uma estratégia de reprocessamento para esta competência.
+
+
+
+
+
+
setReprocessMode("reprocessar_tudo")}
+ className={`rounded-lg border p-3 text-left transition ${
+ reprocessMode === "reprocessar_tudo"
+ ? "border-primary bg-primary/5"
+ : "border-border hover:border-primary/60"
+ }`}
+ >
+ Reprocessar tudo
+
+ Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
+
+
+
setReprocessMode("reprocessar_alguns")}
+ className={`rounded-lg border p-3 text-left transition ${
+ reprocessMode === "reprocessar_alguns"
+ ? "border-primary bg-primary/5"
+ : "border-border hover:border-primary/60"
+ }`}
+ >
+ Reprocessar apenas alguns
+
+ Reprocessa somente os parceiros selecionados abaixo.
+
+
+
setReprocessMode("buscar_novos_fechamentos")}
+ className={`rounded-lg border p-3 text-left transition ${
+ reprocessMode === "buscar_novos_fechamentos"
+ ? "border-primary bg-primary/5"
+ : "border-border hover:border-primary/60"
+ }`}
+ >
+ Buscar novos fechamentos
+
+ Busca no Asana e cria apenas os fechamentos que ainda não existem.
+
+
+
+
+ {reprocessMode === "reprocessar_alguns" ? (
+
+
+
Selecione os fechamentos/parceiros
+
+ setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
+ >
+ Marcar todos
+
+ setSelectedParceiroIds([])}
+ >
+ Limpar
+
+
+
+ {fechamentos.map((f) => {
+ const checked = selectedParceiroIds.includes(f.parceiroId);
+ return (
+
+ {
+ const on = Boolean(value);
+ setSelectedParceiroIds((prev) =>
+ on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
+ );
+ }}
+ />
+
+ {getDisplayNome(f)}
+
+ {f.status === "fechado" ? "Fechado" : "Em aberto"}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+
+
+ setIsReprocessModalOpen(false)} disabled={importing}>
+ Cancelar
+
+ void handleExecutarReprocessamento()} disabled={importing}>
+ {importing ? "Processando..." : "Executar"}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/Configuracoes.tsx b/src/modules/fechamento-hgtx/pages/Configuracoes.tsx
new file mode 100644
index 0000000..833033e
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/Configuracoes.tsx
@@ -0,0 +1,461 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Separator } from "@/components/ui/separator";
+import { useNavigate } from "react-router-dom";
+import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
+import { toast } from "sonner";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { asanaService, type AsanaWorkspace } from "@/services/asana";
+import {
+ fechamentoConfiguracoesService,
+ type ConfiguracaoPublica,
+} from "@/services/fechamento/configuracoes";
+import { fechamentoUnidadesService } from "@/services/fechamento/unidades";
+import {
+ clearCommanderUnidadeIdCache,
+ getEstabelecimentoCodigoFromTransfer,
+ lookupUnidadeByEstabelecimento,
+ type UnidadeLookupRow,
+} from "@/services/fechamento/unidadeContext";
+import { useAuthAccess } from "@/contexts/AuthAccessContext";
+
+export default function Configuracoes() {
+ const navigate = useNavigate();
+ const { papel } = useAuthAccess();
+ const isAdmin = papel === "admin";
+ const unidadeSectionRef = useRef(null);
+
+ const [loading, setLoading] = useState(true);
+ const [configAsanaError, setConfigAsanaError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
+ const [showToken, setShowToken] = useState(false);
+
+ const [configAtual, setConfigAtual] = useState(null);
+ const [asanaToken, setAsanaToken] = useState("");
+ const [workspaces, setWorkspaces] = useState([]);
+ const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
+ const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
+
+ const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
+ const [unidadeExistente, setUnidadeExistente] = useState(null);
+ const [nomeUnidade, setNomeUnidade] = useState("");
+ const [loadingUnidade, setLoadingUnidade] = useState(true);
+ const [savingUnidade, setSavingUnidade] = useState(false);
+
+ const tokenJaConfigurado = Boolean(configAtual?.asanaTokenConfigured);
+ const tokenDigitado = asanaToken.trim();
+ const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
+
+ const workspaceOptions = useMemo(() => {
+ if (!selectedWorkspaceId || !selectedWorkspaceNome) {
+ return workspaces;
+ }
+ if (workspaces.some((item) => item.id === selectedWorkspaceId)) {
+ return workspaces;
+ }
+ return [{ id: selectedWorkspaceId, name: selectedWorkspaceNome }, ...workspaces];
+ }, [workspaces, selectedWorkspaceId, selectedWorkspaceNome]);
+
+ const loadUnidade = async () => {
+ try {
+ setLoadingUnidade(true);
+ const codigo = getEstabelecimentoCodigoFromTransfer();
+ setCodigoEstabelecimento(codigo);
+ if (!codigo) {
+ setUnidadeExistente(null);
+ setNomeUnidade("");
+ return;
+ }
+ const row = await lookupUnidadeByEstabelecimento(codigo);
+ setUnidadeExistente(row);
+ setNomeUnidade(row?.nome ?? "");
+ } catch {
+ setUnidadeExistente(null);
+ setNomeUnidade("");
+ } finally {
+ setLoadingUnidade(false);
+ }
+ };
+
+ const loadConfiguracoesAsana = async () => {
+ try {
+ setConfigAsanaError(null);
+ const data = await fechamentoConfiguracoesService.getConfiguracoes();
+ setConfigAtual(data);
+ setAsanaToken(data?.asanaToken ?? "");
+ setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
+ setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
+ setConfigAsanaError(message);
+ setConfigAtual(null);
+ }
+ };
+
+ const loadAll = async () => {
+ try {
+ setLoading(true);
+ await Promise.all([loadUnidade(), loadConfiguracoesAsana()]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void loadAll();
+ }, []);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || loading) return;
+ if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
+ unidadeSectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }, [loading]);
+
+ const handleBuscarWorkspaces = async () => {
+ if (!tokenDigitado) {
+ toast.error("Informe o token do Asana para buscar workspaces.");
+ return;
+ }
+
+ try {
+ setLoadingWorkspaces(true);
+ const ws = await asanaService.getWorkspaces(tokenDigitado);
+ setWorkspaces(ws);
+ if (ws.length === 0) {
+ toast.warning("Nenhum workspace foi encontrado para este token.");
+ return;
+ }
+
+ if (selectedWorkspaceId && ws.some((item) => item.id === selectedWorkspaceId)) {
+ const selected = ws.find((item) => item.id === selectedWorkspaceId);
+ setSelectedWorkspaceNome(selected?.name ?? selectedWorkspaceNome);
+ } else {
+ setSelectedWorkspaceId("");
+ setSelectedWorkspaceNome("");
+ }
+
+ toast.success(`${ws.length} workspace(s) carregado(s).`);
+ } catch (error) {
+ const message = error && typeof error === "object" && "message" in error
+ ? String((error as { message: unknown }).message)
+ : "Erro ao buscar workspaces do Asana.";
+ toast.error(message);
+ setWorkspaces([]);
+ } finally {
+ setLoadingWorkspaces(false);
+ }
+ };
+
+ const handleSaveAsana = async () => {
+ if (!selectedWorkspaceId) {
+ toast.error("Selecione um workspace antes de salvar.");
+ return;
+ }
+
+ try {
+ setSaving(true);
+ const workspaceSelecionado = workspaceOptions.find((item) => item.id === selectedWorkspaceId);
+ const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
+ asanaToken: tokenDigitado ? tokenDigitado : undefined,
+ asanaWorkspaceId: selectedWorkspaceId,
+ asanaWorkspaceNome: workspaceSelecionado?.name ?? selectedWorkspaceNome,
+ });
+
+ setConfigAtual(updated);
+ setAsanaToken(updated.asanaToken ?? "");
+ setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
+ setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
+ setConfigAsanaError(null);
+ toast.success("Configurações salvas com sucesso.");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao salvar configurações.";
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleSalvarUnidade = async () => {
+ const nome = nomeUnidade.trim();
+ if (!nome) {
+ toast.error("Informe o nome da unidade.");
+ return;
+ }
+ const codigo = codigoEstabelecimento.trim();
+ if (!codigo) {
+ toast.error("Código do estabelecimento não disponível no transfer.");
+ return;
+ }
+
+ try {
+ setSavingUnidade(true);
+ if (unidadeExistente) {
+ await fechamentoUnidadesService.updateUnidade(unidadeExistente.id, { nome });
+ toast.success("Unidade atualizada.");
+ clearCommanderUnidadeIdCache();
+ await loadUnidade();
+ } else {
+ await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
+ toast.success("Unidade cadastrada.");
+ clearCommanderUnidadeIdCache();
+ navigate("/fechamento-hgtx", { replace: true });
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
+ toast.error(message);
+ } finally {
+ setSavingUnidade(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ Carregando configurações...
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Unidade
+
+ Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
+ estabelecimento é somente leitura.
+
+
+
+ {loadingUnidade ? (
+
+
+ Carregando dados da unidade...
+
+ ) : !isAdmin ? (
+
+ Apenas administradores podem cadastrar ou editar a unidade aqui.
+
+ ) : !codigoEstabelecimento ? (
+
+ Abra o módulo pelo Codex com o estabelecimento no transfer para exibir o código e cadastrar a
+ unidade.
+
+ ) : (
+ <>
+
+ Código do estabelecimento (transfer)
+
+
+
+ Nome da unidade
+ setNomeUnidade(e.target.value)}
+ placeholder="Ex.: Unidade Matriz"
+ className="h-11 text-sm"
+ />
+
+
+ {unidadeExistente ? (
+
+ Unidade cadastrada
+
+ ) : (
+
+ Pendente: informe o nome e salve para criar a unidade
+
+ )}
+
+
+
+ {savingUnidade ? (
+ <>
+
+ Salvando...
+ >
+ ) : (
+ <>
+
+ {unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
+ >
+ )}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ Integração Asana
+
+ Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
+
+
+
+ {configAsanaError ? (
+
+
Não foi possível carregar as configurações do Asana
+
+ {configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
+
+
+ ) : null}
+
+
+
+ Token de acesso
+
+
+ {tokenJaConfigurado ? (
+
+ Token configurado no servidor
+
+ ) : (
+
+ Token ainda não salvo
+
+ )}
+
+
+
+
+ setAsanaToken(e.target.value)}
+ placeholder={
+ tokenJaConfigurado
+ ? "Substitua o token ou mantenha em branco para não alterar ao salvar"
+ : "Cole o token do Asana (Personal Access Token)"
+ }
+ className="h-11 pr-11 font-mono text-sm"
+ disabled={Boolean(configAsanaError)}
+ autoComplete="off"
+ />
+ setShowToken((prev) => !prev)}
+ title={showToken ? "Ocultar token" : "Mostrar token"}
+ className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
+ >
+ {showToken ? : }
+
+
+
+ {loadingWorkspaces ? (
+ <>
+
+ Buscando…
+ >
+ ) : (
+ <>
+
+ Listar workspaces
+ >
+ )}
+
+
+
+
+
+
+ Workspace padrão
+
+
{
+ setSelectedWorkspaceId(value);
+ const selected = workspaceOptions.find((item) => item.id === value);
+ setSelectedWorkspaceNome(selected?.name ?? "");
+ }}
+ disabled={Boolean(configAsanaError)}
+ >
+
+ 0
+ ? "Selecione um workspace"
+ : "Informe o token e clique em Listar workspaces"
+ }
+ />
+
+
+ {workspaceOptions.map((workspace) => (
+
+ {workspace.name}
+
+ ))}
+
+
+
+ Selecionado: {selectedWorkspaceNome || "—"}
+
+
+
+
+
+ {saving ? (
+ <>
+
+ Salvando…
+ >
+ ) : (
+ <>
+
+ Salvar integração Asana
+ >
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx b/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
new file mode 100644
index 0000000..17ad5c7
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
@@ -0,0 +1,962 @@
+import { useEffect, useMemo, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { ArrowLeft, CheckCircle2, Clock3, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { useAuthAccess } from "@/contexts/AuthAccessContext";
+import { fechamentoCompetenciasService } from "@/services/fechamento/competencias";
+import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos";
+
+function formatHoras(minutos: number | null): string {
+ if (!minutos || minutos <= 0) return "0h";
+ const horas = Math.floor(minutos / 60);
+ const mins = Math.round(minutos % 60);
+ if (horas === 0) return `${mins}min`;
+ if (mins === 0) return `${horas}h`;
+ return `${horas}h ${mins}min`;
+}
+
+function formatHorasResumo(minutos: number): string {
+ const horas = Math.floor(minutos / 60);
+ const mins = Math.round(minutos % 60);
+ if (horas === 0) return `${mins}min`;
+ if (mins === 0) return `${horas}h`;
+ return `${horas}h ${mins}min`;
+}
+
+function formatPontos(valor: number): string {
+ return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
+}
+
+function parsePontuacaoInput(value: string): number {
+ const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
+ const parsed = Number(normalized);
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
+}
+
+type FechamentoDetalhesLocationState = {
+ competenciaId?: string;
+ status?: "em_aberto" | "fechado";
+};
+
+export default function FechamentoDetalhes() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { me } = useAuthAccess();
+ const { id: fechamentoId = "" } = useParams();
+ const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null;
+ const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? "");
+ const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
+ const [loading, setLoading] = useState(true);
+ const [tarefas, setTarefas] = useState([]);
+ const [togglingTaskId, setTogglingTaskId] = useState(null);
+ const [deletingTaskId, setDeletingTaskId] = useState(null);
+ const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
+ const [savingLancamento, setSavingLancamento] = useState(false);
+ const [isConcluirOpen, setIsConcluirOpen] = useState(false);
+ const [concluindo, setConcluindo] = useState(false);
+ const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState("");
+ const [motivoAjuste, setMotivoAjuste] = useState("");
+ const [isReabrirOpen, setIsReabrirOpen] = useState(false);
+ const [reabrindo, setReabrindo] = useState(false);
+ const [motivoReabertura, setMotivoReabertura] = useState("");
+ const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus");
+ const [lancamentoDescricao, setLancamentoDescricao] = useState("");
+ const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
+ const [pontuacaoMeta, setPontuacaoMeta] = useState(null);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [deletingTarefa, setDeletingTarefa] = useState(null);
+ const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false);
+ const [reprocessandoAsana, setReprocessandoAsana] = useState(false);
+ const [isEditarOpen, setIsEditarOpen] = useState(false);
+ const [savingEdicao, setSavingEdicao] = useState(false);
+ const [editingTarefa, setEditingTarefa] = useState(null);
+ const [edicaoNumeroTicket, setEdicaoNumeroTicket] = useState("");
+ const [edicaoDescricao, setEdicaoDescricao] = useState("");
+ const [edicaoCliente, setEdicaoCliente] = useState("");
+ const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
+ const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
+ const isFechado = fechamentoStatus === "fechado";
+
+ const totais = useMemo(() => {
+ const aprovadas = tarefas.filter((t) => t.estaRevisada);
+ const pontos = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
+ const minutos = aprovadas.reduce((acc, t) => acc + Number(t.tempoMinutos || 0), 0);
+ return {
+ pontos,
+ horas: formatHorasResumo(minutos),
+ aprovadas: aprovadas.length,
+ };
+ }, [tarefas]);
+ const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
+ const bancoCalculado = totais.pontos - pontuacaoPagaNumero;
+ const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
+ const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
+ const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
+ const pontuacaoTotalLabel = String(totais.pontos);
+ const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
+
+ const loadTarefas = async () => {
+ try {
+ setLoading(true);
+ const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
+ setTarefas(data);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
+ toast.error(message);
+ setTarefas([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadFechamentoStatus = async (currentCompetenciaId: string) => {
+ if (!currentCompetenciaId) return;
+ try {
+ const rows = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
+ const current = rows.find((row) => row.id === fechamentoId);
+ if (!current) return;
+ setFechamentoStatus(current.status);
+ setPontuacaoMeta(current.pontuacaoMeta);
+ if (!competenciaId) {
+ setCompetenciaId(current.competenciaId);
+ }
+ } catch {
+ // mantém status atual em caso de erro para evitar bloquear navegação
+ }
+ };
+
+ const toastBloqueioFechado = () => {
+ toast.error("Fechamento está fechado. Reabra para editar.");
+ };
+
+ useEffect(() => {
+ if (fechamentoId) {
+ void loadTarefas();
+ }
+ }, [fechamentoId]);
+
+ useEffect(() => {
+ if (competenciaId) {
+ void loadFechamentoStatus(competenciaId);
+ }
+ }, [competenciaId, fechamentoId]);
+
+ useEffect(() => {
+ if (isFechado) {
+ setIsConcluirOpen(false);
+ setIsLancamentoOpen(false);
+ }
+ }, [isFechado]);
+
+ const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ const previous = tarefas;
+ setTogglingTaskId(tarefa.id);
+ setTarefas((prev) =>
+ prev.map((item) =>
+ item.id === tarefa.id
+ ? {
+ ...item,
+ estaRevisada: approved,
+ }
+ : item,
+ ),
+ );
+ try {
+ if (!me?.id) {
+ throw new Error("Não foi possível identificar o usuário para registrar a edição.");
+ }
+ await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
+ estaRevisada: approved,
+ editadoPorId: me.id,
+ });
+ } catch (error) {
+ setTarefas(previous);
+ const message = error instanceof Error ? error.message : "Erro ao atualizar aprovação da tarefa.";
+ toast.error(message);
+ } finally {
+ setTogglingTaskId(null);
+ }
+ };
+
+ const resetLancamentoForm = () => {
+ setLancamentoTipo("bonus");
+ setLancamentoDescricao("");
+ setLancamentoPontuacao("0");
+ };
+
+ const handleSalvarLancamento = async () => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ const descricao = lancamentoDescricao.trim();
+ const pontuacao = Number(lancamentoPontuacao);
+ if (!descricao) {
+ toast.error("Informe a descrição do lançamento.");
+ return;
+ }
+ if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
+ toast.error("Informe uma pontuação válida maior que zero.");
+ return;
+ }
+
+ try {
+ setSavingLancamento(true);
+ await fechamentoFechamentosService.criarLancamento(fechamentoId, {
+ tipo: lancamentoTipo,
+ descricao,
+ pontuacao,
+ });
+ toast.success("Lançamento incluído com sucesso.");
+ setIsLancamentoOpen(false);
+ resetLancamentoForm();
+ await loadTarefas();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao incluir lançamento.";
+ toast.error(message);
+ } finally {
+ setSavingLancamento(false);
+ }
+ };
+
+ const handleAbrirConcluir = () => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ setPontuacaoPagaInput(pontuacaoTotalLabel);
+ setMotivoAjuste("");
+ setIsConcluirOpen(true);
+ };
+
+ const handleConcluirFechamento = async () => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ const pontuacaoPagaRaw = parsePontuacaoInput(pontuacaoPagaInput);
+ if (!Number.isFinite(pontuacaoPagaRaw)) {
+ toast.error("Informe uma pontuação paga válida.");
+ return;
+ }
+ if (pontuacaoPagaRaw <= 0) {
+ toast.error("A pontuação paga deve ser maior que zero.");
+ return;
+ }
+ if (requerMotivoAjuste && !motivoAjuste.trim()) {
+ toast.error("Informe o motivo do ajuste quando houver diferença de saldo.");
+ return;
+ }
+ try {
+ setConcluindo(true);
+ const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
+ pontuacaoPaga: pontuacaoPagaRaw,
+ motivoAjuste: motivoAjuste.trim() || undefined,
+ });
+ toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
+ setFechamentoStatus("fechado");
+ navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
+ toast.error(message);
+ if (competenciaId) {
+ await loadFechamentoStatus(competenciaId);
+ }
+ await loadTarefas();
+ } finally {
+ setConcluindo(false);
+ }
+ };
+
+ const handleReabrirFechamento = async () => {
+ if (!me?.id) {
+ toast.error("Não foi possível identificar o usuário para reabertura.");
+ return;
+ }
+ try {
+ setReabrindo(true);
+ const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, {
+ reabertoPorId: me.id,
+ motivo: motivoReabertura,
+ });
+ toast.success("Fechamento reaberto com sucesso.");
+ setFechamentoStatus(data.status);
+ setIsReabrirOpen(false);
+ setMotivoReabertura("");
+ await loadTarefas();
+ if (data.competenciaId) {
+ setCompetenciaId(data.competenciaId);
+ } else if (competenciaId) {
+ await loadFechamentoStatus(competenciaId);
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao reabrir fechamento.";
+ toast.error(message);
+ if (competenciaId) {
+ await loadFechamentoStatus(competenciaId);
+ }
+ } finally {
+ setReabrindo(false);
+ }
+ };
+
+ const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ const isManual = tarefa.tipo === "bonus" || tarefa.tipo === "desconto";
+ if (!isManual) return;
+ setDeletingTarefa(tarefa);
+ setIsDeleteDialogOpen(true);
+ };
+
+ const confirmDeleteLancamento = async () => {
+ if (!deletingTarefa) return;
+ try {
+ setDeletingTaskId(deletingTarefa.id);
+ await fechamentoFechamentosService.excluirLancamento(fechamentoId, deletingTarefa.id);
+ toast.success("Lançamento excluído com sucesso.");
+ setIsDeleteDialogOpen(false);
+ setDeletingTarefa(null);
+ await loadTarefas();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao excluir lançamento.";
+ toast.error(message);
+ } finally {
+ setDeletingTaskId(null);
+ }
+ };
+
+ const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ setEditingTarefa(tarefa);
+ setEdicaoNumeroTicket(tarefa.numeroTicket ?? "");
+ setEdicaoDescricao(tarefa.descricao ?? "");
+ setEdicaoCliente(tarefa.cliente ?? "");
+ setEdicaoTempoMinutos(tarefa.tempoMinutos != null ? String(tarefa.tempoMinutos) : "");
+ setEdicaoPontuacao(String(Number(tarefa.pontuacao ?? 0)));
+ setIsEditarOpen(true);
+ };
+
+ const handleSalvarEdicao = async () => {
+ if (!editingTarefa) return;
+ if (!me?.id) {
+ toast.error("Não foi possível identificar o usuário para registrar a edição.");
+ return;
+ }
+ const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto";
+ const descricao = edicaoDescricao.trim();
+ const pontuacao = parsePontuacaoInput(edicaoPontuacao);
+ if (!descricao) {
+ toast.error("Descrição é obrigatória.");
+ return;
+ }
+ if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
+ toast.error("Pontuação deve ser maior que zero.");
+ return;
+ }
+
+ let tempoMinutos: number | null | undefined = undefined;
+ if (!isManual) {
+ const tempoRaw = edicaoTempoMinutos.trim();
+ if (tempoRaw.length > 0) {
+ const tempoParsed = Number(tempoRaw);
+ if (!Number.isFinite(tempoParsed) || tempoParsed < 0 || !Number.isInteger(tempoParsed)) {
+ toast.error("Horas/minutos deve ser um número inteiro maior ou igual a zero.");
+ return;
+ }
+ tempoMinutos = tempoParsed;
+ } else {
+ tempoMinutos = null;
+ }
+ }
+
+ try {
+ setSavingEdicao(true);
+ await fechamentoFechamentosService.patchTarefa(fechamentoId, editingTarefa.id, {
+ descricao,
+ pontuacao,
+ ...(isManual
+ ? {}
+ : {
+ numeroTicket: edicaoNumeroTicket.trim() ? edicaoNumeroTicket.trim() : null,
+ cliente: edicaoCliente.trim() ? edicaoCliente.trim() : null,
+ tempoMinutos,
+ }),
+ editadoPorId: me.id,
+ });
+ toast.success("Tarefa atualizada com sucesso.");
+ setIsEditarOpen(false);
+ setEditingTarefa(null);
+ await loadTarefas();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao editar tarefa.";
+ toast.error(message);
+ } finally {
+ setSavingEdicao(false);
+ }
+ };
+
+ const handleReprocessarAsana = async () => {
+ if (isFechado) {
+ toastBloqueioFechado();
+ return;
+ }
+ try {
+ setReprocessandoAsana(true);
+ const data = await fechamentoFechamentosService.reprocessarAsana(fechamentoId);
+ toast.success(
+ `Reprocessamento concluído: ${data.tarefasImportadas} tarefa(s) atualizada(s) para este parceiro.`,
+ );
+ setIsReprocessAsanaOpen(false);
+ await loadTarefas();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao reprocessar Asana.";
+ toast.error(message);
+ } finally {
+ setReprocessandoAsana(false);
+ }
+ };
+
+ return (
+
+
+
+
navigate(-1)}>
+
+ Voltar
+
+
+
+
+
+
+
+ Detalhes do Fechamento
+
+
Revisão operacional de tarefas, pontos e horas.
+
+
+ {!loading ? (
+
+
{isFechado ? "Fechado" : "Em aberto"}
+
setIsLancamentoOpen(true)}
+ disabled={isFechado}
+ className="min-w-[152px]"
+ >
+
+ Fazer lançamento
+
+ {!isFechado ? (
+
setIsReprocessAsanaOpen(true)}
+ disabled={reprocessandoAsana}
+ className="min-w-[152px]"
+ >
+
+ Reprocessar Asana
+
+ ) : null}
+ {isFechado ? (
+
setIsReabrirOpen(true)}
+ disabled={reabrindo}
+ className="min-w-[152px]"
+ >
+
+ Reabrir fechamento
+
+ ) : (
+
+
+ Concluir fechamento
+
+ )}
+
+ ) : null}
+
+
+ {!loading ? (
+
+
+
+
+ Total de tarefas
+
+
+ {tarefas.length}
+
+
+
+
+
+ Tarefas aprovadas
+
+
+ {totais.aprovadas}
+
+
+
+
+
+ Pontuação aprovada
+
+
+ {formatPontos(totais.pontos)}
+
+
+
+
+
+ Horas aprovadas
+
+
+ {totais.horas}
+
+
+
+ ) : null}
+
+
+
+ {!loading && tarefas.length === 0 ? (
+
+
+ Nenhuma tarefa encontrada
+ Este fechamento não possui tarefas cadastradas.
+
+
+ ) : (
+
+
+
+
+ Aprovada
+ Ticket
+ Descrição
+ Cliente
+ Tipo
+ Horas
+ Pontuação
+ Ações
+
+
+
+ {loading ? (
+
+
+ Carregando tarefas...
+
+
+ ) : (
+ tarefas.map((tarefa) => (
+
+
+
+ void handleToggleAprovada(tarefa, Boolean(checked))}
+ disabled={togglingTaskId === tarefa.id || isFechado}
+ />
+ {togglingTaskId === tarefa.id ? : null}
+
+
+ {tarefa.numeroTicket || "—"}
+ {tarefa.descricao}
+ {tarefa.cliente || "—"}
+ {tarefa.tipo}
+ {formatHoras(tarefa.tempoMinutos)}
+ {Number(tarefa.pontuacao || 0)}
+
+
+
openEditarTarefa(tarefa)}
+ disabled={isFechado}
+ >
+
+ Editar
+
+ {tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? (
+
void handleExcluirLancamento(tarefa)}
+ disabled={isFechado || deletingTaskId === tarefa.id}
+ >
+ {deletingTaskId === tarefa.id ? (
+
+ ) : (
+
+ )}
+ Excluir
+
+ ) : null}
+
+
+
+ ))
+ )}
+
+
+
+ )}
+
+
+
+
+
+ Fazer lançamento
+
+ Adicione uma bonificação ou desconto em pontuação para este fechamento.
+
+
+
+
+
+ Tipo de lançamento
+ setLancamentoTipo(value as "bonus" | "desconto")}
+ >
+
+
+
+
+ Bonificação
+ Desconto
+
+
+
+
+
+ Pontuação
+ setLancamentoPontuacao(e.target.value)}
+ />
+
+
+
+ Descrição
+ setLancamentoDescricao(e.target.value)}
+ placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint"
+ />
+
+
+
+
+ {
+ setIsLancamentoOpen(false);
+ resetLancamentoForm();
+ }}
+ disabled={savingLancamento}
+ >
+ Cancelar
+
+ void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
+ {savingLancamento ? "Salvando..." : "Salvar lançamento"}
+
+
+
+
+
+
+
+
+ Concluir fechamento
+
+ Revise os totais e confirme a pontuação paga para concluir este fechamento.
+
+
+
+
+
+
+
Aprovada
+
{formatPontos(totais.pontos)}
+
+
+
Meta
+
{formatPontos(Number(pontuacaoMeta ?? 0))}
+
+
+
Diferença
+
= 0 ? "text-emerald-600" : "text-red-600"}`}>
+ {formatPontos(diferencaParaMeta)}
+
+
+
+
Banco de Pontos
+
= 0 ? "text-emerald-600" : "text-red-600"}`}>
+ {formatPontos(bancoCalculado)}
+
+
+
+
+ setPontuacaoPagaInput(pontuacaoTotalLabel)}
+ >
+ Pagar total aprovado
+
+ setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
+ >
+ Pagar meta
+
+
+
+
Pontuação paga
+ {isValorEditado ? (
+
+ setPontuacaoPagaInput(pontuacaoTotalLabel)}
+ >
+ Usar valor total
+
+
+ ) : null}
+
setPontuacaoPagaInput(e.target.value)}
+ placeholder="Ex.: 10,5"
+ />
+
+ {requerMotivoAjuste ? (
+
+ Motivo do ajuste *
+ setMotivoAjuste(e.target.value)}
+ placeholder="Ex.: pagamento parcial acordado com o parceiro"
+ />
+
+ ) : null}
+
+
+
+ setIsConcluirOpen(false)} disabled={concluindo}>
+ Cancelar
+
+ void handleConcluirFechamento()} disabled={concluindo || isFechado}>
+ {concluindo ? "Concluindo..." : "Confirmar conclusão"}
+
+
+
+
+
+
+
+
+ Reabrir fechamento
+
+ Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
+
+
+
+ Motivo (opcional)
+ setMotivoReabertura(e.target.value)}
+ placeholder="Ex.: ajuste após revisão financeira"
+ />
+
+
+ setIsReabrirOpen(false)} disabled={reabrindo}>
+ Cancelar
+
+ void handleReabrirFechamento()} disabled={reabrindo}>
+ {reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
+
+
+
+
+
+
+
+
+ Excluir lançamento
+
+ Deseja realmente excluir o lançamento manual{" "}
+ {deletingTarefa?.descricao} ? Esta ação não pode ser desfeita.
+
+
+
+ {
+ setIsDeleteDialogOpen(false);
+ setDeletingTarefa(null);
+ }}
+ disabled={deletingTaskId !== null}
+ >
+ Cancelar
+
+ void confirmDeleteLancamento()}
+ disabled={deletingTaskId !== null}
+ >
+ {deletingTaskId !== null ? "Excluindo..." : "Confirmar exclusão"}
+
+
+
+
+
+
+
+
+ Reprocessar Asana deste fechamento
+
+ Esta ação atualiza somente as tarefas do Asana para este parceiro no período da competência. Lançamentos
+ manuais (bônus/desconto) serão preservados.
+
+
+
+ setIsReprocessAsanaOpen(false)}
+ disabled={reprocessandoAsana}
+ >
+ Cancelar
+
+ void handleReprocessarAsana()} disabled={reprocessandoAsana}>
+ {reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
+
+
+
+
+
+
+
+
+ Editar tarefa
+
+ {editingTarefa?.tipo === "bonus" || editingTarefa?.tipo === "desconto"
+ ? "Para bônus/desconto, você pode editar apenas descrição e pontuação."
+ : "Edite os campos da tarefa e salve as alterações."}
+
+
+
+
+ setIsEditarOpen(false)} disabled={savingEdicao}>
+ Cancelar
+
+ void handleSalvarEdicao()} disabled={savingEdicao}>
+ {savingEdicao ? "Salvando..." : "Salvar"}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/Fechamentos.tsx b/src/modules/fechamento-hgtx/pages/Fechamentos.tsx
new file mode 100644
index 0000000..cc7c5d3
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/Fechamentos.tsx
@@ -0,0 +1,270 @@
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ClipboardList, ExternalLink, Plus } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
+
+const meses = [
+ "Janeiro",
+ "Fevereiro",
+ "Março",
+ "Abril",
+ "Maio",
+ "Junho",
+ "Julho",
+ "Agosto",
+ "Setembro",
+ "Outubro",
+ "Novembro",
+ "Dezembro",
+];
+
+function formatCompetenciaMes(mes: number, ano: number): string {
+ return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
+}
+
+export default function Fechamentos() {
+ const navigate = useNavigate();
+ const currentYear = new Date().getFullYear();
+ const [allCompetencias, setAllCompetencias] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [anoSelecionado, setAnoSelecionado] = useState("");
+ const [modalAberto, setModalAberto] = useState(false);
+ const [mesSelecionado, setMesSelecionado] = useState("");
+ const [anoNovo, setAnoNovo] = useState("");
+ const [criando, setCriando] = useState(false);
+
+ const anosDisponiveis = useMemo(() => {
+ const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))];
+ if (!anosUnicos.includes(currentYear)) {
+ anosUnicos.push(currentYear);
+ }
+ return anosUnicos.sort((a, b) => b - a).map(String);
+ }, [allCompetencias, currentYear]);
+
+ const competenciasFiltradas = useMemo(() => {
+ if (!anoSelecionado) return [];
+ return allCompetencias.filter((c) => c.ano === Number(anoSelecionado));
+ }, [allCompetencias, anoSelecionado]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadCompetencias = async () => {
+ try {
+ setLoading(true);
+ const data = await fechamentoCompetenciasService.listarCompetencias({});
+ if (!cancelled) {
+ setAllCompetencias(data);
+ }
+ } catch (error) {
+ if (!cancelled) {
+ const message = error instanceof Error ? error.message : "Erro ao carregar competências.";
+ toast.error(message);
+ setAllCompetencias([]);
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void loadCompetencias();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (anosDisponiveis.length > 0 && !anoSelecionado) {
+ const defaultAno = anosDisponiveis.includes(String(currentYear))
+ ? String(currentYear)
+ : anosDisponiveis[0];
+ setAnoSelecionado(defaultAno);
+ }
+ }, [anosDisponiveis, anoSelecionado, currentYear]);
+
+ const handleCriarCompetencia = async () => {
+ const mes = Number(mesSelecionado);
+ const ano = Number(anoNovo);
+ if (!mes || !ano) {
+ toast.error("Selecione mês e ano.");
+ return;
+ }
+ setCriando(true);
+ try {
+ const nova = await fechamentoCompetenciasService.criarCompetencia({ mes, ano });
+ setAllCompetencias((prev) => [...prev, nova]);
+ setModalAberto(false);
+ setMesSelecionado("");
+ setAnoNovo("");
+ toast.success("Competência criada com sucesso.");
+ setAnoSelecionado(String(ano));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao criar competência.";
+ toast.error(message);
+ } finally {
+ setCriando(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Fechamentos
+
+
+
+
+
+ Nova Competência
+
+
+
+
+ Nova Competência
+
+
+
+ Mês
+
+
+
+
+
+ {meses.map ((m, i) => (
+
+ {m}
+
+ ))}
+
+
+
+
+ Ano
+
+
+
+
+
+ {anosDisponiveis.map((ano) => (
+
+ {ano}
+
+ ))}
+
+
+
+
+
+ setModalAberto(false)} disabled={criando}>
+ Cancelar
+
+ void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
+ {criando ? "Criando..." : "Criar Competência"}
+
+
+
+
+
+
+ Ano:
+
+
+
+
+
+ {anosDisponiveis.map((ano) => (
+
+ {ano}
+
+ ))}
+
+
+
+
+
+
+ {!loading && competenciasFiltradas.length === 0 ? (
+
+
+ Nenhuma competência encontrada
+ Não há competências cadastradas para o ano selecionado.
+
+
+ ) : (
+
+
+
+
+ Mês
+ Quantidade de Fechamentos
+ Status
+ Acessar
+
+
+
+ {loading ? (
+
+
+ Carregando competências...
+
+
+ ) : (
+ competenciasFiltradas.map((competencia) => (
+
+
+ {formatCompetenciaMes(competencia.mes, competencia.ano)}
+
+ {competencia.quantidadeFechamentos}
+
+
+ {competencia.status === "concluido" ? "Concluída" : "Em aberto"}
+
+
+
+
+ navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
+ >
+
+ Acessar
+
+
+
+
+ ))
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/NotFound.tsx b/src/modules/fechamento-hgtx/pages/NotFound.tsx
new file mode 100644
index 0000000..fae7bef
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/NotFound.tsx
@@ -0,0 +1,25 @@
+import { useEffect } from "react";
+import { Link, useLocation } from "react-router-dom";
+
+export default function NotFound() {
+ const location = useLocation();
+
+ useEffect(() => {
+ console.error(
+ "404 Error: User attempted to access non-existent fechamento-hgtx route:",
+ location.pathname,
+ );
+ }, [location.pathname]);
+
+ return (
+
+
+
404
+
Rota não encontrada neste módulo.
+
+ Voltar para Fechamentos
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/Parceiros.tsx b/src/modules/fechamento-hgtx/pages/Parceiros.tsx
new file mode 100644
index 0000000..570bac2
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/Parceiros.tsx
@@ -0,0 +1,1053 @@
+import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
+import { Building2, Edit, Image as ImageIcon, Loader2, Plus, Power, Upload } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ fechamentoParceirosService,
+ type ParceiroItem,
+ type ParceiroStatusFiltro,
+ type ParceiroTipoPessoa,
+} from "@/services/fechamento/parceiros";
+import { fechamentoUploadsService } from "@/services/fechamento/uploads";
+import { fechamentoConfiguracoesService } from "@/services/fechamento/configuracoes";
+import {
+ fechamentoAsanaWorkspaceUsersService,
+ type AsanaWorkspaceUser,
+} from "@/services/fechamento/asanaWorkspaceUsers";
+
+const MAX_FILE_SIZE = 5 * 1024 * 1024;
+const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
+
+type ParceiroForm = {
+ nome: string;
+ codinome: string;
+ tipoPessoa: ParceiroTipoPessoa;
+ cpf: string;
+ cnpj: string;
+ email: string;
+ whatsapp: string;
+ asanaId: string;
+ logoUrl: string;
+ observacoes: string;
+ pontuacaoMeta: string;
+};
+
+const FORM_INICIAL: ParceiroForm = {
+ nome: "",
+ codinome: "",
+ tipoPessoa: "juridica",
+ cpf: "",
+ cnpj: "",
+ email: "",
+ whatsapp: "",
+ asanaId: "",
+ logoUrl: "",
+ observacoes: "",
+ pontuacaoMeta: "0",
+};
+
+function normalizeOptionalText(value: string): string | null {
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : null;
+}
+
+function onlyDigits(value: string): string {
+ return value.replace(/\D/g, "");
+}
+
+function formatCpf(value: string): string {
+ const digits = onlyDigits(value).slice(0, 11);
+ const p1 = digits.slice(0, 3);
+ const p2 = digits.slice(3, 6);
+ const p3 = digits.slice(6, 9);
+ const p4 = digits.slice(9, 11);
+ if (digits.length <= 3) return p1;
+ if (digits.length <= 6) return `${p1}.${p2}`;
+ if (digits.length <= 9) return `${p1}.${p2}.${p3}`;
+ return `${p1}.${p2}.${p3}-${p4}`;
+}
+
+function formatCnpj(value: string): string {
+ const digits = onlyDigits(value).slice(0, 14);
+ const p1 = digits.slice(0, 2);
+ const p2 = digits.slice(2, 5);
+ const p3 = digits.slice(5, 8);
+ const p4 = digits.slice(8, 12);
+ const p5 = digits.slice(12, 14);
+ if (digits.length <= 2) return p1;
+ if (digits.length <= 5) return `${p1}.${p2}`;
+ if (digits.length <= 8) return `${p1}.${p2}.${p3}`;
+ if (digits.length <= 12) return `${p1}.${p2}.${p3}/${p4}`;
+ return `${p1}.${p2}.${p3}/${p4}-${p5}`;
+}
+
+function formatWhatsapp(value: string): string {
+ const digits = onlyDigits(value).slice(0, 11);
+ const ddd = digits.slice(0, 2);
+ const part1 = digits.slice(2, 7);
+ const part2 = digits.slice(7, 11);
+
+ if (digits.length <= 2) return ddd;
+ if (digits.length <= 7) return `${ddd} ${part1}`;
+ return `${ddd} ${part1}-${part2}`;
+}
+
+function isRepeatedDigits(value: string): boolean {
+ return /^(\d)\1+$/.test(value);
+}
+
+function isValidCpf(value: string): boolean {
+ const cpf = onlyDigits(value);
+ if (cpf.length !== 11 || isRepeatedDigits(cpf)) return false;
+
+ let sum = 0;
+ for (let i = 0; i < 9; i += 1) {
+ sum += Number(cpf[i]) * (10 - i);
+ }
+ let check = (sum * 10) % 11;
+ if (check === 10) check = 0;
+ if (check !== Number(cpf[9])) return false;
+
+ sum = 0;
+ for (let i = 0; i < 10; i += 1) {
+ sum += Number(cpf[i]) * (11 - i);
+ }
+ check = (sum * 10) % 11;
+ if (check === 10) check = 0;
+ return check === Number(cpf[10]);
+}
+
+function isValidCnpj(value: string): boolean {
+ const cnpj = onlyDigits(value);
+ if (cnpj.length !== 14 || isRepeatedDigits(cnpj)) return false;
+
+ const calc = (base: string, factors: number[]) => {
+ const sum = base
+ .split("")
+ .reduce((acc, digit, idx) => acc + Number(digit) * factors[idx], 0);
+ const remainder = sum % 11;
+ return remainder < 2 ? 0 : 11 - remainder;
+ };
+
+ const check1 = calc(cnpj.slice(0, 12), [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
+ if (check1 !== Number(cnpj[12])) return false;
+
+ const check2 = calc(cnpj.slice(0, 13), [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]);
+ return check2 === Number(cnpj[13]);
+}
+
+function validateLogoFile(file: File): string | null {
+ if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
+ return "Arquivo inválido. Use JPG, PNG ou WEBP.";
+ }
+ if (file.size > MAX_FILE_SIZE) {
+ return "Arquivo excede 5MB. Escolha uma imagem menor.";
+ }
+ return null;
+}
+
+function getParceiroNome(parceiro: ParceiroItem): string {
+ return parceiro.nome?.trim() || "—";
+}
+
+function getTipoPessoaLabel(tipoPessoa: ParceiroTipoPessoa): string {
+ return tipoPessoa === "fisica" ? "Física" : "Jurídica";
+}
+
+export default function Parceiros() {
+ const logoFileInputRef = useRef(null);
+ const [parceiros, setParceiros] = useState([]);
+ const [loadingList, setLoadingList] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [toggling, setToggling] = useState(false);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+
+ const [busca, setBusca] = useState("");
+ const [filtroStatus, setFiltroStatus] = useState("all");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(10);
+ const [totalRegistros, setTotalRegistros] = useState(0);
+ const [totalPaginas, setTotalPaginas] = useState(1);
+ const [reloadNonce, setReloadNonce] = useState(0);
+
+ const [isFormOpen, setIsFormOpen] = useState(false);
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [isToggleOpen, setIsToggleOpen] = useState(false);
+ const [selectedParceiro, setSelectedParceiro] = useState(null);
+ const [form, setForm] = useState(FORM_INICIAL);
+ const [selectedLogoFile, setSelectedLogoFile] = useState(null);
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState(null);
+ const [asanaUsers, setAsanaUsers] = useState([]);
+ const [loadingAsanaUsers, setLoadingAsanaUsers] = useState(false);
+ const [asanaWorkspaceId, setAsanaWorkspaceId] = useState("");
+ const [asanaWorkspaceNome, setAsanaWorkspaceNome] = useState("");
+
+ const totalPages = Math.max(1, totalPaginas);
+ const hasActiveFilters = busca.trim().length > 0 || filtroStatus !== "all";
+ const isPessoaFisica = form.tipoPessoa === "fisica";
+ const logoDisplayUrl = logoPreviewUrl ?? normalizeOptionalText(form.logoUrl);
+ const formTitle = isEditMode ? "Editar parceiro" : "Novo parceiro";
+ const formActionLabel = saving ? "Salvando..." : isEditMode ? "Salvar alterações" : "Criar parceiro";
+ const hasAsanaWorkspace = asanaWorkspaceId.trim().length > 0;
+ const asanaSelectValue = form.asanaId || "__none__";
+
+ const logoStatusLabel = useMemo(() => {
+ if (uploadingLogo) {
+ return "Enviando logo...";
+ }
+ if (selectedLogoFile) {
+ return `Arquivo selecionado: ${selectedLogoFile.name}`;
+ }
+ return "Nenhum arquivo novo selecionado.";
+ }, [selectedLogoFile, uploadingLogo]);
+
+ const asanaUserOptions = useMemo(() => {
+ if (!form.asanaId) {
+ return asanaUsers;
+ }
+ if (asanaUsers.some((item) => item.id === form.asanaId)) {
+ return asanaUsers;
+ }
+ return [{ id: form.asanaId, name: `Usuário atual (${form.asanaId})` }, ...asanaUsers];
+ }, [asanaUsers, form.asanaId]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoadingList(true);
+
+ fechamentoParceirosService
+ .listarParceiros({
+ busca: busca.trim() || undefined,
+ estaAtivo: filtroStatus,
+ page: currentPage,
+ perPage: itemsPerPage,
+ })
+ .then((res) => {
+ if (!cancelled) {
+ setParceiros(res.data ?? []);
+ setTotalRegistros(res.meta?.total ?? 0);
+ setTotalPaginas(res.meta?.totalPaginas ?? 1);
+ }
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ const message = error instanceof Error ? error.message : "Erro ao listar parceiros.";
+ toast.error(message);
+ setParceiros([]);
+ setTotalRegistros(0);
+ setTotalPaginas(1);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoadingList(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [busca, filtroStatus, currentPage, itemsPerPage, reloadNonce]);
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [busca, filtroStatus]);
+
+ useEffect(() => {
+ return () => {
+ if (logoPreviewUrl) {
+ URL.revokeObjectURL(logoPreviewUrl);
+ }
+ };
+ }, [logoPreviewUrl]);
+
+ useEffect(() => {
+ if (!isFormOpen) {
+ return;
+ }
+
+ let cancelled = false;
+ const loadAsanaUsers = async () => {
+ try {
+ setLoadingAsanaUsers(true);
+ const config = await fechamentoConfiguracoesService.getConfiguracoes();
+ const workspaceId = config?.asanaWorkspaceId?.trim() ?? "";
+ const workspaceNome = config?.asanaWorkspaceNome?.trim() ?? "";
+
+ if (cancelled) {
+ return;
+ }
+
+ setAsanaWorkspaceId(workspaceId);
+ setAsanaWorkspaceNome(workspaceNome);
+
+ if (!workspaceId) {
+ setAsanaUsers([]);
+ return;
+ }
+
+ const users = await fechamentoAsanaWorkspaceUsersService.listarUsuariosDoWorkspace(workspaceId);
+ if (!cancelled) {
+ setAsanaUsers(users);
+ }
+ } catch (error) {
+ if (!cancelled) {
+ const message =
+ error instanceof Error ? error.message : "Erro ao carregar usuários do workspace do Asana.";
+ toast.error(message);
+ setAsanaUsers([]);
+ }
+ } finally {
+ if (!cancelled) {
+ setLoadingAsanaUsers(false);
+ }
+ }
+ };
+
+ void loadAsanaUsers();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [isFormOpen]);
+
+ const clearFilters = () => {
+ setBusca("");
+ setFiltroStatus("all");
+ setCurrentPage(1);
+ };
+
+ const resetFormState = () => {
+ if (logoPreviewUrl) {
+ URL.revokeObjectURL(logoPreviewUrl);
+ }
+ setForm(FORM_INICIAL);
+ setSelectedLogoFile(null);
+ setLogoPreviewUrl(null);
+ setSelectedParceiro(null);
+ setIsEditMode(false);
+ setAsanaUsers([]);
+ setAsanaWorkspaceId("");
+ setAsanaWorkspaceNome("");
+ };
+
+ const openCreateDialog = () => {
+ resetFormState();
+ setIsFormOpen(true);
+ };
+
+ const openEditDialog = (parceiro: ParceiroItem) => {
+ resetFormState();
+ setSelectedParceiro(parceiro);
+ setIsEditMode(true);
+ setForm({
+ nome: parceiro.nome ?? "",
+ codinome: parceiro.codinome ?? "",
+ tipoPessoa: parceiro.tipoPessoa,
+ cpf: onlyDigits(parceiro.cpf ?? ""),
+ cnpj: onlyDigits(parceiro.cnpj ?? ""),
+ email: parceiro.email ?? "",
+ whatsapp: onlyDigits(parceiro.whatsapp ?? ""),
+ asanaId: parceiro.asanaId ?? "",
+ logoUrl: parceiro.logoUrl ?? "",
+ observacoes: parceiro.observacoes ?? "",
+ pontuacaoMeta: String(parceiro.pontuacaoMeta ?? 0),
+ });
+ setIsFormOpen(true);
+ };
+
+ const openToggleDialog = (parceiro: ParceiroItem) => {
+ setSelectedParceiro(parceiro);
+ setIsToggleOpen(true);
+ };
+
+ const handleItemsPerPageChange = (value: string) => {
+ setItemsPerPage(Number(value));
+ setCurrentPage(1);
+ };
+
+ const onChangeTipoPessoa = (value: ParceiroTipoPessoa) => {
+ setForm((prev) => ({
+ ...prev,
+ tipoPessoa: value,
+ cpf: value === "fisica" ? prev.cpf : "",
+ cnpj: value === "juridica" ? prev.cnpj : "",
+ }));
+ };
+
+ const handleLogoFileChange: ChangeEventHandler = (event) => {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ const validationMessage = validateLogoFile(file);
+ if (validationMessage) {
+ toast.error(validationMessage);
+ event.currentTarget.value = "";
+ return;
+ }
+
+ if (logoPreviewUrl) {
+ URL.revokeObjectURL(logoPreviewUrl);
+ }
+ setSelectedLogoFile(file);
+ setLogoPreviewUrl(URL.createObjectURL(file));
+ };
+
+ const clearLogoSelection = () => {
+ if (logoPreviewUrl) {
+ URL.revokeObjectURL(logoPreviewUrl);
+ }
+ setSelectedLogoFile(null);
+ setLogoPreviewUrl(null);
+ if (logoFileInputRef.current) {
+ logoFileInputRef.current.value = "";
+ }
+ setForm((prev) => ({ ...prev, logoUrl: "" }));
+ };
+
+ const validateForm = (): boolean => {
+ if (!form.nome.trim()) {
+ toast.error("Nome é obrigatório.");
+ return false;
+ }
+ if (!form.codinome.trim()) {
+ toast.error("Codinome é obrigatório.");
+ return false;
+ }
+ if (!form.email.trim()) {
+ toast.error("E-mail é obrigatório.");
+ return false;
+ }
+ if (!onlyDigits(form.whatsapp).trim()) {
+ toast.error("WhatsApp é obrigatório.");
+ return false;
+ }
+ if (!form.asanaId.trim()) {
+ toast.error("Asana ID é obrigatório.");
+ return false;
+ }
+ if (isPessoaFisica) {
+ if (form.cpf.trim().length !== 11) {
+ toast.error("CPF deve conter 11 dígitos.");
+ return false;
+ }
+ if (!isValidCpf(form.cpf)) {
+ toast.error("CPF inválido.");
+ return false;
+ }
+ } else {
+ if (form.cnpj.trim().length !== 14) {
+ toast.error("CNPJ deve conter 14 dígitos.");
+ return false;
+ }
+ if (!isValidCnpj(form.cnpj)) {
+ toast.error("CNPJ inválido.");
+ return false;
+ }
+ }
+
+ const pontuacao = Number(form.pontuacaoMeta);
+ if (!Number.isInteger(pontuacao) || pontuacao < 0) {
+ toast.error("Pontuação meta deve ser um número inteiro maior ou igual a zero.");
+ return false;
+ }
+
+ return true;
+ };
+
+ const uploadLogoIfNeeded = async (): Promise => {
+ if (!selectedLogoFile) {
+ return normalizeOptionalText(form.logoUrl);
+ }
+
+ setUploadingLogo(true);
+ try {
+ const signed = await fechamentoUploadsService.presignUploadParceiroLogo({
+ fileName: selectedLogoFile.name,
+ contentType: selectedLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
+ });
+ await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedLogoFile);
+ return signed.publicUrl;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao enviar logo.";
+ toast.error(message);
+ throw error;
+ } finally {
+ setUploadingLogo(false);
+ }
+ };
+
+ const buildPayload = async () => {
+ const logoUrl = await uploadLogoIfNeeded();
+ return {
+ nome: form.nome.trim(),
+ codinome: normalizeOptionalText(form.codinome),
+ tipoPessoa: form.tipoPessoa,
+ cpf: form.tipoPessoa === "fisica" ? form.cpf.trim() : null,
+ cnpj: form.tipoPessoa === "juridica" ? form.cnpj.trim() : null,
+ email: form.email.trim(),
+ whatsapp: normalizeOptionalText(formatWhatsapp(form.whatsapp)),
+ asanaId: normalizeOptionalText(form.asanaId),
+ logoUrl,
+ observacoes: normalizeOptionalText(form.observacoes),
+ pontuacaoMeta: Number(form.pontuacaoMeta),
+ };
+ };
+
+ const handleSaveParceiro = async () => {
+ if (!validateForm()) {
+ return;
+ }
+
+ try {
+ setSaving(true);
+ const payload = await buildPayload();
+
+ if (isEditMode && selectedParceiro) {
+ await fechamentoParceirosService.editarParceiro(selectedParceiro.id, payload);
+ toast.success("Parceiro atualizado com sucesso.");
+ } else {
+ await fechamentoParceirosService.criarParceiro({ ...payload, estaAtivo: true });
+ toast.success("Parceiro criado com sucesso.");
+ }
+
+ setIsFormOpen(false);
+ resetFormState();
+ setReloadNonce((prev) => prev + 1);
+ if (!isEditMode) {
+ setCurrentPage(1);
+ }
+ } catch (error) {
+ if (!(error instanceof Error)) {
+ toast.error("Erro ao salvar parceiro.");
+ }
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleStatus = async () => {
+ if (!selectedParceiro) {
+ return;
+ }
+
+ try {
+ setToggling(true);
+ const updated = await fechamentoParceirosService.toggleAtivoParceiro(selectedParceiro.id);
+ toast.success(updated.estaAtivo ? "Parceiro reativado com sucesso." : "Parceiro inativado com sucesso.");
+ setIsToggleOpen(false);
+ setSelectedParceiro(null);
+ setReloadNonce((prev) => prev + 1);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao atualizar status do parceiro.";
+ toast.error(message);
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Parceiros
+
+
+
+ Novo parceiro
+
+
+
+
+
setBusca(e.target.value)}
+ className="w-full md:max-w-sm"
+ />
+
setFiltroStatus(value as ParceiroStatusFiltro)}>
+
+
+
+
+ Todos
+ Ativos
+ Inativos
+
+
+ {hasActiveFilters && (
+
+ Limpar
+
+ )}
+
+ Itens:
+
+
+
+
+
+ 10
+ 20
+ 50
+ 100
+
+
+
+
+
+
+
+ {!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
+
+
+
+
+ Nenhum parceiro cadastrado
+
+ Crie o primeiro parceiro para iniciar o módulo de fechamentos.
+
+
+
+
+ Novo parceiro
+
+
+
+ ) : (
+ <>
+
+
+
+
+ Logo
+ Nome
+ Codinome
+ E-mail
+ Tipo
+ Status
+ Ações
+
+
+
+ {loadingList ? (
+
+
+ Carregando...
+
+
+ ) : totalRegistros === 0 ? (
+
+
+ Nenhum parceiro encontrado para os filtros atuais.
+
+
+ ) : (
+ parceiros.map((parceiro) => (
+
+
+ {parceiro.logoUrl ? (
+
+ ) : (
+ "—"
+ )}
+
+ {getParceiroNome(parceiro)}
+ {parceiro.codinome?.trim() || "—"}
+ {parceiro.email}
+ {getTipoPessoaLabel(parceiro.tipoPessoa)}
+
+
+ {parceiro.estaAtivo ? "Ativo" : "Inativo"}
+
+
+
+
+
openEditDialog(parceiro)}
+ title="Editar parceiro"
+ >
+
+
+
openToggleDialog(parceiro)}
+ title={parceiro.estaAtivo ? "Inativar parceiro" : "Reativar parceiro"}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {!loadingList && totalRegistros > 0 && (
+
+
+ Mostrando {totalRegistros} {totalRegistros === 1 ? "parceiro" : "parceiros"}
+
+
+
setCurrentPage((p) => Math.max(1, p - 1))}
+ disabled={currentPage === 1}
+ >
+ Anterior
+
+
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
+ let page: number;
+ if (totalPages <= 5) {
+ page = i + 1;
+ } else if (currentPage <= 3) {
+ page = i + 1;
+ } else if (currentPage >= totalPages - 2) {
+ page = totalPages - 4 + i;
+ } else {
+ page = currentPage - 2 + i;
+ }
+ return (
+ setCurrentPage(page)}
+ className="h-9 w-10 p-0"
+ >
+ {page}
+
+ );
+ })}
+
+
setCurrentPage((p) => Math.min(totalPages, p + 1))}
+ disabled={currentPage === totalPages}
+ >
+ Próxima
+
+
+
+ )}
+ >
+ )}
+
+
+
{
+ setIsFormOpen(open);
+ if (!open) {
+ resetFormState();
+ }
+ }}
+ >
+
+
+ {formTitle}
+ Preencha os dados para salvar o parceiro.
+
+
+
+
logoFileInputRef.current?.click()}
+ title="Clique para alterar a foto"
+ >
+ {logoDisplayUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ Alterar foto
+
+
+
+
+
{logoStatusLabel}
+
+
+ Remover logo
+
+
+
+
+
+
+ Nome *
+
+ setForm((prev) => ({ ...prev, nome: e.target.value }))}
+ placeholder="Razão social ou nome completo"
+ />
+
+
+
+
+ Codinome *
+
+ setForm((prev) => ({ ...prev, codinome: e.target.value }))}
+ placeholder="Nome curto para exibição"
+ />
+
+
+
+
+ Tipo de Pessoa *
+
+ onChangeTipoPessoa(value as ParceiroTipoPessoa)}>
+
+
+
+
+ Jurídica
+ Física
+
+
+
+
+ {isPessoaFisica ? (
+
+
+ CPF *
+
+ setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))}
+ placeholder="000.000.000-00"
+ maxLength={14}
+ />
+
+ ) : (
+
+
+ CNPJ *
+
+ setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))}
+ placeholder="00.000.000/0000-00"
+ maxLength={18}
+ />
+
+ )}
+
+
+
+ E-mail *
+
+ setForm((prev) => ({ ...prev, email: e.target.value }))}
+ placeholder="contato@empresa.com"
+ />
+
+
+
+
+ WhatsApp *
+
+ setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))}
+ placeholder="11 99999-9999"
+ maxLength={13}
+ />
+
+
+
+
+ Asana ID *
+
+
{
+ setForm((prev) => ({
+ ...prev,
+ asanaId: value === "__none__" ? "" : value,
+ }));
+ }}
+ disabled={loadingAsanaUsers || !hasAsanaWorkspace}
+ >
+
+
+
+
+ Sem vínculo
+ {asanaUserOptions.map((user) => (
+
+ {user.name}
+
+ ))}
+
+
+
+ {loadingAsanaUsers ? (
+
+
+ Buscando usuários do workspace...
+
+ ) : hasAsanaWorkspace ? (
+ `Workspace: ${asanaWorkspaceNome || asanaWorkspaceId}`
+ ) : (
+ "Defina token/workspace do Asana na tela Configurações para habilitar este campo."
+ )}
+
+
+
+
+
+ Pontuação Meta *
+
+ setForm((prev) => ({ ...prev, pontuacaoMeta: e.target.value }))}
+ placeholder="0"
+ />
+
+
+
+ Observações
+
+
+
+
+ {
+ setIsFormOpen(false);
+ resetFormState();
+ }}
+ disabled={saving || uploadingLogo}
+ >
+ Cancelar
+
+
+ {uploadingLogo ? "Enviando logo..." : formActionLabel}
+
+
+
+
+
+
+
+
+
+ {selectedParceiro?.estaAtivo ? "Inativar parceiro" : "Reativar parceiro"}
+
+
+ {selectedParceiro?.estaAtivo ? (
+ <>
+ Deseja inativar o parceiro {selectedParceiro ? getParceiroNome(selectedParceiro) : "—"} ?
+ >
+ ) : (
+ <>
+ Deseja reativar o parceiro {selectedParceiro ? getParceiroNome(selectedParceiro) : "—"} ?
+ >
+ )}
+
+
+
+ Cancelar
+
+ {toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
+
+
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/Usuarios.tsx b/src/modules/fechamento-hgtx/pages/Usuarios.tsx
new file mode 100644
index 0000000..13833b4
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/Usuarios.tsx
@@ -0,0 +1,736 @@
+import { useEffect, useMemo, useState } from "react";
+import { Edit, Plus, Power, Users, UserCheck, UserX } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ fechamentoUsuariosService,
+ type UsuarioItem,
+ type UsuarioPapel,
+ type UsuarioStatusFiltro,
+} from "@/services/fechamento/usuarios";
+import {
+ fechamentoParceirosService,
+ type ParceiroItem,
+} from "@/services/fechamento/parceiros";
+
+type UsuarioForm = {
+ nome: string;
+ email: string;
+ papel: UsuarioPapel;
+ parceiroId: string;
+};
+
+const formInicial: UsuarioForm = {
+ nome: "",
+ email: "",
+ papel: "admin",
+ parceiroId: "",
+};
+const parceiroSemVinculoValue = "__none__";
+
+function getNomeUsuario(usuario: UsuarioItem | null | undefined): string {
+ if (!usuario) {
+ return "—";
+ }
+ const nome = usuario.nome?.trim();
+ return nome && nome.length > 0 ? nome : "—";
+}
+
+function getEmailUsuario(usuario: UsuarioItem | null | undefined): string {
+ if (!usuario) {
+ return "—";
+ }
+ const email = usuario.email?.trim();
+ return email && email.length > 0 ? email : "—";
+}
+
+export default function Usuarios() {
+ const [usuarios, setUsuarios] = useState([]);
+ const [parceiros, setParceiros] = useState([]);
+
+ const [loadingList, setLoadingList] = useState(true);
+ const [loadingParceiros, setLoadingParceiros] = useState(true);
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
+ const [isEditOpen, setIsEditOpen] = useState(false);
+ const [isToggleOpen, setIsToggleOpen] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [toggling, setToggling] = useState(false);
+
+ const [selectedUsuario, setSelectedUsuario] = useState(null);
+ const [form, setForm] = useState(formInicial);
+
+ const [busca, setBusca] = useState("");
+ const [filtroPapel, setFiltroPapel] = useState<"all" | UsuarioPapel>("all");
+ const [filtroStatus, setFiltroStatus] = useState("all");
+ const [currentPage, setCurrentPage] = useState(1);
+ const [itemsPerPage, setItemsPerPage] = useState(10);
+ const [totalRegistros, setTotalRegistros] = useState(0);
+ const [totalPaginas, setTotalPaginas] = useState(1);
+ const [reloadNonce, setReloadNonce] = useState(0);
+
+ const totalPages = Math.max(1, totalPaginas);
+ const hasActiveFilters =
+ busca.trim().length > 0 || filtroPapel !== "all" || filtroStatus !== "all";
+ const parceiroObrigatorio = form.papel === "parceiro";
+ const parceiroSelecionado = useMemo(
+ () => parceiros.find((p) => p.id === form.parceiroId) ?? null,
+ [parceiros, form.parceiroId],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoadingParceiros(true);
+ fechamentoParceirosService
+ .listarParceirosAtivos()
+ .then((data) => {
+ if (!cancelled) {
+ setParceiros(data);
+ }
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ const message = error instanceof Error ? error.message : "Erro ao listar parceiros.";
+ toast.error(message);
+ setParceiros([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoadingParceiros(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoadingList(true);
+ fechamentoUsuariosService
+ .listarUsuarios({
+ busca: busca.trim() || undefined,
+ papel: filtroPapel === "all" ? undefined : filtroPapel,
+ estaAtivo: filtroStatus,
+ page: currentPage,
+ perPage: itemsPerPage,
+ })
+ .then((res) => {
+ if (!cancelled) {
+ setUsuarios(res.data ?? []);
+ setTotalRegistros(res.meta?.total ?? 0);
+ setTotalPaginas(res.meta?.totalPaginas ?? 1);
+ }
+ })
+ .catch((error) => {
+ if (!cancelled) {
+ const message = error instanceof Error ? error.message : "Erro ao listar usuários.";
+ toast.error(message);
+ setUsuarios([]);
+ setTotalRegistros(0);
+ setTotalPaginas(1);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setLoadingList(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [busca, filtroPapel, filtroStatus, currentPage, itemsPerPage, reloadNonce]);
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [busca, filtroPapel, filtroStatus]);
+
+ const clearFilters = () => {
+ setBusca("");
+ setFiltroPapel("all");
+ setFiltroStatus("all");
+ setCurrentPage(1);
+ };
+
+ const openCreateDialog = () => {
+ setForm(formInicial);
+ setIsCreateOpen(true);
+ };
+
+ const openEditDialog = (usuario: UsuarioItem) => {
+ setSelectedUsuario(usuario);
+ setForm({
+ nome: usuario.nome ?? "",
+ email: usuario.email ?? "",
+ papel: usuario.papel,
+ parceiroId: usuario.parceiroId ?? "",
+ });
+ setIsEditOpen(true);
+ };
+
+ const openToggleDialog = (usuario: UsuarioItem) => {
+ setSelectedUsuario(usuario);
+ setIsToggleOpen(true);
+ };
+
+ const validateForm = (): boolean => {
+ if (!form.nome.trim()) {
+ toast.error("Nome é obrigatório.");
+ return false;
+ }
+ if (!form.email.trim()) {
+ toast.error("E-mail é obrigatório.");
+ return false;
+ }
+ if (parceiroObrigatorio && !form.parceiroId) {
+ toast.error("Selecione um parceiro para o perfil parceiro.");
+ return false;
+ }
+ return true;
+ };
+
+ const handleCreate = async () => {
+ if (!validateForm()) {
+ return;
+ }
+ try {
+ setSaving(true);
+ await fechamentoUsuariosService.criarUsuario({
+ nome: form.nome.trim(),
+ email: form.email.trim(),
+ papel: form.papel,
+ parceiroId: form.parceiroId || null,
+ estaAtivo: true,
+ });
+ toast.success("Usuário criado com sucesso.");
+ setIsCreateOpen(false);
+ setCurrentPage(1);
+ setReloadNonce((prev) => prev + 1);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao criar usuário.";
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleEdit = async () => {
+ if (!selectedUsuario || !validateForm()) {
+ return;
+ }
+ try {
+ setSaving(true);
+ await fechamentoUsuariosService.editarUsuario(selectedUsuario.id, {
+ nome: form.nome.trim(),
+ email: form.email.trim(),
+ papel: form.papel,
+ parceiroId: form.parceiroId || null,
+ });
+ toast.success("Usuário atualizado com sucesso.");
+ setIsEditOpen(false);
+ setSelectedUsuario(null);
+ setReloadNonce((prev) => prev + 1);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao editar usuário.";
+ toast.error(message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggleStatus = async () => {
+ if (!selectedUsuario) {
+ return;
+ }
+ try {
+ setToggling(true);
+ const updated = await fechamentoUsuariosService.toggleAtivoUsuario(selectedUsuario.id);
+ toast.success(updated.estaAtivo ? "Usuário reativado com sucesso." : "Usuário inativado com sucesso.");
+ setIsToggleOpen(false);
+ setSelectedUsuario(null);
+ setReloadNonce((prev) => prev + 1);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erro ao atualizar status do usuário.";
+ toast.error(message);
+ } finally {
+ setToggling(false);
+ }
+ };
+
+ const handleItemsPerPageChange = (value: string) => {
+ setItemsPerPage(Number(value));
+ setCurrentPage(1);
+ };
+
+ const onChangePapel = (value: UsuarioPapel) => {
+ setForm((prev) => ({
+ ...prev,
+ papel: value,
+ }));
+ };
+
+ return (
+
+
+
+
+
+ Usuários
+
+
+
+ Novo usuário
+
+
+
+
+
setBusca(e.target.value)}
+ className="w-full md:max-w-sm"
+ />
+
setFiltroPapel(value as "all" | UsuarioPapel)}
+ >
+
+
+
+
+ Todos os perfis
+ Admin
+ Parceiro
+
+
+
setFiltroStatus(value as UsuarioStatusFiltro)}>
+
+
+
+
+ Todos
+ Ativos
+ Inativos
+
+
+ {hasActiveFilters && (
+
+ Limpar
+
+ )}
+
+ Itens:
+
+
+
+
+
+ 10
+ 20
+ 50
+ 100
+
+
+
+
+
+
+
+ {!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
+
+
+
+
+ Nenhum usuário cadastrado
+
+
+ Crie o primeiro usuário para iniciar o gerenciamento de acessos.
+
+
+
+
+
+ Novo usuário
+
+
+
+ ) : (
+ <>
+
+
+
+
+ Nome
+ E-mail
+ Perfil
+ Status
+ Ações
+
+
+
+ {loadingList ? (
+
+
+ Carregando...
+
+
+ ) : totalRegistros === 0 ? (
+
+
+ Nenhum usuário encontrado para os filtros atuais.
+
+
+ ) : (
+ usuarios.map((usuario) => (
+
+ {getNomeUsuario(usuario)}
+ {getEmailUsuario(usuario)}
+
+
+ {usuario.papel === "admin" ? "Admin" : "Parceiro"}
+
+
+
+
+ {usuario.estaAtivo ? "Ativo" : "Inativo"}
+
+
+
+
+
openEditDialog(usuario)}
+ title="Editar usuário"
+ >
+
+
+
openToggleDialog(usuario)}
+ title={usuario.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
+ >
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {!loadingList && totalRegistros > 0 && (
+
+
+ Mostrando {totalRegistros} {totalRegistros === 1 ? "usuário" : "usuários"}
+
+
+
setCurrentPage((p) => Math.max(1, p - 1))}
+ disabled={currentPage === 1}
+ >
+ Anterior
+
+
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
+ let page: number;
+ if (totalPages <= 5) {
+ page = i + 1;
+ } else if (currentPage <= 3) {
+ page = i + 1;
+ } else if (currentPage >= totalPages - 2) {
+ page = totalPages - 4 + i;
+ } else {
+ page = currentPage - 2 + i;
+ }
+ return (
+ setCurrentPage(page)}
+ className="h-9 w-10 p-0"
+ >
+ {page}
+
+ );
+ })}
+
+
setCurrentPage((p) => Math.min(totalPages, p + 1))}
+ disabled={currentPage === totalPages}
+ >
+ Próxima
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ Novo usuário
+ Preencha os dados para criar um novo usuário.
+
+
+
+ Nome
+ setForm((prev) => ({ ...prev, nome: e.target.value }))}
+ placeholder="Nome completo"
+ />
+
+
+ E-mail
+ setForm((prev) => ({ ...prev, email: e.target.value }))}
+ placeholder="usuario@empresa.com"
+ />
+
+
+ Perfil
+ onChangePapel(value as UsuarioPapel)}>
+
+
+
+
+ Admin
+ Parceiro
+
+
+
+
+
+ Parceiro {parceiroObrigatorio ? * : null}
+
+
+ setForm((prev) => ({
+ ...prev,
+ parceiroId: value === parceiroSemVinculoValue ? "" : value,
+ }))
+ }
+ disabled={loadingParceiros}
+ >
+
+
+
+
+
+ Sem parceiro
+
+ {parceiros.map((parceiro) => (
+
+ {parceiro.codinome?.trim()
+ ? `${parceiro.nome} (${parceiro.codinome.trim()})`
+ : parceiro.nome}
+
+ ))}
+
+
+
+ Cada parceiro pode estar vinculado a apenas um usuário.
+
+
+
+
+ setIsCreateOpen(false)} disabled={saving}>
+ Cancelar
+
+
+ {saving ? "Criando..." : "Criar usuário"}
+
+
+
+
+
+
+
+
+ Editar usuário
+ Atualize os dados do usuário selecionado.
+
+
+
+ Nome
+ setForm((prev) => ({ ...prev, nome: e.target.value }))}
+ placeholder="Nome completo"
+ />
+
+
+ E-mail
+ setForm((prev) => ({ ...prev, email: e.target.value }))}
+ placeholder="usuario@empresa.com"
+ />
+
+
+ Perfil
+ onChangePapel(value as UsuarioPapel)}>
+
+
+
+
+ Admin
+ Parceiro
+
+
+
+
+
+ Parceiro {parceiroObrigatorio ? * : null}
+
+
+ setForm((prev) => ({
+ ...prev,
+ parceiroId: value === parceiroSemVinculoValue ? "" : value,
+ }))
+ }
+ disabled={loadingParceiros}
+ >
+
+
+
+
+
+ Sem parceiro
+
+ {parceiros.map((parceiro) => (
+
+ {parceiro.codinome?.trim()
+ ? `${parceiro.nome} (${parceiro.codinome.trim()})`
+ : parceiro.nome}
+
+ ))}
+
+
+ {parceiroSelecionado && (
+
+ Vinculado: {parceiroSelecionado.codinome?.trim() || parceiroSelecionado.nome}
+
+ )}
+
+ Cada parceiro pode estar vinculado a apenas um usuário.
+
+
+
+
+ setIsEditOpen(false)} disabled={saving}>
+ Cancelar
+
+
+ {saving ? "Salvando..." : "Salvar alterações"}
+
+
+
+
+
+
+
+
+
+ {selectedUsuario?.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
+
+
+ {selectedUsuario?.estaAtivo ? (
+ <>
+ Deseja inativar o usuário {getNomeUsuario(selectedUsuario)} ?
+ >
+ ) : (
+ <>
+ Deseja reativar o usuário {getNomeUsuario(selectedUsuario)} ?
+ >
+ )}
+
+
+
+ Cancelar
+
+ {toggling ? (
+ "Processando..."
+ ) : selectedUsuario?.estaAtivo ? (
+
+
+ Inativar
+
+ ) : (
+
+
+ Reativar
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/services/fechamento/asanaWorkspaceUsers.ts b/src/services/fechamento/asanaWorkspaceUsers.ts
new file mode 100644
index 0000000..9193a9f
--- /dev/null
+++ b/src/services/fechamento/asanaWorkspaceUsers.ts
@@ -0,0 +1,62 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type AsanaWorkspaceUser = {
+ id: string;
+ name: string;
+};
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+type AsanaWorkspaceUsersResponse = {
+ data: Array<{
+ gid: string;
+ name?: string;
+ email?: string;
+ }>;
+};
+
+class FechamentoAsanaWorkspaceUsersService {
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarUsuariosDoWorkspace(workspaceId: string): Promise {
+ if (!workspaceId.trim()) {
+ return [];
+ }
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(
+ `${baseUrl}/asana/workspaces/${encodeURIComponent(workspaceId)}/users`,
+ { headers },
+ );
+
+ return (response.data.data ?? []).map((item) => ({
+ id: item.gid,
+ name: item.name?.trim() || item.email?.trim() || item.gid,
+ }));
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar usuários do workspace do Asana.");
+ }
+ }
+}
+
+export const fechamentoAsanaWorkspaceUsersService = new FechamentoAsanaWorkspaceUsersService();
diff --git a/src/services/fechamento/authMe.ts b/src/services/fechamento/authMe.ts
new file mode 100644
index 0000000..672c83f
--- /dev/null
+++ b/src/services/fechamento/authMe.ts
@@ -0,0 +1,123 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type PapelUsuario = "admin" | "parceiro";
+
+export type MeData = {
+ id: string;
+ nome: string;
+ email: string;
+ papel: PapelUsuario;
+ parceiroId: string | null;
+ estaAtivo: boolean;
+ unidadeId: string;
+};
+
+export type BootstrapStatusData = {
+ requiresBootstrap: boolean;
+ estabelecimentoId: string;
+ unidade: { id: string; nome: string; estabelecimentoId: string } | null;
+};
+
+export type BootstrapInitInput = {
+ estabelecimentoId: string;
+ unidadeNome: string;
+ adminNome: string;
+ adminEmail: string;
+};
+
+type MeResponse = {
+ success: boolean;
+ data: MeData;
+};
+
+type BootstrapStatusResponse = {
+ success: boolean;
+ data: BootstrapStatusData;
+};
+
+type BootstrapInitResponse = {
+ success: boolean;
+ data: {
+ unidadeId: string;
+ adminId: string;
+ };
+};
+
+type ApiErrorShape = {
+ status?: number;
+ data?: {
+ error?: {
+ code?: string;
+ message?: string;
+ };
+ };
+ message?: string;
+};
+
+function isApiError(value: unknown): value is ApiErrorShape {
+ return typeof value === "object" && value !== null;
+}
+
+class AuthMeService {
+ async getMe(usuarioEmail: string): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders({ omitUnidadeId: true });
+
+ try {
+ const response = await axios.get(`${baseUrl}/me`, {
+ params: { usuarioEmail },
+ headers,
+ });
+ return response.data.data;
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status;
+ const code = (error.response?.data as ApiErrorShape["data"] | undefined)?.error?.code;
+ if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
+ return null;
+ }
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message =
+ (error.response?.data as ApiErrorShape["data"] | undefined)?.error?.message ?? error.message;
+ throw new Error(message || "Erro ao consultar acesso do usuário.");
+ }
+ if (isApiError(error)) {
+ const status = error.status;
+ const code = error.data?.error?.code;
+ if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
+ return null;
+ }
+ const message = error.data?.error?.message ?? error.message;
+ throw new Error(message || "Erro ao consultar acesso do usuário.");
+ }
+ throw new Error("Erro ao consultar acesso do usuário.");
+ }
+ }
+
+ async getBootstrapStatus(estabelecimentoId: string): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders({ omitUnidadeId: true });
+ const response = await axios.get(`${baseUrl}/bootstrap-status`, {
+ headers,
+ params: { estabelecimentoId },
+ });
+ return response.data.data;
+ }
+
+ async bootstrapInitialize(input: BootstrapInitInput): Promise<{ unidadeId: string; adminId: string }> {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders({ omitUnidadeId: true });
+ const response = await axios.post(`${baseUrl}/bootstrap`, input, {
+ headers,
+ });
+ return response.data.data;
+ }
+}
+
+export const authMeService = new AuthMeService();
diff --git a/src/services/fechamento/commanderHttp.ts b/src/services/fechamento/commanderHttp.ts
new file mode 100644
index 0000000..9701fbb
--- /dev/null
+++ b/src/services/fechamento/commanderHttp.ts
@@ -0,0 +1,27 @@
+import { GlobalFunctions } from "@/GlobalFunctions";
+
+import { resolveCommanderUnidadeId } from "./unidadeContext";
+import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
+
+export { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
+
+export type BuildCommanderHeadersOptions = {
+ /** Rotas allowlisted no Commander (ex.: `/me`, lookup de `/unidades`) não devem enviar `X-Unidade-Id`. */
+ omitUnidadeId?: boolean;
+};
+
+export async function buildCommanderHeaders(
+ opts?: BuildCommanderHeadersOptions,
+): Promise> {
+ const token = await GlobalFunctions.getToken();
+ const apiKey = import.meta.env.VITE_API_KEY || "";
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...(apiKey ? { apikey: apiKey } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+ if (!opts?.omitUnidadeId) {
+ headers["X-Unidade-Id"] = await resolveCommanderUnidadeId();
+ }
+ return headers;
+}
diff --git a/src/services/fechamento/competencias.ts b/src/services/fechamento/competencias.ts
new file mode 100644
index 0000000..09a9c21
--- /dev/null
+++ b/src/services/fechamento/competencias.ts
@@ -0,0 +1,159 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type CompetenciaStatus = "em_aberto" | "concluido";
+
+export type CompetenciaItem = {
+ id: string;
+ mes: number;
+ ano: number;
+ status: CompetenciaStatus;
+ quantidadeFechamentos: number;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+export type FechamentoDaCompetenciaItem = {
+ id: string;
+ competenciaId: string;
+ parceiroId: string;
+ parceiroNome: string;
+ parceiroCodinome: string | null;
+ parceiroLogoUrl: string | null;
+ horasTotal: number;
+ status: "em_aberto" | "fechado";
+ pontuacaoTotalEntregue: number;
+ pontuacaoMeta: number;
+ pontuacaoPaga: number;
+ pontuacaoBanco: number;
+ exportadoFinanceiro: boolean;
+ fechadoEm: string | null;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+type ListarCompetenciasResponse = {
+ data: CompetenciaItem[];
+};
+
+type ListarFechamentosDaCompetenciaResponse = {
+ data: FechamentoDaCompetenciaItem[];
+};
+
+type CriarCompetenciaResponse = {
+ data: CompetenciaItem;
+};
+
+type ImportarCompetenciaResponse = {
+ data: {
+ competenciaId: string;
+ tarefasRecebidas: number;
+ tarefasImportadas: number;
+ fechamentosCriados: number;
+ tarefasIgnoradasSemParceiro: number;
+ chamadasSubtarefasAsana: number;
+ };
+};
+
+export type ImportacaoAsanaModo =
+ | "reprocessar_tudo"
+ | "reprocessar_alguns"
+ | "buscar_novos_fechamentos";
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+class FechamentoCompetenciasService {
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarCompetencias(params: { ano?: number; status?: CompetenciaStatus }): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/competencias`, {
+ headers,
+ params: {
+ ano: params.ano,
+ status: params.status,
+ },
+ });
+ return response.data.data ?? [];
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar competências.");
+ }
+ }
+
+ async listarFechamentosDaCompetencia(competenciaId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(
+ `${baseUrl}/competencias/${competenciaId}/fechamentos`,
+ { headers },
+ );
+ return response.data.data ?? [];
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar fechamentos da competência.");
+ }
+ }
+
+ async criarCompetencia(input: { mes: number; ano: number }): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/competencias`,
+ { mes: input.mes, ano: input.ano },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao criar competência.");
+ }
+ }
+
+ async importarDoAsana(
+ competenciaId: string,
+ opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
+ ): Promise<{ tarefasRecebidas: number; tarefasImportadas: number; fechamentosCriados: number; tarefasIgnoradasSemParceiro: number }> {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/competencias/${competenciaId}/importar`,
+ {
+ modo: opcoes?.modo ?? "reprocessar_tudo",
+ parceiroIds: opcoes?.parceiroIds,
+ },
+ { headers },
+ );
+ return {
+ tarefasRecebidas: response.data.data.tarefasRecebidas,
+ tarefasImportadas: response.data.data.tarefasImportadas,
+ fechamentosCriados: response.data.data.fechamentosCriados,
+ tarefasIgnoradasSemParceiro: response.data.data.tarefasIgnoradasSemParceiro,
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao importar tasks do Asana.");
+ }
+ }
+}
+
+export const fechamentoCompetenciasService = new FechamentoCompetenciasService();
diff --git a/src/services/fechamento/configuracoes.ts b/src/services/fechamento/configuracoes.ts
new file mode 100644
index 0000000..75dd04c
--- /dev/null
+++ b/src/services/fechamento/configuracoes.ts
@@ -0,0 +1,83 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type ConfiguracaoPublica = {
+ id: string;
+ asanaWorkspaceId: string | null;
+ asanaWorkspaceNome: string | null;
+ asanaToken: string | null;
+ asanaTokenConfigured: boolean;
+ atualizadoEm: string;
+ atualizadoPorId: string | null;
+};
+
+type GetConfiguracoesResponse = {
+ data: ConfiguracaoPublica | null;
+};
+
+type SalvarConfiguracoesBody = {
+ asanaToken?: string;
+ asanaWorkspaceId?: string | null;
+ asanaWorkspaceNome?: string | null;
+};
+
+type SalvarConfiguracoesResponse = {
+ data: ConfiguracaoPublica;
+};
+
+class FechamentoConfiguracoesService {
+ async getConfiguracoes(): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/configuracoes`, {
+ headers,
+ });
+ return response.data.data;
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message =
+ ((error.response.data as { error?: { message?: string } } | undefined)?.error?.message ??
+ error.message) ||
+ "Erro ao carregar configurações.";
+ throw new Error(message);
+ }
+ throw new Error("Erro ao carregar configurações.");
+ }
+ }
+
+ async salvarConfiguracoes(body: SalvarConfiguracoesBody): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/configuracoes`,
+ body,
+ { headers },
+ );
+ return response.data.data;
+ } catch (error: unknown) {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message =
+ ((error.response.data as { error?: { message?: string } } | undefined)?.error?.message ??
+ error.message) ||
+ "Erro ao salvar configurações.";
+ throw new Error(message);
+ }
+ throw new Error("Erro ao salvar configurações.");
+ }
+ }
+}
+
+export const fechamentoConfiguracoesService = new FechamentoConfiguracoesService();
diff --git a/src/services/fechamento/fechamentoBaseUrl.ts b/src/services/fechamento/fechamentoBaseUrl.ts
new file mode 100644
index 0000000..f806cd9
--- /dev/null
+++ b/src/services/fechamento/fechamentoBaseUrl.ts
@@ -0,0 +1,9 @@
+export function resolveCommanderBaseUrl(): string {
+ const raw = import.meta.env.VITE_API_BASE_URL_COMMANDER?.trim();
+ if (!raw) {
+ throw new Error(
+ "A variável VITE_API_BASE_URL_COMMANDER não está configurada. Defina, por exemplo, http://localhost:3333/api/",
+ );
+ }
+ return raw.endsWith("/") ? raw.slice(0, -1) : raw;
+}
diff --git a/src/services/fechamento/fechamentos.ts b/src/services/fechamento/fechamentos.ts
new file mode 100644
index 0000000..8750a36
--- /dev/null
+++ b/src/services/fechamento/fechamentos.ts
@@ -0,0 +1,288 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type FechamentoTarefaItem = {
+ id: string;
+ fechamentoId: string;
+ tipo: string;
+ numeroTicket: string | null;
+ descricao: string;
+ cliente: string | null;
+ linkAsana: string | null;
+ tempoMinutos: number | null;
+ pontuacao: number;
+ pontuacaoOriginal: number;
+ estaRevisada: boolean;
+ dataConclusao: string | null;
+ editadoEm: string | null;
+ editadoPorId: string | null;
+ criadoEm: string;
+};
+
+type ListarTarefasResponse = {
+ data: FechamentoTarefaItem[];
+};
+
+type PatchTarefaResponse = {
+ data: FechamentoTarefaItem;
+};
+
+type CriarLancamentoResponse = {
+ data: FechamentoTarefaItem;
+};
+
+export type FechamentoFechadoResponse = {
+ id: string;
+ competenciaId: string;
+ parceiroId: string;
+ status: "fechado";
+ versao: number;
+ pontuacaoTotalEntregue: number;
+ pontuacaoMeta: number;
+ pontuacaoPaga: number;
+ pontuacaoBanco: number;
+ exportadoFinanceiro: boolean;
+ fechadoPorId: string | null;
+ fechadoEm: string | null;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+export type FechamentoReabertoResponse = {
+ id: string;
+ competenciaId: string;
+ parceiroId: string;
+ status: "em_aberto";
+ versao: number;
+ pontuacaoTotalEntregue: number;
+ pontuacaoMeta: number;
+ pontuacaoPaga: number;
+ pontuacaoBanco: number;
+ exportadoFinanceiro: boolean;
+ fechadoPorId: string | null;
+ fechadoEm: string | null;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+type ConcluirFechamentoResponse = {
+ data: FechamentoFechadoResponse;
+};
+
+type ReabrirFechamentoResponse = {
+ data: FechamentoReabertoResponse;
+};
+
+type ReprocessarAsanaResponse = {
+ data: {
+ fechamentoId: string;
+ tarefasRecebidas: number;
+ tarefasImportadas: number;
+ tarefasIgnoradasSemParceiro: number;
+ chamadasSubtarefasAsana: number;
+ };
+};
+
+export type ExportarPlanilhaResponse = {
+ buffer: ArrayBuffer;
+ filename: string | null;
+};
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+class FechamentoFechamentosService {
+ private extractFilenameFromContentDisposition(value: string | undefined): string | null {
+ if (!value) return null;
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
+ if (utf8Match?.[1]) {
+ try {
+ return decodeURIComponent(utf8Match[1]);
+ } catch {
+ return utf8Match[1];
+ }
+ }
+ const regularMatch = value.match(/filename="?([^";]+)"?/i);
+ return regularMatch?.[1] ?? null;
+ }
+
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarTarefas(fechamentoId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
+ headers,
+ });
+ return response.data.data ?? [];
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar tarefas do fechamento.");
+ }
+ }
+
+ async patchTarefa(
+ fechamentoId: string,
+ tarefaId: string,
+ input: {
+ estaRevisada?: boolean;
+ pontuacao?: number;
+ numeroTicket?: string | null;
+ descricao?: string;
+ cliente?: string | null;
+ tempoMinutos?: number | null;
+ editadoPorId: string;
+ },
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.patch(
+ `${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
+ {
+ ...(input.estaRevisada !== undefined ? { esta_revisada: input.estaRevisada } : {}),
+ ...(input.pontuacao !== undefined ? { pontuacao: input.pontuacao } : {}),
+ ...(input.numeroTicket !== undefined ? { numero_ticket: input.numeroTicket } : {}),
+ ...(input.descricao !== undefined ? { descricao: input.descricao } : {}),
+ ...(input.cliente !== undefined ? { cliente: input.cliente } : {}),
+ ...(input.tempoMinutos !== undefined ? { tempo_minutos: input.tempoMinutos } : {}),
+ editado_por_id: input.editadoPorId,
+ },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao atualizar aprovação da tarefa.");
+ }
+ }
+
+ async criarLancamento(
+ fechamentoId: string,
+ input: { tipo: "bonus" | "desconto"; descricao: string; pontuacao: number },
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/fechamentos/${fechamentoId}/tarefas`,
+ {
+ tipo: input.tipo,
+ descricao: input.descricao,
+ pontuacao: input.pontuacao,
+ },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao criar lançamento no fechamento.");
+ }
+ }
+
+ async excluirLancamento(fechamentoId: string, tarefaId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.delete(
+ `${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao excluir lançamento.");
+ }
+ }
+
+ async concluirFechamento(
+ fechamentoId: string,
+ input: { pontuacaoPaga: number; fechadoPorId?: string; motivoAjuste?: string },
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/fechamentos/${fechamentoId}/concluir`,
+ {
+ pontuacao_paga: input.pontuacaoPaga,
+ ...(input.fechadoPorId ? { fechado_por_id: input.fechadoPorId } : {}),
+ ...(input.motivoAjuste?.trim() ? { motivo_ajuste: input.motivoAjuste.trim() } : {}),
+ },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao concluir fechamento.");
+ }
+ }
+
+ async exportarPlanilha(fechamentoId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/fechamentos/${fechamentoId}/exportar`, {
+ headers,
+ responseType: "arraybuffer",
+ });
+ return {
+ buffer: response.data,
+ filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao exportar planilha do fechamento.");
+ }
+ }
+
+ async reabrirFechamento(
+ fechamentoId: string,
+ input: { reabertoPorId: string; motivo?: string },
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/fechamentos/${fechamentoId}/reabrir`,
+ {
+ reaberto_por_id: input.reabertoPorId,
+ ...(input.motivo?.trim() ? { motivo: input.motivo.trim() } : {}),
+ },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao reabrir fechamento.");
+ }
+ }
+
+ async reprocessarAsana(fechamentoId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/fechamentos/${fechamentoId}/reprocessar-asana`,
+ {},
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao reprocessar tarefas do Asana.");
+ }
+ }
+}
+
+export const fechamentoFechamentosService = new FechamentoFechamentosService();
diff --git a/src/services/fechamento/parceiros.ts b/src/services/fechamento/parceiros.ts
new file mode 100644
index 0000000..ba905d8
--- /dev/null
+++ b/src/services/fechamento/parceiros.ts
@@ -0,0 +1,157 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type ParceiroTipoPessoa = "fisica" | "juridica";
+export type ParceiroStatusFiltro = "all" | "true" | "false";
+
+export type ParceiroItem = {
+ id: string;
+ nome: string;
+ codinome: string | null;
+ tipoPessoa: ParceiroTipoPessoa;
+ cpf: string | null;
+ cnpj: string | null;
+ email: string;
+ whatsapp: string | null;
+ asanaId: string | null;
+ logoUrl: string | null;
+ observacoes: string | null;
+ pontuacaoMeta: number;
+ estaAtivo: boolean;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+type MetaResponse = {
+ total: number;
+ paginaAtual: number;
+ totalPaginas: number;
+};
+
+export type ListarParceirosResponse = {
+ data: ParceiroItem[];
+ meta: MetaResponse;
+};
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+export type ListarParceirosParams = {
+ busca?: string;
+ estaAtivo?: ParceiroStatusFiltro;
+ page: number;
+ perPage: number;
+};
+
+export type SalvarParceiroPayload = {
+ nome: string;
+ codinome?: string | null;
+ tipoPessoa: ParceiroTipoPessoa;
+ cpf?: string | null;
+ cnpj?: string | null;
+ email: string;
+ whatsapp?: string | null;
+ asanaId?: string | null;
+ logoUrl?: string | null;
+ observacoes?: string | null;
+ pontuacaoMeta: number;
+ estaAtivo?: boolean;
+};
+
+type SalvarParceiroResponse = {
+ data: ParceiroItem;
+};
+
+class FechamentoParceirosService {
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarParceiros(params: ListarParceirosParams): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/parceiros`, {
+ headers,
+ params: {
+ busca: params.busca || undefined,
+ estaAtivo: params.estaAtivo ?? "all",
+ page: params.page,
+ perPage: params.perPage,
+ },
+ });
+ return response.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar parceiros.");
+ }
+ }
+
+ async listarParceirosAtivos(): Promise {
+ const response = await this.listarParceiros({
+ estaAtivo: "true",
+ page: 1,
+ perPage: 100,
+ });
+ return response.data ?? [];
+ }
+
+ async criarParceiro(payload: SalvarParceiroPayload): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(`${baseUrl}/parceiros`, payload, {
+ headers,
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao criar parceiro.");
+ }
+ }
+
+ async editarParceiro(id: string, payload: Partial): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.patch(`${baseUrl}/parceiros/${id}`, payload, {
+ headers,
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao editar parceiro.");
+ }
+ }
+
+ async toggleAtivoParceiro(id: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.patch(
+ `${baseUrl}/parceiros/${id}/toggle-ativo`,
+ {},
+ {
+ headers,
+ },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao atualizar status do parceiro.");
+ }
+ }
+}
+
+export const fechamentoParceirosService = new FechamentoParceirosService();
diff --git a/src/services/fechamento/unidadeContext.ts b/src/services/fechamento/unidadeContext.ts
new file mode 100644
index 0000000..454754b
--- /dev/null
+++ b/src/services/fechamento/unidadeContext.ts
@@ -0,0 +1,106 @@
+import axios from "axios";
+import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
+
+import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
+
+export type UnidadeLookupRow = {
+ id: string;
+ nome: string;
+ estabelecimentoId: string;
+};
+
+const cacheByCodigo = new Map();
+
+type UnidadesListResponse = {
+ data: Array<{
+ id: string;
+ nome: string;
+ estabelecimentoId: string;
+ }>;
+};
+
+async function buildLookupHeaders(): Promise> {
+ const token = await GlobalFunctions.getToken();
+ const apiKey = import.meta.env.VITE_API_KEY || "";
+ return {
+ "Content-Type": "application/json",
+ ...(apiKey ? { apikey: apiKey } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+}
+
+export function clearCommanderUnidadeIdCache(): void {
+ cacheByCodigo.clear();
+}
+
+/** Código de estabelecimento vindo do Transfer (host), ou string vazia. */
+export function getEstabelecimentoCodigoFromTransfer(): string {
+ const raw = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
+ const codigo =
+ typeof raw === "string" ? raw.trim() : raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
+ return codigo;
+}
+
+/**
+ * Consulta `GET /unidades?estabelecimentoId=…` sem lançar; retorna `null` se não houver linha.
+ * Resultado é cacheado por código.
+ */
+export async function lookupUnidadeByEstabelecimento(codigo: string): Promise {
+ const key = codigo.trim();
+ if (!key) {
+ return null;
+ }
+
+ const cached = cacheByCodigo.get(key);
+ if (cached) {
+ return cached;
+ }
+
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildLookupHeaders();
+ const response = await axios.get(`${baseUrl}/unidades`, {
+ headers,
+ params: {
+ estabelecimentoId: key,
+ page: 1,
+ perPage: 10,
+ },
+ });
+
+ const row =
+ response.data.data?.find((u) => u.estabelecimentoId === key) ?? response.data.data?.[0];
+ if (!row?.id) {
+ return null;
+ }
+
+ const mapped: UnidadeLookupRow = {
+ id: row.id,
+ nome: row.nome,
+ estabelecimentoId: row.estabelecimentoId,
+ };
+ cacheByCodigo.set(key, mapped);
+ return mapped;
+}
+
+/**
+ * Resolve o UUID da unidade no Commander a partir do código de estabelecimento
+ * disponibilizado pelo host (mesmo padrão de `audioGeneration.ts` / Codex).
+ * Lança se o código estiver ausente ou não existir unidade cadastrada.
+ */
+export async function resolveCommanderUnidadeId(): Promise {
+ const codigo = getEstabelecimentoCodigoFromTransfer();
+ if (!codigo) {
+ throw new Error(
+ "EstabelecimentoCodigo não disponível (TransferArea). Não foi possível resolver a unidade para o Commander.",
+ );
+ }
+
+ const row = await lookupUnidadeByEstabelecimento(codigo);
+ if (!row) {
+ throw new Error(
+ `Nenhuma unidade encontrada no Commander para o estabelecimento "${codigo}". Cadastre a unidade ou verifique o código.`,
+ );
+ }
+
+ return row.id;
+}
diff --git a/src/services/fechamento/unidades.ts b/src/services/fechamento/unidades.ts
new file mode 100644
index 0000000..d65519c
--- /dev/null
+++ b/src/services/fechamento/unidades.ts
@@ -0,0 +1,79 @@
+import axios from "axios";
+import { GlobalFunctions } from "@/GlobalFunctions";
+
+import { buildCommanderHeaders } from "./commanderHttp";
+import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
+import { clearCommanderUnidadeIdCache, type UnidadeLookupRow } from "./unidadeContext";
+
+async function buildLookupHeaders(): Promise> {
+ const token = await GlobalFunctions.getToken();
+ const apiKey = import.meta.env.VITE_API_KEY || "";
+ return {
+ "Content-Type": "application/json",
+ ...(apiKey ? { apikey: apiKey } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+}
+
+function getUsuarioEmailParaCommander(): string {
+ const email = GlobalFunctions.getUsuarioLogado().email?.trim();
+ if (!email) {
+ throw new Error("E-mail do usuário logado não disponível para cadastro de unidade.");
+ }
+ return email;
+}
+
+export type CriarUnidadeInput = {
+ nome: string;
+ estabelecimentoId: string;
+};
+
+export type AtualizarUnidadeInput = {
+ nome?: string;
+ estabelecimentoId?: string;
+};
+
+type UnidadeResponse = { data: UnidadeLookupRow & { criadoEm: string } };
+
+class FechamentoUnidadesService {
+ /**
+ * `POST /unidades` sem `X-Unidade-Id`. Exige `usuarioEmail` na query (validação admin no Commander).
+ */
+ async createUnidade(input: CriarUnidadeInput): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildLookupHeaders();
+ const usuarioEmail = getUsuarioEmailParaCommander();
+ const response = await axios.post(
+ `${baseUrl}/unidades`,
+ {
+ nome: input.nome.trim(),
+ estabelecimentoId: input.estabelecimentoId.trim(),
+ },
+ {
+ headers,
+ params: { usuarioEmail },
+ },
+ );
+ clearCommanderUnidadeIdCache();
+ return response.data.data;
+ }
+
+ async updateUnidade(
+ unidadeId: string,
+ input: AtualizarUnidadeInput,
+ ): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ const body: Record = {};
+ if (input.nome !== undefined) body.nome = input.nome.trim();
+ if (input.estabelecimentoId !== undefined) body.estabelecimentoId = input.estabelecimentoId.trim();
+
+ const response = await axios.patch(`${baseUrl}/unidades/${unidadeId}`, body, {
+ headers,
+ });
+ clearCommanderUnidadeIdCache();
+ return response.data.data;
+ }
+}
+
+export const fechamentoUnidadesService = new FechamentoUnidadesService();
diff --git a/src/services/fechamento/uploads.ts b/src/services/fechamento/uploads.ts
new file mode 100644
index 0000000..bc70022
--- /dev/null
+++ b/src/services/fechamento/uploads.ts
@@ -0,0 +1,72 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+type PresignParceiroLogoResponse = {
+ data: {
+ uploadUrl: string;
+ publicUrl: string;
+ objectKey: string;
+ expiresIn: number;
+ };
+};
+
+export type PresignParceiroLogoPayload = {
+ fileName: string;
+ contentType: "image/jpeg" | "image/png" | "image/webp";
+};
+
+class FechamentoUploadsService {
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async presignUploadParceiroLogo(payload: PresignParceiroLogoPayload) {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/uploads/parceiros/logo/presign`,
+ payload,
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao preparar upload da logo.");
+ }
+ }
+
+ async uploadFileToSignedUrl(uploadUrl: string, file: File): Promise {
+ try {
+ await axios.put(uploadUrl, file, {
+ headers: {
+ "Content-Type": file.type,
+ },
+ });
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ throw new Error(error.message || "Falha ao enviar arquivo para armazenamento.");
+ }
+ throw new Error("Falha ao enviar arquivo para armazenamento.");
+ }
+ }
+}
+
+export const fechamentoUploadsService = new FechamentoUploadsService();
diff --git a/src/services/fechamento/usuarios.ts b/src/services/fechamento/usuarios.ts
new file mode 100644
index 0000000..5879171
--- /dev/null
+++ b/src/services/fechamento/usuarios.ts
@@ -0,0 +1,132 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type UsuarioPapel = "admin" | "parceiro";
+export type UsuarioStatusFiltro = "all" | "true" | "false";
+
+export type UsuarioItem = {
+ id: string;
+ parceiroId: string | null;
+ nome: string | null;
+ email: string | null;
+ papel: UsuarioPapel;
+ estaAtivo: boolean;
+ criadoEm: string;
+ atualizadoEm: string;
+};
+
+type MetaResponse = {
+ total: number;
+ paginaAtual: number;
+ totalPaginas: number;
+};
+
+type ListarUsuariosResponse = {
+ data: UsuarioItem[];
+ meta: MetaResponse;
+};
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+export type ListarUsuariosParams = {
+ busca?: string;
+ papel?: UsuarioPapel;
+ estaAtivo?: UsuarioStatusFiltro;
+ page: number;
+ perPage: number;
+};
+
+export type SalvarUsuarioPayload = {
+ nome?: string | null;
+ email?: string | null;
+ papel: UsuarioPapel;
+ parceiroId?: string | null;
+ estaAtivo?: boolean;
+};
+
+type SalvarUsuarioResponse = {
+ data: UsuarioItem;
+};
+
+class FechamentoUsuariosService {
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message = ((error.response.data as ApiErrorShape | undefined)?.error?.message ??
+ error.message ??
+ fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarUsuarios(params: ListarUsuariosParams): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/usuarios`, {
+ headers,
+ params: {
+ busca: params.busca || undefined,
+ papel: params.papel || undefined,
+ estaAtivo: params.estaAtivo ?? "all",
+ page: params.page,
+ perPage: params.perPage,
+ },
+ });
+ return response.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar usuários.");
+ }
+ }
+
+ async criarUsuario(payload: SalvarUsuarioPayload): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(`${baseUrl}/usuarios`, payload, { headers });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao criar usuário.");
+ }
+ }
+
+ async editarUsuario(id: string, payload: Partial): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.patch(`${baseUrl}/usuarios/${id}`, payload, {
+ headers,
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao editar usuário.");
+ }
+ }
+
+ async toggleAtivoUsuario(id: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.patch(
+ `${baseUrl}/usuarios/${id}/toggle-ativo`,
+ {},
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao atualizar status do usuário.");
+ }
+ }
+}
+
+export const fechamentoUsuariosService = new FechamentoUsuariosService();
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index ce0ba66..8a58b67 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
+ readonly VITE_API_BASE_URL_COMMANDER: string
readonly VITE_API_KEY: string
readonly VITE_USER_EMAIL: string
readonly VITE_ESTABELECIMENTO_ID: string