atualizacoes modulo fechamento

This commit is contained in:
Vitex Tecnologia
2026-04-26 22:54:56 -03:00
parent 7a01c6f97b
commit 6da067c641
29 changed files with 5973 additions and 3 deletions
+3 -3
View File
@@ -2,14 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 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 { ThemeProvider } from "next-themes";
import Index from "./pages/Index"; import Index from "./pages/Index";
import NotFound from "./pages/NotFound"; import NotFound from "./pages/NotFound";
import Redirect from "./pages/Redirect"; import Redirect from "./pages/Redirect";
import { GlobalFunctions } from "./GlobalFunctions";
import React from "react"; import React from "react";
import IntelligenceIAApp from "./modules/intelligence-ia/App"; import IntelligenceIAApp from "./modules/intelligence-ia/App";
import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -33,8 +33,8 @@ const App = () => (
element={<Redirect />} element={<Redirect />}
/> />
{/* Módulo Intelligence IA como subpath*/}
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} /> <Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
<Route path="/fechamento-hgtx/*" element={<FechamentoHgtxApp />} />
<Route path="/*" element={<Index />} /> <Route path="/*" element={<Index />} />
+163
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Validando acesso...</p>
</div>
</div>
);
}
if (!hasAccess) {
return <NoAccessScreen reason={reason} errorMessage={errorMessage} />;
}
return <>{children}</>;
}
+125
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-xl">
<CardHeader className="text-center">
<div className="mb-3 flex justify-center">
<ShieldX className="h-10 w-10 text-destructive" />
</div>
<CardTitle>Você não tem acesso a este módulo</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-center">
<p className="text-muted-foreground">{getMessage(reason)}</p>
{reason === "bootstrap" ? (
<div className="space-y-4 rounded-md border p-4 text-left">
<div className="space-y-1">
<Label>Código do estabelecimento</Label>
<Input value={estabelecimentoId} readOnly className="font-mono bg-muted" />
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
<Input
id="bootstrap-unidade"
value={unidadeNome}
onChange={(e) => setUnidadeNome(e.target.value)}
placeholder="Ex.: Unidade Matriz"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-nome">Nome do primeiro admin</Label>
<Input
id="bootstrap-admin-nome"
value={adminNome}
onChange={(e) => setAdminNome(e.target.value)}
placeholder="Ex.: João Silva"
/>
</div>
<div className="space-y-1">
<Label htmlFor="bootstrap-admin-email">E-mail do primeiro admin</Label>
<Input
id="bootstrap-admin-email"
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="admin@empresa.com"
/>
</div>
<div className="flex justify-end">
<Button type="button" onClick={handleBootstrap} disabled={saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Inicializando...
</>
) : (
"Inicializar ambiente"
)}
</Button>
</div>
</div>
) : null}
{reason !== "bootstrap" && errorMessage ? (
<p className="text-sm text-muted-foreground">{errorMessage}</p>
) : null}
</CardContent>
</Card>
</div>
);
}
+175
View File
@@ -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<GatePhase>("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 (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Verificando unidade do estabelecimento...</p>
</div>
</div>
);
}
if (phase === "ready") {
return <>{children}</>;
}
if (phase === "unidade_nao_cadastrada" && isAdmin && onConfiguracoes) {
return <>{children}</>;
}
if (phase === "sem_codigo_transfer") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<MapPinOff className="h-10 w-10 text-muted-foreground" />
</div>
<CardTitle>Estabelecimento não identificado</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
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.
</p>
</CardContent>
</Card>
</div>
);
}
if (phase === "unidade_nao_cadastrada") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<div className="mb-2 flex justify-center">
<Building2 className="h-10 w-10 text-primary" />
</div>
<CardTitle>Unidade ainda não cadastrada</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
Não existe unidade no Commander para o estabelecimento{" "}
<span className="font-mono text-foreground">{codigoTransfer || "—"}</span>.
</p>
{isAdmin ? (
<>
<p className="text-sm text-muted-foreground">
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
</p>
<Button asChild>
<Link to="/fechamento-hgtx/configuracoes#unidade">Ir para Configurações</Link>
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">
Peça a um administrador para cadastrar a unidade deste estabelecimento no Commander. Informe o
código: <span className="font-mono text-foreground">{codigoTransfer}</span>
</p>
)}
</CardContent>
</Card>
</div>
);
}
if (phase === "usuario_outra_unidade") {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle>Estabelecimento diferente do seu cadastro</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-muted-foreground">
<p>
Você está vinculado a outra unidade no Commander do que o estabelecimento aberto neste contexto
(código <span className="font-mono text-foreground">{codigoTransfer}</span>).
</p>
<p className="text-sm">
Abra o módulo com o estabelecimento correspondente ao seu usuário ou solicite ajuste ao
administrador.
</p>
</CardContent>
</Card>
</div>
);
}
return <>{children}</>;
}
+60
View File
@@ -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<AuthAccessState>) => void;
resetAuthState: () => void;
};
const initialState: AuthAccessState = {
loading: true,
isAuthenticated: false,
hasAccess: false,
papel: null,
me: null,
reason: null,
errorMessage: null,
};
const AuthAccessContext = createContext<AuthAccessContextValue | undefined>(undefined);
export function AuthAccessProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthAccessState>(initialState);
const setAuthState = useCallback((partial: Partial<AuthAccessState>) => {
setState((prev) => ({ ...prev, ...partial }));
}, []);
const resetAuthState = useCallback(() => {
setState(initialState);
}, []);
const value = useMemo<AuthAccessContextValue>(
() => ({
...state,
setAuthState,
resetAuthState,
}),
[state, setAuthState, resetAuthState],
);
return <AuthAccessContext.Provider value={value}>{children}</AuthAccessContext.Provider>;
}
export function useAuthAccess() {
const ctx = useContext(AuthAccessContext);
if (!ctx) {
throw new Error("useAuthAccess deve ser usado dentro de AuthAccessProvider.");
}
return ctx;
}
+69
View File
@@ -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 <Navigate to="/fechamento-hgtx" replace />;
}
const FechamentoHgtxApp = () => {
return (
<AuthAccessProvider>
<AuthGate>
<UnidadeGate>
<MainLayout>
<Routes>
<Route index element={<Fechamentos />} />
<Route path="competencias/:id" element={<CompetenciaFechamentos />} />
<Route path="fechamentos/:id" element={<FechamentoDetalhes />} />
<Route path="banco-pontos" element={<BancoPontos />} />
<Route
path="parceiros"
element={
<RequireAdminRoute>
<Parceiros />
</RequireAdminRoute>
}
/>
<Route
path="usuarios"
element={
<RequireAdminRoute>
<Usuarios />
</RequireAdminRoute>
}
/>
<Route
path="configuracoes"
element={
<RequireAdminRoute>
<Configuracoes />
</RequireAdminRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</MainLayout>
</UnidadeGate>
</AuthGate>
</AuthAccessProvider>
);
};
export default FechamentoHgtxApp;
@@ -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 = () => (
<>
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
<ClipboardList className="h-5 w-5 text-primary" />
</div>
{!collapsed && (
<div className="animate-fade-in">
<h1 className="text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
<p className="text-xs text-muted-foreground">
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
</p>
</div>
)}
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{allowedNavItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === ""}
onClick={() => 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",
)
}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
</NavLink>
))}
</nav>
<div className="hidden border-t border-sidebar-border px-3 py-4 lg:block">
<button
onClick={() => 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"
>
<ChevronLeft
className={cn(
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
collapsed && "rotate-180",
)}
/>
{!collapsed && <span>Recolher</span>}
</button>
</div>
</>
);
return (
<>
<button
onClick={() => setMobileOpen(true)}
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
<aside
className={cn(
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-300 lg:hidden",
mobileOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
onClick={() => setMobileOpen(false)}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
>
<X className="h-5 w-5" />
</button>
<SidebarContent />
</aside>
<aside
className={cn(
"sticky top-0 hidden h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-300 lg:flex",
collapsed ? "w-[72px]" : "w-64",
)}
>
<SidebarContent />
</aside>
</>
);
}
@@ -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 (
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<main className="flex-1 overflow-auto">
<div className="p-4 pt-16 lg:p-8 lg:pt-8">{children}</div>
</main>
</div>
);
}
@@ -0,0 +1,23 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function BancoPontos() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Banco de Pontos</h1>
<p className="text-muted-foreground">
Consulte saldos por parceiro e extrato de créditos/débitos.
</p>
</div>
<Card className="metric-card">
<CardHeader>
<CardTitle>Estrutura inicial pronta</CardTitle>
</CardHeader>
<CardContent>
Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
</CardContent>
</Card>
</div>
);
}
@@ -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<FechamentoDaCompetenciaItem[]>([]);
const [importing, setImporting] = useState(false);
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
const [reprocessMode, setReprocessMode] = useState<
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
>("reprocessar_tudo");
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
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 (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento-hgtx")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos da Competência
</h1>
{!loading && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">
{fechamentos.length} fechamento(s)
</p>
<Button
size="sm"
variant="secondary"
onClick={() => setIsReprocessModalOpen(true)}
disabled={importing}
>
<RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && fechamentos.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl">
<CardHeader>
<CardTitle>Nenhum fechamento encontrado</CardTitle>
<CardDescription>
Importe as tasks do Asana para criar os fechamentos automaticamente.
</CardDescription>
</CardHeader>
<div className="px-6 pb-6">
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" />
{importing ? "Importando..." : "Importar tasks do Asana"}
</Button>
</div>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[100px]">Logo</TableHead>
<TableHead className="min-w-[280px]">Nome</TableHead>
<TableHead className="min-w-[140px]">Pontuação Total</TableHead>
<TableHead className="min-w-[120px]">Horas Total</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
Carregando fechamentos...
</TableCell>
</TableRow>
) : (
fechamentos.map((row) => (
<TableRow key={row.id}>
<TableCell>
{row.parceiroLogoUrl ? (
<img
src={row.parceiroLogoUrl}
alt={`Logo ${row.parceiroNome}`}
className="h-8 w-8 rounded-md object-cover"
/>
) : (
"—"
)}
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell>{row.pontuacaoTotalEntregue}</TableCell>
<TableCell>{formatHoras(row.horasTotal * 60)}</TableCell>
<TableCell>
<Badge
className={
row.status === "fechado"
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
}
>
{row.status === "fechado" ? "Fechado" : "Em aberto"}
</Badge>
</TableCell>
<TableCell>
<div className="flex justify-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleExportar(row.id)}
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
title="Exportar planilha financeira (XLSX)"
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
{exportingFechamentoId === row.id ? "Exportando..." : "Exportar"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
state: { competenciaId, status: row.status },
})
}
>
<ExternalLink className="mr-2 h-4 w-4" />
Acessar
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Reprocessar Asana</DialogTitle>
<DialogDescription>
Escolha uma estratégia de reprocessamento para esta competência.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<button
type="button"
onClick={() => 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"
}`}
>
<p className="text-sm font-semibold">Reprocessar tudo</p>
<p className="mt-1 text-xs text-muted-foreground">
Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
</p>
</button>
<button
type="button"
onClick={() => 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"
}`}
>
<p className="text-sm font-semibold">Reprocessar apenas alguns</p>
<p className="mt-1 text-xs text-muted-foreground">
Reprocessa somente os parceiros selecionados abaixo.
</p>
</button>
<button
type="button"
onClick={() => 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"
}`}
>
<p className="text-sm font-semibold">Buscar novos fechamentos</p>
<p className="mt-1 text-xs text-muted-foreground">
Busca no Asana e cria apenas os fechamentos que ainda não existem.
</p>
</button>
</div>
{reprocessMode === "reprocessar_alguns" ? (
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">Selecione os fechamentos/parceiros</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
>
Marcar todos
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSelectedParceiroIds([])}
>
Limpar
</Button>
</div>
</div>
{fechamentos.map((f) => {
const checked = selectedParceiroIds.includes(f.parceiroId);
return (
<label
key={f.id}
htmlFor={`sel-${f.id}`}
className={`flex cursor-pointer items-center space-x-3 rounded-md border p-2 transition ${
checked ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
>
<Checkbox
id={`sel-${f.id}`}
checked={checked}
onCheckedChange={(value) => {
const on = Boolean(value);
setSelectedParceiroIds((prev) =>
on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
);
}}
/>
<Label htmlFor={`sel-${f.id}`} className="cursor-pointer text-sm">
{getDisplayNome(f)}
<span className="ml-2 text-xs text-muted-foreground">
{f.status === "fechado" ? "Fechado" : "Em aberto"}
</span>
</Label>
</label>
);
})}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
Cancelar
</Button>
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
{importing ? "Processando..." : "Executar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
const [showToken, setShowToken] = useState(false);
const [configAtual, setConfigAtual] = useState<ConfiguracaoPublica | null>(null);
const [asanaToken, setAsanaToken] = useState("");
const [workspaces, setWorkspaces] = useState<AsanaWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(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 (
<div className="flex min-h-[300px] items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Carregando configurações...</span>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-5xl space-y-8 pb-8">
<header className="border-b border-border pb-6">
<h1 className="text-xl font-semibold tracking-tight text-foreground md:text-2xl">Configurações do sistema</h1>
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto
atual do Commander.
</p>
</header>
<div ref={unidadeSectionRef} id="unidade">
<Card className="overflow-hidden border-border shadow-sm">
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Unidade</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
estabelecimento é somente leitura.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{loadingUnidade ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Carregando dados da unidade...
</div>
) : !isAdmin ? (
<p className="text-sm text-muted-foreground">
Apenas administradores podem cadastrar ou editar a unidade aqui.
</p>
) : !codigoEstabelecimento ? (
<p className="text-sm text-muted-foreground">
Abra o módulo pelo Codex com o estabelecimento no transfer para exibir o código e cadastrar a
unidade.
</p>
) : (
<>
<div className="space-y-2">
<Label>Código do estabelecimento (transfer)</Label>
<Input value={codigoEstabelecimento} readOnly className="h-11 font-mono text-sm bg-muted" />
</div>
<div className="space-y-2">
<Label htmlFor="nome-unidade">Nome da unidade</Label>
<Input
id="nome-unidade"
value={nomeUnidade}
onChange={(e) => setNomeUnidade(e.target.value)}
placeholder="Ex.: Unidade Matriz"
className="h-11 text-sm"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{unidadeExistente ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Unidade cadastrada
</Badge>
) : (
<Badge variant="outline" className="max-w-full shrink-0 whitespace-normal sm:whitespace-nowrap">
Pendente: informe o nome e salve para criar a unidade
</Badge>
)}
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button type="button" onClick={handleSalvarUnidade} disabled={savingUnidade}>
{savingUnidade ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border shadow-sm">
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 px-6 py-6">
{configAsanaError ? (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive"
>
<p className="font-medium">Não foi possível carregar as configurações do Asana</p>
<p className="mt-1 leading-relaxed opacity-90">
{configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
</p>
</div>
) : null}
<div className="space-y-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<Label htmlFor="asana-token" className="text-sm font-medium">
Token de acesso
</Label>
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
{tokenJaConfigurado ? (
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
Token configurado no servidor
</Badge>
) : (
<Badge variant="outline" className="shrink-0 whitespace-nowrap">
Token ainda não salvo
</Badge>
)}
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
<div className="relative min-w-0 flex-1">
<Input
id="asana-token"
type={showToken ? "text" : "password"}
value={asanaToken}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => 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 ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
type="button"
variant="secondary"
className="h-11 shrink-0 whitespace-nowrap px-5 lg:self-stretch"
onClick={handleBuscarWorkspaces}
disabled={!podeBuscarWorkspaces || Boolean(configAsanaError)}
>
{loadingWorkspaces ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Buscando
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Listar workspaces
</>
)}
</Button>
</div>
</div>
<div className="space-y-3">
<Label htmlFor="asana-workspace" className="text-sm font-medium">
Workspace padrão
</Label>
<Select
value={selectedWorkspaceId}
onValueChange={(value) => {
setSelectedWorkspaceId(value);
const selected = workspaceOptions.find((item) => item.id === value);
setSelectedWorkspaceNome(selected?.name ?? "");
}}
disabled={Boolean(configAsanaError)}
>
<SelectTrigger id="asana-workspace" className="h-11 w-full">
<SelectValue
placeholder={
workspaceOptions.length > 0
? "Selecione um workspace"
: "Informe o token e clique em Listar workspaces"
}
/>
</SelectTrigger>
<SelectContent>
{workspaceOptions.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-relaxed text-muted-foreground">
Selecionado: <span className="font-medium text-foreground">{selectedWorkspaceNome || "—"}</span>
</p>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
size="lg"
className="min-w-[200px]"
onClick={handleSaveAsana}
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError)}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar integração Asana
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -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<FechamentoTarefaItem[]>([]);
const [togglingTaskId, setTogglingTaskId] = useState<string | null>(null);
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(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<number | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(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<FechamentoTarefaItem | null>(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 (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div className="mb-2">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<ListChecks className="h-5 w-5 md:h-6 md:w-6" />
Detalhes do Fechamento
</h1>
<p className="mt-1 text-sm text-muted-foreground">Revisão operacional de tarefas, pontos e horas.</p>
</div>
{!loading ? (
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Badge variant={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
<Button
size="sm"
variant="outline"
onClick={() => setIsLancamentoOpen(true)}
disabled={isFechado}
className="min-w-[152px]"
>
<Plus className="mr-2 h-4 w-4" />
Fazer lançamento
</Button>
{!isFechado ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReprocessAsanaOpen(true)}
disabled={reprocessandoAsana}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
) : null}
{isFechado ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirOpen(true)}
disabled={reabrindo}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reabrir fechamento
</Button>
) : (
<Button
size="sm"
variant="secondary"
onClick={handleAbrirConcluir}
disabled={tarefas.length === 0 || concluindo}
className="min-w-[152px]"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Concluir fechamento
</Button>
)}
</div>
) : null}
</div>
{!loading ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
<CardHeader className="space-y-2 p-4">
<div className="flex items-start justify-between">
<CardDescription className="text-[11px] uppercase tracking-wide">Total de tarefas</CardDescription>
<ListChecks className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{tarefas.length}</CardTitle>
</CardHeader>
</Card>
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
<CardHeader className="space-y-2 p-4">
<div className="flex items-start justify-between">
<CardDescription className="text-[11px] uppercase tracking-wide">Tarefas aprovadas</CardDescription>
<Target className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
</CardHeader>
</Card>
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
<CardHeader className="space-y-2 p-4">
<div className="flex items-start justify-between">
<CardDescription className="text-[11px] uppercase tracking-wide">Pontuação aprovada</CardDescription>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
</CardHeader>
</Card>
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
<CardHeader className="space-y-2 p-4">
<div className="flex items-start justify-between">
<CardDescription className="text-[11px] uppercase tracking-wide">Horas aprovadas</CardDescription>
<Clock3 className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{totais.horas}</CardTitle>
</CardHeader>
</Card>
</div>
) : null}
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && tarefas.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl">
<CardHeader>
<CardTitle>Nenhuma tarefa encontrada</CardTitle>
<CardDescription>Este fechamento não possui tarefas cadastradas.</CardDescription>
</CardHeader>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">Aprovada</TableHead>
<TableHead className="min-w-[120px]">Ticket</TableHead>
<TableHead className="min-w-[320px]">Descrição</TableHead>
<TableHead className="min-w-[180px]">Cliente</TableHead>
<TableHead className="min-w-[110px]">Tipo</TableHead>
<TableHead className="min-w-[120px]">Horas</TableHead>
<TableHead className="min-w-[120px]">Pontuação</TableHead>
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
Carregando tarefas...
</TableCell>
</TableRow>
) : (
tarefas.map((tarefa) => (
<TableRow key={tarefa.id}>
<TableCell>
<div className="flex items-center gap-2">
<Checkbox
checked={tarefa.estaRevisada}
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
disabled={togglingTaskId === tarefa.id || isFechado}
/>
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</div>
</TableCell>
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
<TableCell>{tarefa.cliente || "—"}</TableCell>
<TableCell>{tarefa.tipo}</TableCell>
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => openEditarTarefa(tarefa)}
disabled={isFechado}
>
<Pencil className="h-4 w-4" />
<span className="ml-1">Editar</span>
</Button>
{tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? (
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => void handleExcluirLancamento(tarefa)}
disabled={isFechado || deletingTaskId === tarefa.id}
>
{deletingTaskId === tarefa.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="ml-1">Excluir</span>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Fazer lançamento</DialogTitle>
<DialogDescription>
Adicione uma bonificação ou desconto em pontuação para este fechamento.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lancamento-tipo">Tipo de lançamento</Label>
<Select
value={lancamentoTipo}
onValueChange={(value) => setLancamentoTipo(value as "bonus" | "desconto")}
>
<SelectTrigger id="lancamento-tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonus">Bonificação</SelectItem>
<SelectItem value="desconto">Desconto</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
<Input
id="lancamento-pontuacao"
type="number"
min={1}
step={1}
value={lancamentoPontuacao}
onChange={(e) => setLancamentoPontuacao(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lancamento-descricao">Descrição</Label>
<Input
id="lancamento-descricao"
value={lancamentoDescricao}
onChange={(e) => setLancamentoDescricao(e.target.value)}
placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsLancamentoOpen(false);
resetLancamentoForm();
}}
disabled={savingLancamento}
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Concluir fechamento</DialogTitle>
<DialogDescription>
Revise os totais e confirme a pontuação paga para concluir este fechamento.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Aprovada</p>
<p className="text-2xl font-bold">{formatPontos(totais.pontos)}</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Meta</p>
<p className="text-2xl font-bold">{formatPontos(Number(pontuacaoMeta ?? 0))}</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Diferença</p>
<p className={`text-2xl font-bold ${diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{formatPontos(diferencaParaMeta)}
</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Banco de Pontos</p>
<p className={`text-2xl font-bold ${bancoCalculado >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{formatPontos(bancoCalculado)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Pagar total aprovado
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
>
Pagar meta
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="pontuacao-paga">Pontuação paga</Label>
{isValorEditado ? (
<div>
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 transition hover:text-foreground"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Usar valor total
</button>
</div>
) : null}
<Input
id="pontuacao-paga"
type="text"
inputMode="decimal"
value={pontuacaoPagaInput}
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
placeholder="Ex.: 10,5"
/>
</div>
{requerMotivoAjuste ? (
<div className="space-y-2">
<Label htmlFor="motivo-ajuste">Motivo do ajuste *</Label>
<Input
id="motivo-ajuste"
value={motivoAjuste}
onChange={(e) => setMotivoAjuste(e.target.value)}
placeholder="Ex.: pagamento parcial acordado com o parceiro"
/>
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
Cancelar
</Button>
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReabrirOpen} onOpenChange={setIsReabrirOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Reabrir fechamento</DialogTitle>
<DialogDescription>
Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabertura">Motivo (opcional)</Label>
<Input
id="motivo-reabertura"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: ajuste após revisão financeira"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
Cancelar
</Button>
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
{reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Excluir lançamento</DialogTitle>
<DialogDescription>
Deseja realmente excluir o lançamento manual{" "}
<strong>{deletingTarefa?.descricao}</strong>? Esta ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setDeletingTarefa(null);
}}
disabled={deletingTaskId !== null}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void confirmDeleteLancamento()}
disabled={deletingTaskId !== null}
>
{deletingTaskId !== null ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReprocessAsanaOpen} onOpenChange={setIsReprocessAsanaOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reprocessar Asana deste fechamento</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsReprocessAsanaOpen(false)}
disabled={reprocessandoAsana}
>
Cancelar
</Button>
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isEditarOpen} onOpenChange={setIsEditarOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Editar tarefa</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{editingTarefa?.tipo === "tarefa" ? (
<>
<div className="space-y-1">
<Label htmlFor="edit-ticket">Ticket</Label>
<Input id="edit-ticket" value={edicaoNumeroTicket} onChange={(e) => setEdicaoNumeroTicket(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-descricao">Descrição</Label>
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-cliente">Cliente</Label>
<Input id="edit-cliente" value={edicaoCliente} onChange={(e) => setEdicaoCliente(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-tempo">Horas (minutos)</Label>
<Input
id="edit-tempo"
type="number"
min={0}
step={1}
value={edicaoTempoMinutos}
onChange={(e) => setEdicaoTempoMinutos(e.target.value)}
/>
</div>
</>
) : null}
{editingTarefa?.tipo !== "tarefa" ? (
<div className="space-y-1">
<Label htmlFor="edit-descricao">Descrição</Label>
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
</div>
) : null}
<div className="space-y-1">
<Label htmlFor="edit-pontuacao">Pontuação</Label>
<Input
id="edit-pontuacao"
type="text"
inputMode="decimal"
value={edicaoPontuacao}
onChange={(e) => setEdicaoPontuacao(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
Cancelar
</Button>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
{savingEdicao ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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<CompetenciaItem[]>([]);
const [loading, setLoading] = useState(true);
const [anoSelecionado, setAnoSelecionado] = useState<string>("");
const [modalAberto, setModalAberto] = useState(false);
const [mesSelecionado, setMesSelecionado] = useState<string>("");
const [anoNovo, setAnoNovo] = useState<string>("");
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 (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<ClipboardList className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos
</h1>
<Dialog open={modalAberto} onOpenChange={setModalAberto}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Nova Competência
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova Competência</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Mês</label>
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
<SelectTrigger>
<SelectValue placeholder="Selecione o mês" />
</SelectTrigger>
<SelectContent>
{meses.map ((m, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Ano</label>
<Select value={anoNovo} onValueChange={setAnoNovo}>
<SelectTrigger>
<SelectValue placeholder="Selecione o ano" />
</SelectTrigger>
<SelectContent>
{anosDisponiveis.map((ano) => (
<SelectItem key={ano} value={ano}>
{ano}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setModalAberto(false)} disabled={criando}>
Cancelar
</Button>
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
{criando ? "Criando..." : "Criar Competência"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Ano:</span>
<Select value={anoSelecionado} onValueChange={setAnoSelecionado}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{anosDisponiveis.map((ano) => (
<SelectItem key={ano} value={ano}>
{ano}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && competenciasFiltradas.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl">
<CardHeader>
<CardTitle>Nenhuma competência encontrada</CardTitle>
<CardDescription>Não competências cadastradas para o ano selecionado.</CardDescription>
</CardHeader>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[220px]">Mês</TableHead>
<TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Acessar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground">
Carregando competências...
</TableCell>
</TableRow>
) : (
competenciasFiltradas.map((competencia) => (
<TableRow key={competencia.id}>
<TableCell className="font-medium">
{formatCompetenciaMes(competencia.mes, competencia.ano)}
</TableCell>
<TableCell>{competencia.quantidadeFechamentos}</TableCell>
<TableCell>
<Badge
className={
competencia.status === "concluido"
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
}
>
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
</Badge>
</TableCell>
<TableCell>
<div className="flex justify-center">
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
>
<ExternalLink className="mr-2 h-4 w-4" />
Acessar
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
}
@@ -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 (
<div className="flex min-h-[70vh] items-center justify-center">
<div className="text-center">
<h1 className="mb-3 text-4xl font-bold">404</h1>
<p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
<Link to="/fechamento-hgtx" className="text-primary underline hover:text-primary/90">
Voltar para Fechamentos
</Link>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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<UsuarioItem[]>([]);
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
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<UsuarioItem | null>(null);
const [form, setForm] = useState<UsuarioForm>(formInicial);
const [busca, setBusca] = useState("");
const [filtroPapel, setFiltroPapel] = useState<"all" | UsuarioPapel>("all");
const [filtroStatus, setFiltroStatus] = useState<UsuarioStatusFiltro>("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 (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<Users className="h-5 w-5 md:h-6 md:w-6" />
Usuários
</h1>
<Button onClick={openCreateDialog} className="gap-2">
<Plus className="h-4 w-4" />
Novo usuário
</Button>
</div>
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4">
<Input
placeholder="Buscar por nome ou e-mail..."
value={busca}
onChange={(e) => setBusca(e.target.value)}
className="w-full md:max-w-sm"
/>
<Select
value={filtroPapel}
onValueChange={(value) => setFiltroPapel(value as "all" | UsuarioPapel)}
>
<SelectTrigger className="w-full md:w-[170px]">
<SelectValue placeholder="Perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os perfis</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent>
</Select>
<Select value={filtroStatus} onValueChange={(value) => setFiltroStatus(value as UsuarioStatusFiltro)}>
<SelectTrigger className="w-full md:w-[170px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="true">Ativos</SelectItem>
<SelectItem value="false">Inativos</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Limpar
</Button>
)}
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-sm text-muted-foreground">Itens:</span>
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
<Card className="mx-auto mt-12 max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Nenhum usuário cadastrado
</CardTitle>
<CardDescription>
Crie o primeiro usuário para iniciar o gerenciamento de acessos.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Novo usuário
</Button>
</CardContent>
</Card>
) : (
<>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[220px]">Nome</TableHead>
<TableHead className="min-w-[220px]">E-mail</TableHead>
<TableHead className="min-w-[120px]">Perfil</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
Nenhum usuário encontrado para os filtros atuais.
</TableCell>
</TableRow>
) : (
usuarios.map((usuario) => (
<TableRow key={usuario.id}>
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
<TableCell>{getEmailUsuario(usuario)}</TableCell>
<TableCell>
<Badge variant={usuario.papel === "admin" ? "default" : "secondary"}>
{usuario.papel === "admin" ? "Admin" : "Parceiro"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={usuario.estaAtivo ? "secondary" : "outline"}>
{usuario.estaAtivo ? "Ativo" : "Inativo"}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(usuario)}
title="Editar usuário"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openToggleDialog(usuario)}
title={usuario.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
>
<Power className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{!loadingList && totalRegistros > 0 && (
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center">
<p className="text-center text-sm text-muted-foreground sm:text-left">
Mostrando {totalRegistros} {totalRegistros === 1 ? "usuário" : "usuários"}
</p>
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Anterior
</Button>
<div className="flex items-center gap-1">
{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 (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className="h-9 w-10 p-0"
>
{page}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Próxima
</Button>
</div>
</div>
)}
</>
)}
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo usuário</DialogTitle>
<DialogDescription>Preencha os dados para criar um novo usuário.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="create-nome">Nome</Label>
<Input
id="create-nome"
value={form.nome}
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
placeholder="Nome completo"
/>
</div>
<div>
<Label htmlFor="create-email">E-mail</Label>
<Input
id="create-email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="usuario@empresa.com"
/>
</div>
<div>
<Label>Perfil</Label>
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
</Label>
<Select
value={form.parceiroId || parceiroSemVinculoValue}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
parceiroId: value === parceiroSemVinculoValue ? "" : value,
}))
}
disabled={loadingParceiros}
>
<SelectTrigger>
<SelectValue
placeholder={
parceiroObrigatorio
? "Selecione um parceiro"
: "Opcional para admin, obrigatório para parceiro"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
Sem parceiro
</SelectItem>
{parceiros.map((parceiro) => (
<SelectItem key={parceiro.id} value={parceiro.id}>
{parceiro.codinome?.trim()
? `${parceiro.nome} (${parceiro.codinome.trim()})`
: parceiro.nome}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
Cada parceiro pode estar vinculado a apenas um usuário.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={saving}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving ? "Criando..." : "Criar usuário"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Editar usuário</DialogTitle>
<DialogDescription>Atualize os dados do usuário selecionado.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label htmlFor="edit-nome">Nome</Label>
<Input
id="edit-nome"
value={form.nome}
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
placeholder="Nome completo"
/>
</div>
<div>
<Label htmlFor="edit-email">E-mail</Label>
<Input
id="edit-email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="usuario@empresa.com"
/>
</div>
<div>
<Label>Perfil</Label>
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
</Label>
<Select
value={form.parceiroId || parceiroSemVinculoValue}
onValueChange={(value) =>
setForm((prev) => ({
...prev,
parceiroId: value === parceiroSemVinculoValue ? "" : value,
}))
}
disabled={loadingParceiros}
>
<SelectTrigger>
<SelectValue
placeholder={
parceiroObrigatorio
? "Selecione um parceiro"
: "Opcional para admin, obrigatório para parceiro"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
Sem parceiro
</SelectItem>
{parceiros.map((parceiro) => (
<SelectItem key={parceiro.id} value={parceiro.id}>
{parceiro.codinome?.trim()
? `${parceiro.nome} (${parceiro.codinome.trim()})`
: parceiro.nome}
</SelectItem>
))}
</SelectContent>
</Select>
{parceiroSelecionado && (
<p className="mt-1 text-xs text-muted-foreground">
Vinculado: {parceiroSelecionado.codinome?.trim() || parceiroSelecionado.nome}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
Cada parceiro pode estar vinculado a apenas um usuário.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={saving}>
Cancelar
</Button>
<Button onClick={handleEdit} disabled={saving}>
{saving ? "Salvando..." : "Salvar alterações"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={isToggleOpen} onOpenChange={setIsToggleOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{selectedUsuario?.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
</AlertDialogTitle>
<AlertDialogDescription>
{selectedUsuario?.estaAtivo ? (
<>
Deseja inativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
</>
) : (
<>
Deseja reativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={toggling}>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
{toggling ? (
"Processando..."
) : selectedUsuario?.estaAtivo ? (
<span className="inline-flex items-center gap-2">
<UserX className="h-4 w-4" />
Inativar
</span>
) : (
<span className="inline-flex items-center gap-2">
<UserCheck className="h-4 w-4" />
Reativar
</span>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -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<AsanaWorkspaceUser[]> {
if (!workspaceId.trim()) {
return [];
}
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<AsanaWorkspaceUsersResponse>(
`${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();
+123
View File
@@ -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<MeData | null> {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
try {
const response = await axios.get<MeResponse>(`${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<BootstrapStatusData> {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
const response = await axios.get<BootstrapStatusResponse>(`${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<BootstrapInitResponse>(`${baseUrl}/bootstrap`, input, {
headers,
});
return response.data.data;
}
}
export const authMeService = new AuthMeService();
+27
View File
@@ -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<Record<string, string>> {
const token = await GlobalFunctions.getToken();
const apiKey = import.meta.env.VITE_API_KEY || "";
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(apiKey ? { apikey: apiKey } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
if (!opts?.omitUnidadeId) {
headers["X-Unidade-Id"] = await resolveCommanderUnidadeId();
}
return headers;
}
+159
View File
@@ -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<CompetenciaItem[]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ListarCompetenciasResponse>(`${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<FechamentoDaCompetenciaItem[]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ListarFechamentosDaCompetenciaResponse>(
`${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<CompetenciaItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<CriarCompetenciaResponse>(
`${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<ImportarCompetenciaResponse>(
`${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();
+83
View File
@@ -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<ConfiguracaoPublica | null> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<GetConfiguracoesResponse>(`${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<ConfiguracaoPublica> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<SalvarConfiguracoesResponse>(
`${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();
@@ -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;
}
+288
View File
@@ -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<FechamentoTarefaItem[]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ListarTarefasResponse>(`${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<FechamentoTarefaItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.patch<PatchTarefaResponse>(
`${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<FechamentoTarefaItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<CriarLancamentoResponse>(
`${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<FechamentoTarefaItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.delete<PatchTarefaResponse>(
`${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<FechamentoFechadoResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<ConcluirFechamentoResponse>(
`${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<ExportarPlanilhaResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ArrayBuffer>(`${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<FechamentoReabertoResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<ReabrirFechamentoResponse>(
`${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<ReprocessarAsanaResponse["data"]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<ReprocessarAsanaResponse>(
`${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();
+157
View File
@@ -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<ListarParceirosResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ListarParceirosResponse>(`${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<ParceiroItem[]> {
const response = await this.listarParceiros({
estaAtivo: "true",
page: 1,
perPage: 100,
});
return response.data ?? [];
}
async criarParceiro(payload: SalvarParceiroPayload): Promise<ParceiroItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<SalvarParceiroResponse>(`${baseUrl}/parceiros`, payload, {
headers,
});
return response.data.data;
} catch (error) {
this.handleAxiosError(error, "Erro ao criar parceiro.");
}
}
async editarParceiro(id: string, payload: Partial<SalvarParceiroPayload>): Promise<ParceiroItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.patch<SalvarParceiroResponse>(`${baseUrl}/parceiros/${id}`, payload, {
headers,
});
return response.data.data;
} catch (error) {
this.handleAxiosError(error, "Erro ao editar parceiro.");
}
}
async toggleAtivoParceiro(id: string): Promise<ParceiroItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.patch<SalvarParceiroResponse>(
`${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();
+106
View File
@@ -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<string, UnidadeLookupRow>();
type UnidadesListResponse = {
data: Array<{
id: string;
nome: string;
estabelecimentoId: string;
}>;
};
async function buildLookupHeaders(): Promise<Record<string, string>> {
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<UnidadeLookupRow | null> {
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<UnidadesListResponse>(`${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<string> {
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;
}
+79
View File
@@ -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<Record<string, string>> {
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<UnidadeLookupRow & { criadoEm: string }> {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildLookupHeaders();
const usuarioEmail = getUsuarioEmailParaCommander();
const response = await axios.post<UnidadeResponse>(
`${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<UnidadeLookupRow & { criadoEm: string }> {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildCommanderHeaders();
const body: Record<string, string> = {};
if (input.nome !== undefined) body.nome = input.nome.trim();
if (input.estabelecimentoId !== undefined) body.estabelecimentoId = input.estabelecimentoId.trim();
const response = await axios.patch<UnidadeResponse>(`${baseUrl}/unidades/${unidadeId}`, body, {
headers,
});
clearCommanderUnidadeIdCache();
return response.data.data;
}
}
export const fechamentoUnidadesService = new FechamentoUnidadesService();
+72
View File
@@ -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<PresignParceiroLogoResponse>(
`${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<void> {
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();
+132
View File
@@ -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<ListarUsuariosResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ListarUsuariosResponse>(`${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<UsuarioItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<SalvarUsuarioResponse>(`${baseUrl}/usuarios`, payload, { headers });
return response.data.data;
} catch (error) {
this.handleAxiosError(error, "Erro ao criar usuário.");
}
}
async editarUsuario(id: string, payload: Partial<SalvarUsuarioPayload>): Promise<UsuarioItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.patch<SalvarUsuarioResponse>(`${baseUrl}/usuarios/${id}`, payload, {
headers,
});
return response.data.data;
} catch (error) {
this.handleAxiosError(error, "Erro ao editar usuário.");
}
}
async toggleAtivoUsuario(id: string): Promise<UsuarioItem> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.patch<SalvarUsuarioResponse>(
`${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();
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
readonly VITE_API_BASE_URL_COMMANDER: string
readonly VITE_API_KEY: string readonly VITE_API_KEY: string
readonly VITE_USER_EMAIL: string readonly VITE_USER_EMAIL: string
readonly VITE_ESTABELECIMENTO_ID: string readonly VITE_ESTABELECIMENTO_ID: string