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" ? ( +
+
+ + +
+
+ + setUnidadeNome(e.target.value)} + placeholder="Ex.: Unidade Matriz" + /> +
+
+ + setAdminNome(e.target.value)} + placeholder="Ex.: João Silva" + /> +
+
+ + setAdminEmail(e.target.value)} + placeholder="admin@empresa.com" + /> +
+
+ +
+
+ ) : 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). +

+ + + ) : ( +

+ 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"} +

+
+ )} +
+ + + +
+ +
+ + ); + + return ( + <> + + + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + + + + + ); +} 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 ( +
+ +
+
{children}
+
+
+ ); +} 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 ( +
+
+
+ +
+

+ + Fechamentos da Competência +

+ {!loading && ( +
+

+ {fechamentos.length} fechamento(s) +

+ +
+ )} +
+ +
+ {!loading && fechamentos.length === 0 ? ( + + + Nenhum fechamento encontrado + + Importe as tasks do Asana para criar os fechamentos automaticamente. + + +
+ +
+
+ ) : ( +
+ + + + Logo + Nome + Pontuação Total + Horas Total + Status + Ações + + + + {loading ? ( + + + Carregando fechamentos... + + + ) : ( + fechamentos.map((row) => ( + + + {row.parceiroLogoUrl ? ( + {`Logo + ) : ( + "—" + )} + + {getDisplayNome(row)} + {row.pontuacaoTotalEntregue} + {formatHoras(row.horasTotal * 60)} + + + {row.status === "fechado" ? "Fechado" : "Em aberto"} + + + +
+ + +
+
+
+ )) + )} +
+
+
+ )} +
+ + + + + Reprocessar Asana + + Escolha uma estratégia de reprocessamento para esta competência. + + + +
+
+ + + +
+ + {reprocessMode === "reprocessar_alguns" ? ( +
+
+

Selecione os fechamentos/parceiros

+
+ + +
+
+ {fechamentos.map((f) => { + const checked = selectedParceiroIds.includes(f.parceiroId); + return ( + + ); + })} +
+ ) : null} +
+ + + + + +
+
+
+ ); +} 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 ( +
+
+

Configurações do sistema

+

+ Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto + atual do Commander. +

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

+ ) : ( + <> +
+ + +
+
+ + 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 + + )} +
+
+ +
+ + )} +
+
+
+ + + + + + 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} +
+
+ +
+ {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" + /> + +
+ +
+
+ +
+ + +

+ Selecionado: {selectedWorkspaceNome || "—"} +

+
+ +
+ +
+
+
+
+ ); +} 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 ( +
+
+
+ +
+ +
+
+

+ + Detalhes do Fechamento +

+

Revisão operacional de tarefas, pontos e horas.

+
+ + {!loading ? ( +
+ {isFechado ? "Fechado" : "Em aberto"} + + {!isFechado ? ( + + ) : null} + {isFechado ? ( + + ) : ( + + )} +
+ ) : 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)} + +
+ + {tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? ( + + ) : null} +
+
+
+ )) + )} +
+
+
+ )} +
+ + + + + Fazer lançamento + + Adicione uma bonificação ou desconto em pontuação para este fechamento. + + + +
+
+ + +
+ +
+ + setLancamentoPontuacao(e.target.value)} + /> +
+ +
+ + setLancamentoDescricao(e.target.value)} + placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint" + /> +
+
+ + + + + +
+
+ + + + + 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)} +

+
+
+
+ + +
+
+ + {isValorEditado ? ( +
+ +
+ ) : null} + setPontuacaoPagaInput(e.target.value)} + placeholder="Ex.: 10,5" + /> +
+ {requerMotivoAjuste ? ( +
+ + setMotivoAjuste(e.target.value)} + placeholder="Ex.: pagamento parcial acordado com o parceiro" + /> +
+ ) : null} +
+ + + + + +
+
+ + + + + Reabrir fechamento + + Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois. + + +
+ + setMotivoReabertura(e.target.value)} + placeholder="Ex.: ajuste após revisão financeira" + /> +
+ + + + +
+
+ + + + + Excluir lançamento + + Deseja realmente excluir o lançamento manual{" "} + {deletingTarefa?.descricao}? Esta ação não pode ser desfeita. + + + + + + + + + + + + + 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. + + + + + + + + + + + + + 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."} + + +
+ {editingTarefa?.tipo === "tarefa" ? ( + <> +
+ + setEdicaoNumeroTicket(e.target.value)} /> +
+
+ + setEdicaoDescricao(e.target.value)} /> +
+
+ + setEdicaoCliente(e.target.value)} /> +
+
+ + setEdicaoTempoMinutos(e.target.value)} + /> +
+ + ) : null} + {editingTarefa?.tipo !== "tarefa" ? ( +
+ + setEdicaoDescricao(e.target.value)} /> +
+ ) : null} +
+ + setEdicaoPontuacao(e.target.value)} + /> +
+
+ + + + +
+
+
+ ); +} 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 + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+
+ 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"} + + + +
+ +
+
+
+ )) + )} +
+
+
+ )} +
+
+ ); +} 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 +

+ +
+ +
+ setBusca(e.target.value)} + className="w-full md:max-w-sm" + /> + + {hasActiveFilters && ( + + )} +
+ Itens: + +
+
+
+ +
+ {!loadingList && totalRegistros === 0 && !hasActiveFilters ? ( + + + + + Nenhum parceiro cadastrado + + Crie o primeiro parceiro para iniciar o módulo de fechamentos. + + + + + + ) : ( + <> +
+ + + + 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 ? ( + {`Logo + ) : ( + "—" + )} + + {getParceiroNome(parceiro)} + {parceiro.codinome?.trim() || "—"} + {parceiro.email} + {getTipoPessoaLabel(parceiro.tipoPessoa)} + + + {parceiro.estaAtivo ? "Ativo" : "Inativo"} + + + +
+ + +
+
+
+ )) + )} +
+
+
+ + {!loadingList && totalRegistros > 0 && ( +
+

+ Mostrando {totalRegistros} {totalRegistros === 1 ? "parceiro" : "parceiros"} +

+
+ +
+ {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 ( + + ); + })} +
+ +
+
+ )} + + )} +
+ + { + setIsFormOpen(open); + if (!open) { + resetFormState(); + } + }} + > + + + {formTitle} + Preencha os dados para salvar o parceiro. + + +
+ + +

{logoStatusLabel}

+ + +
+ +
+
+ + setForm((prev) => ({ ...prev, nome: e.target.value }))} + placeholder="Razão social ou nome completo" + /> +
+ +
+ + setForm((prev) => ({ ...prev, codinome: e.target.value }))} + placeholder="Nome curto para exibição" + /> +
+ +
+ + +
+ + {isPessoaFisica ? ( +
+ + setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))} + placeholder="000.000.000-00" + maxLength={14} + /> +
+ ) : ( +
+ + setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))} + placeholder="00.000.000/0000-00" + maxLength={18} + /> +
+ )} + +
+ + setForm((prev) => ({ ...prev, email: e.target.value }))} + placeholder="contato@empresa.com" + /> +
+ +
+ + setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))} + placeholder="11 99999-9999" + maxLength={13} + /> +
+ +
+ + +

+ {loadingAsanaUsers ? ( + + + Buscando usuários do workspace... + + ) : hasAsanaWorkspace ? ( + `Workspace: ${asanaWorkspaceNome || asanaWorkspaceId}` + ) : ( + "Defina token/workspace do Asana na tela Configurações para habilitar este campo." + )} +

+
+ +
+ + setForm((prev) => ({ ...prev, pontuacaoMeta: e.target.value }))} + placeholder="0" + /> +
+ +
+ +