novas features, geracao pdf, regras de usuarios, novo perfil de acesso

This commit is contained in:
Vitex Tecnologia
2026-05-13 17:43:25 -03:00
parent 343aae602f
commit 517c9751bc
22 changed files with 1945 additions and 253 deletions
+3 -2
View File
@@ -52,7 +52,7 @@ export function UnidadeGate({ children }: UnidadeGateProps) {
return; return;
} }
if (papel !== "admin" && me?.unidadeId && me.unidadeId !== row.id) { if (papel !== "admin" && papel !== "supervisor" && me?.unidadeId && me.unidadeId !== row.id) {
setPhase("usuario_outra_unidade"); setPhase("usuario_outra_unidade");
return; return;
} }
@@ -71,6 +71,7 @@ export function UnidadeGate({ children }: UnidadeGateProps) {
}, [me?.unidadeId, location.pathname, papel]); }, [me?.unidadeId, location.pathname, papel]);
const isAdmin = papel === "admin"; const isAdmin = papel === "admin";
const isStaff = papel === "admin" || papel === "supervisor";
const onConfiguracoes = isConfiguracoesPath(location.pathname); const onConfiguracoes = isConfiguracoesPath(location.pathname);
if (phase === "loading") { if (phase === "loading") {
@@ -88,7 +89,7 @@ export function UnidadeGate({ children }: UnidadeGateProps) {
return <>{children}</>; return <>{children}</>;
} }
if (phase === "unidade_nao_cadastrada" && isAdmin && onConfiguracoes) { if (phase === "unidade_nao_cadastrada" && isStaff && onConfiguracoes) {
return <>{children}</>; return <>{children}</>;
} }
+28 -18
View File
@@ -1,5 +1,6 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout"; import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
import DashboardPontos from "@/modules/fechamento-hgtx/pages/DashboardPontos";
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos"; import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos"; import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes"; import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
@@ -16,10 +17,10 @@ import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext"
import { AuthGate } from "@/components/auth/AuthGate"; import { AuthGate } from "@/components/auth/AuthGate";
import { UnidadeGate } from "@/components/fechamento/UnidadeGate"; import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
function RequireAdminRoute({ children }: { children: JSX.Element }) { function RequireStaffRoute({ children }: { children: JSX.Element }) {
const { papel } = useAuthAccess(); const { papel } = useAuthAccess();
if (papel === "admin") { if (papel === "admin" || papel === "supervisor") {
return children; return children;
} }
@@ -31,7 +32,7 @@ function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element })
const location = useLocation(); const location = useLocation();
const readonlyView = Boolean((location.state as { readonlyView?: boolean } | null)?.readonlyView); const readonlyView = Boolean((location.state as { readonlyView?: boolean } | null)?.readonlyView);
if (papel === "admin") { if (papel === "admin" || papel === "supervisor") {
return children; return children;
} }
@@ -49,20 +50,29 @@ const FechamentoHgtxApp = () => {
<UnidadeGate> <UnidadeGate>
<MainLayout> <MainLayout>
<Routes> <Routes>
<Route index element={<Navigate to="dashboard" replace />} />
<Route <Route
index path="dashboard"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<DashboardPontos />
</RequireStaffRoute>
}
/>
<Route
path="gerenciar-fechamentos"
element={
<RequireStaffRoute>
<Fechamentos /> <Fechamentos />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route <Route
path="competencias/:id" path="competencias/:id"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<CompetenciaFechamentos /> <CompetenciaFechamentos />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route <Route
@@ -76,17 +86,17 @@ const FechamentoHgtxApp = () => {
<Route <Route
path="banco-pontos" path="banco-pontos"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<BancoPontos /> <BancoPontos />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route <Route
path="banco-pontos/:parceiroId" path="banco-pontos/:parceiroId"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<BancoPontosExtrato /> <BancoPontosExtrato />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route path="meu-perfil" element={<MeuPerfil />} /> <Route path="meu-perfil" element={<MeuPerfil />} />
@@ -95,25 +105,25 @@ const FechamentoHgtxApp = () => {
<Route <Route
path="parceiros" path="parceiros"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<Parceiros /> <Parceiros />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route <Route
path="usuarios" path="usuarios"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<Usuarios /> <Usuarios />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route <Route
path="configuracoes" path="configuracoes"
element={ element={
<RequireAdminRoute> <RequireStaffRoute>
<Configuracoes /> <Configuracoes />
</RequireAdminRoute> </RequireStaffRoute>
} }
/> />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
@@ -6,6 +6,7 @@ import {
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
Landmark, Landmark,
LayoutDashboard,
Menu, Menu,
ReceiptText, ReceiptText,
Settings, Settings,
@@ -18,67 +19,86 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { GlobalFunctions } from "@/GlobalFunctions"; import { GlobalFunctions } from "@/GlobalFunctions";
import { useAuthAccess } from "@/contexts/AuthAccessContext"; import { useAuthAccess } from "@/contexts/AuthAccessContext";
import type { Capabilities, PapelUsuario } from "@/services/fechamento/authMe";
function canSeeNavItem(path: string, caps: Capabilities | undefined, papel: PapelUsuario | null): boolean {
if (!caps || !papel) return false;
if (path === "meu-fechamento" || path === "meu-perfil") {
return papel === "admin" || papel === "parceiro" || papel === "supervisor";
}
if (path === "configuracoes") {
return papel === "admin" || papel === "supervisor";
}
if (path === "dashboard") {
return (papel === "admin" || papel === "supervisor") && caps.competencias.listar;
}
if (path === "gerenciar-fechamentos") return caps.competencias.listar;
if (path === "banco-pontos") return caps.bancoPontos.acessoAdmin;
if (path === "parceiros") return caps.parceiros.listar;
if (path === "usuarios") return caps.usuarios.listar;
return false;
}
const navItems = [ const navItems = [
{ {
section: "Fechamento", section: "Fechamento" as const,
title: "Gerenciar Fechamentos", title: "Dashboard",
path: "", path: "dashboard",
icon: ClipboardList, icon: LayoutDashboard,
roles: ["admin"] as const,
}, },
{ {
section: "Fechamento", section: "Fechamento" as const,
title: "Gerenciar Fechamentos",
path: "gerenciar-fechamentos",
icon: ClipboardList,
},
{
section: "Fechamento" as const,
title: "Banco de Pontos", title: "Banco de Pontos",
path: "banco-pontos", path: "banco-pontos",
icon: Landmark, icon: Landmark,
roles: ["admin"] as const,
}, },
{ {
section: "Fechamento", section: "Fechamento" as const,
title: "Meu Fechamento", title: "Meu Fechamento",
path: "meu-fechamento", path: "meu-fechamento",
icon: ReceiptText, icon: ReceiptText,
roles: ["admin", "parceiro"] as const,
}, },
{ {
section: "Configurações", section: "Configurações" as const,
title: "Parceiros", title: "Parceiros",
path: "parceiros", path: "parceiros",
icon: Wallet, icon: Wallet,
roles: ["admin"] as const,
}, },
{ {
section: "Configurações", section: "Configurações" as const,
title: "Usuários", title: "Usuários",
path: "usuarios", path: "usuarios",
icon: Users, icon: Users,
roles: ["admin"] as const,
}, },
{ {
section: "Configurações", section: "Configurações" as const,
title: "Configurações", title: "Configurações",
path: "configuracoes", path: "configuracoes",
icon: Settings, icon: Settings,
roles: ["admin"] as const,
}, },
{ {
section: "Configurações", section: "Configurações" as const,
title: "Meu Perfil", title: "Meu Perfil",
path: "meu-perfil", path: "meu-perfil",
icon: UserCircle2, icon: UserCircle2,
roles: ["admin", "parceiro"] as const,
}, },
]; ];
export function AppSidebar() { export function AppSidebar() {
const { papel } = useAuthAccess(); const { papel, me } = useAuthAccess();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const isAdmin = papel === "admin"; const caps = me?.capabilities;
const role = papel ?? "parceiro"; const allowedNavItems = navItems.filter((item) => canSeeNavItem(item.path, caps, papel));
const allowedNavItems = navItems.filter((item) => item.roles.includes(role));
const sections = ["Fechamento", "Configurações"] as const; const sections = ["Fechamento", "Configurações"] as const;
const painelLabel =
papel === "supervisor" ? "Painel Supervisor" : papel === "admin" ? "Painel Admin" : "Painel Parceiro";
const SidebarContent = () => ( const SidebarContent = () => (
<> <>
@@ -98,7 +118,7 @@ export function AppSidebar() {
</div> </div>
<div className="min-w-0 animate-fade-in"> <div className="min-w-0 animate-fade-in">
<h1 className="truncate text-lg font-semibold text-sidebar-foreground">HGTX Intelligence Score</h1> <h1 className="truncate text-lg font-semibold text-sidebar-foreground">HGTX Intelligence Score</h1>
<p className="text-xs text-muted-foreground">{isAdmin ? "Painel Admin" : "Painel Parceiro"}</p> <p className="text-xs text-muted-foreground">{painelLabel}</p>
</div> </div>
</div> </div>
<Button <Button
@@ -151,7 +171,7 @@ export function AppSidebar() {
<NavLink <NavLink
key={item.path} key={item.path}
to={item.path} to={item.path}
end={item.path === ""} end={item.path === "dashboard"}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
@@ -5,9 +5,11 @@ import {
Download, Download,
ExternalLink, ExternalLink,
FileSpreadsheet, FileSpreadsheet,
FileText,
FolderKanban, FolderKanban,
Loader2, Loader2,
RefreshCcw, RefreshCcw,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -25,6 +27,7 @@ import {
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useAuthAccess } from "@/contexts/AuthAccessContext"; import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias"; import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos"; import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
@@ -71,8 +74,25 @@ function reprocessamentoLabel(modo: "reprocessar_tudo" | "reprocessar_alguns" |
return "Buscando novos fechamentos no Asana."; return "Buscando novos fechamentos no Asana.";
} }
/** Supervisor só exporta fechamento do parceiro ao qual está vinculado. */
function supervisorPodeExportarEsteFechamento(
papel: string | null | undefined,
meParceiroId: string | null | undefined,
fechamentoParceiroId: string,
): boolean {
if (papel !== "supervisor") return true;
return Boolean(meParceiroId && meParceiroId === fechamentoParceiroId);
}
export default function CompetenciaFechamentos() { export default function CompetenciaFechamentos() {
const { me } = useAuthAccess(); const { me, papel } = useAuthAccess();
const caps = me?.capabilities;
const podeImportarAsana = caps?.competencias.importarAsana ?? false;
const podeReabrirCompetencia = caps?.competencias.reabrir ?? false;
const podeConcluirCompetencia = caps?.competencias.concluir ?? false;
const podeExportarPlanilha = caps?.fechamentos.exportarPlanilha ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const podeExcluirFechamento = caps?.fechamentos.excluir ?? false;
const { id: competenciaId = "" } = useParams(); const { id: competenciaId = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -80,7 +100,7 @@ export default function CompetenciaFechamentos() {
/** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */ /** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */
const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null); const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null);
const importing = importKind !== null; const importing = importKind !== null;
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null); const [exportacao, setExportacao] = useState<{ fechamentoId: string; tipo: "planilha" | "pdf" } | null>(null);
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false); const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false); const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false);
const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false); const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false);
@@ -92,6 +112,9 @@ export default function CompetenciaFechamentos() {
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos" "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
>("reprocessar_tudo"); >("reprocessar_tudo");
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]); const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
const [fechamentoExcluir, setFechamentoExcluir] = useState<FechamentoDaCompetenciaItem | null>(null);
const [motivoExclusaoFechamento, setMotivoExclusaoFechamento] = useState("");
const [excluindoFechamento, setExcluindoFechamento] = useState(false);
const loadFechamentos = async () => { const loadFechamentos = async () => {
try { try {
@@ -205,7 +228,7 @@ export default function CompetenciaFechamentos() {
const handleExportar = async (fechamentoId: string) => { const handleExportar = async (fechamentoId: string) => {
try { try {
setExportingFechamentoId(fechamentoId); setExportacao({ fechamentoId, tipo: "planilha" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId); const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
const blob = new Blob([buffer], { const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -223,7 +246,56 @@ export default function CompetenciaFechamentos() {
const message = error instanceof Error ? error.message : "Erro ao exportar planilha."; const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
toast.error(message); toast.error(message);
} finally { } finally {
setExportingFechamentoId(null); setExportacao(null);
}
};
const handleExportarPdf = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "pdf" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportacao(null);
}
};
const handleConfirmarExclusaoFechamento = async () => {
if (!fechamentoExcluir || !me?.id) {
toast.error("Não foi possível identificar o usuário ou o fechamento.");
return;
}
if (!motivoExclusaoFechamento.trim()) {
toast.error("Informe o motivo da exclusão.");
return;
}
setExcluindoFechamento(true);
try {
await fechamentoFechamentosService.excluirFechamento(fechamentoExcluir.id, {
excluidoPorId: me.id,
motivo: motivoExclusaoFechamento.trim(),
});
toast.success("Fechamento excluído com sucesso.");
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao excluir fechamento.";
toast.error(message);
} finally {
setExcluindoFechamento(false);
} }
}; };
@@ -249,7 +321,7 @@ export default function CompetenciaFechamentos() {
<div className="flex h-full flex-col bg-background pb-16 md:pb-0"> <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="border-b border-border p-3 md:p-6">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score")}> <Button variant="ghost" size="sm" onClick={() => navigate("/intelligence-score/gerenciar-fechamentos")}>
<ArrowLeft className="mr-1 h-4 w-4" /> <ArrowLeft className="mr-1 h-4 w-4" />
Voltar Voltar
</Button> </Button>
@@ -263,6 +335,7 @@ export default function CompetenciaFechamentos() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p> <p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
{competenciaStatus === "concluido" ? ( {competenciaStatus === "concluido" ? (
podeReabrirCompetencia ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -271,8 +344,10 @@ export default function CompetenciaFechamentos() {
> >
{reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"} {reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
</Button> </Button>
) : null
) : temFechamentos ? ( ) : temFechamentos ? (
<> <>
{podeImportarAsana ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -282,6 +357,8 @@ export default function CompetenciaFechamentos() {
<RefreshCcw className="mr-2 h-4 w-4" /> <RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana Reprocessar Asana
</Button> </Button>
) : null}
{podeConcluirCompetencia ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -290,6 +367,7 @@ export default function CompetenciaFechamentos() {
> >
{concluindoCompetencia ? "Concluindo..." : "Concluir competência"} {concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
</Button> </Button>
) : null}
</> </>
) : null} ) : null}
</div> </div>
@@ -309,11 +387,15 @@ export default function CompetenciaFechamentos() {
<div className="px-6 pb-6"> <div className="px-6 pb-6">
{importKind === "sheet" ? ( {importKind === "sheet" ? (
<AsanaImportLoadingCard /> <AsanaImportLoadingCard />
) : ( ) : podeImportarAsana ? (
<Button onClick={() => void handleImportarAsana()} disabled={importing}> <Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Importar tasks do Asana Importar tasks do Asana
</Button> </Button>
) : (
<p className="text-sm text-muted-foreground">
Não permissão para importar do Asana neste perfil. Peça a um administrador.
</p>
)} )}
</div> </div>
</Card> </Card>
@@ -374,17 +456,44 @@ export default function CompetenciaFechamentos() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-center gap-2"> <div className="flex flex-wrap justify-center gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground" className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportar(row.id)} onClick={() => void handleExportar(row.id)}
disabled={exportingFechamentoId === row.id || row.status !== "fechado"} disabled={
!podeExportarPlanilha ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar planilha financeira (XLSX)" title="Exportar planilha financeira (XLSX)"
> >
<FileSpreadsheet className="mr-2 h-4 w-4" /> <FileSpreadsheet className="mr-2 h-4 w-4" />
{exportingFechamentoId === row.id ? "Exportando..." : "Exportar"} {exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha"
? "Exportando..."
: "Excel"}
</Button>
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportarPdf(row.id)}
disabled={
!podeExportarPdf ||
!supervisorPodeExportarEsteFechamento(papel, me?.parceiroId, row.parceiroId) ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf") ||
row.status !== "fechado"
}
title="Exportar documento do fechamento (PDF)"
>
<FileText className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === row.id && exportacao?.tipo === "pdf"
? "Exportando..."
: "PDF"}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@@ -399,6 +508,21 @@ export default function CompetenciaFechamentos() {
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
Acessar Acessar
</Button> </Button>
{podeExcluirFechamento ? (
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setMotivoExclusaoFechamento("");
setFechamentoExcluir(row);
}}
title="Excluir fechamento"
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -656,6 +780,67 @@ export default function CompetenciaFechamentos() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog
open={fechamentoExcluir !== null}
onOpenChange={(open) => {
if (!open) {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Excluir fechamento</DialogTitle>
<DialogDescription>
{fechamentoExcluir ? (
<>
Parceiro: <span className="font-medium text-foreground">{getDisplayNome(fechamentoExcluir)}</span>.
{fechamentoExcluir.status === "fechado" ? (
<>
{" "}
Será aplicado estorno no banco de pontos (como na reabertura). Se a competência estiver
concluída, reabra a competência antes de excluir este fechamento.
</>
) : null}
</>
) : null}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-excluir-fechamento">Motivo *</Label>
<Textarea
id="motivo-excluir-fechamento"
value={motivoExclusaoFechamento}
onChange={(e) => setMotivoExclusaoFechamento(e.target.value)}
placeholder="Ex.: fechamento criado por engano"
rows={3}
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setFechamentoExcluir(null);
setMotivoExclusaoFechamento("");
}}
disabled={excluindoFechamento}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirmarExclusaoFechamento()}
disabled={excluindoFechamento || !motivoExclusaoFechamento.trim()}
>
{excluindoFechamento ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react"; import { Eye, EyeOff, Image as ImageIcon, Loader2, RefreshCw, Save, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MagicCard } from "@/components/ui/magic-card"; import { MagicCard } from "@/components/ui/magic-card";
@@ -28,17 +28,35 @@ import {
lookupUnidadeByEstabelecimento, lookupUnidadeByEstabelecimento,
type UnidadeLookupRow, type UnidadeLookupRow,
} from "@/services/fechamento/unidadeContext"; } from "@/services/fechamento/unidadeContext";
import { fechamentoUploadsService } from "@/services/fechamento/uploads";
import { useAuthAccess } from "@/contexts/AuthAccessContext"; import { useAuthAccess } from "@/contexts/AuthAccessContext";
const MAX_EMPRESA_LOGO_BYTES = 5 * 1024 * 1024;
const ALLOWED_EMPRESA_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
function validateEmpresaLogoFile(file: File): string | null {
if (!ALLOWED_EMPRESA_LOGO_TYPES.has(file.type)) {
return "Arquivo inválido. Use JPG, PNG ou WEBP.";
}
if (file.size > MAX_EMPRESA_LOGO_BYTES) {
return "Arquivo excede 5MB. Escolha uma imagem menor.";
}
return null;
}
export default function Configuracoes() { export default function Configuracoes() {
const navigate = useNavigate(); const navigate = useNavigate();
const { papel } = useAuthAccess(); const { papel, me } = useAuthAccess();
const caps = me?.capabilities;
const podeSalvarConfig = caps?.configuracoes.salvar ?? false;
const isAdmin = papel === "admin"; const isAdmin = papel === "admin";
const unidadeSectionRef = useRef<HTMLDivElement>(null); const unidadeSectionRef = useRef<HTMLDivElement>(null);
const empresaLogoFileInputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null); const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [savingEmpresaDoc, setSavingEmpresaDoc] = useState(false);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false); const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
const [showToken, setShowToken] = useState(false); const [showToken, setShowToken] = useState(false);
@@ -48,6 +66,12 @@ export default function Configuracoes() {
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(""); const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState(""); const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
const [empresaRazaoSocial, setEmpresaRazaoSocial] = useState("");
const [empresaCnpj, setEmpresaCnpj] = useState("");
const [empresaLogoUrl, setEmpresaLogoUrl] = useState("");
const [selectedEmpresaLogoFile, setSelectedEmpresaLogoFile] = useState<File | null>(null);
const [empresaLogoPreviewUrl, setEmpresaLogoPreviewUrl] = useState<string | null>(null);
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState(""); const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null); const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null);
const [nomeUnidade, setNomeUnidade] = useState(""); const [nomeUnidade, setNomeUnidade] = useState("");
@@ -58,6 +82,18 @@ export default function Configuracoes() {
const tokenDigitado = asanaToken.trim(); const tokenDigitado = asanaToken.trim();
const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces; const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
const empresaLogoDisplayUrl = empresaLogoPreviewUrl ?? (empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null);
const empresaLogoStatusLabel = useMemo(() => {
if (savingEmpresaDoc && selectedEmpresaLogoFile) {
return "Enviando logo e salvando…";
}
if (selectedEmpresaLogoFile) {
return `Arquivo selecionado: ${selectedEmpresaLogoFile.name}`;
}
return "Nenhum arquivo novo selecionado.";
}, [savingEmpresaDoc, selectedEmpresaLogoFile]);
const workspaceOptions = useMemo(() => { const workspaceOptions = useMemo(() => {
if (!selectedWorkspaceId || !selectedWorkspaceNome) { if (!selectedWorkspaceId || !selectedWorkspaceNome) {
return workspaces; return workspaces;
@@ -92,15 +128,41 @@ export default function Configuracoes() {
const loadConfiguracoesAsana = async () => { const loadConfiguracoesAsana = async () => {
try { try {
setConfigAsanaError(null); setConfigAsanaError(null);
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
const data = await fechamentoConfiguracoesService.getConfiguracoes(); const data = await fechamentoConfiguracoesService.getConfiguracoes();
setConfigAtual(data); setConfigAtual(data);
setAsanaToken(data?.asanaToken ?? ""); setAsanaToken(data?.asanaToken ?? "");
setSelectedWorkspaceId(data?.asanaWorkspaceId ?? ""); setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? ""); setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(data?.empresaRazaoSocial ?? "");
setEmpresaCnpj(data?.empresaCnpj ?? "");
setEmpresaLogoUrl(data?.empresaLogoUrl ?? "");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana."; const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
setConfigAsanaError(message); setConfigAsanaError(message);
setConfigAtual(null); setConfigAtual(null);
setEmpresaRazaoSocial("");
setEmpresaCnpj("");
setEmpresaLogoUrl("");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
} }
}; };
@@ -117,6 +179,14 @@ export default function Configuracoes() {
void loadAll(); void loadAll();
}, []); }, []);
useEffect(() => {
return () => {
if (empresaLogoPreviewUrl) {
URL.revokeObjectURL(empresaLogoPreviewUrl);
}
};
}, [empresaLogoPreviewUrl]);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined" || loading) return; if (typeof window === "undefined" || loading) return;
if (window.location.hash === "#unidade" && unidadeSectionRef.current) { if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
@@ -125,6 +195,10 @@ export default function Configuracoes() {
}, [loading]); }, [loading]);
const handleBuscarWorkspaces = async () => { const handleBuscarWorkspaces = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para alterar a integração Asana.");
return;
}
if (!tokenDigitado) { if (!tokenDigitado) {
toast.error("Informe o token do Asana para buscar workspaces."); toast.error("Informe o token do Asana para buscar workspaces.");
return; return;
@@ -160,6 +234,10 @@ export default function Configuracoes() {
}; };
const handleSaveAsana = async () => { const handleSaveAsana = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
if (!selectedWorkspaceId) { if (!selectedWorkspaceId) {
toast.error("Selecione um workspace antes de salvar."); toast.error("Selecione um workspace antes de salvar.");
return; return;
@@ -178,6 +256,9 @@ export default function Configuracoes() {
setAsanaToken(updated.asanaToken ?? ""); setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? ""); setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? ""); setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setConfigAsanaError(null); setConfigAsanaError(null);
toast.success("Configurações salvas com sucesso."); toast.success("Configurações salvas com sucesso.");
} catch (error) { } catch (error) {
@@ -188,6 +269,90 @@ export default function Configuracoes() {
} }
}; };
const handleEmpresaLogoFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
const validationMessage = validateEmpresaLogoFile(file);
if (validationMessage) {
toast.error(validationMessage);
event.currentTarget.value = "";
return;
}
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return URL.createObjectURL(file);
});
setSelectedEmpresaLogoFile(file);
};
const clearEmpresaLogoSelection = () => {
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
setEmpresaLogoUrl("");
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
};
const handleSalvarEmpresaDoc = async () => {
if (!podeSalvarConfig) {
toast.error("Sem permissão para salvar configurações.");
return;
}
try {
setSavingEmpresaDoc(true);
let logoUrlParaSalvar: string | null = null;
if (selectedEmpresaLogoFile) {
const signed = await fechamentoUploadsService.presignUploadEmpresaLogo({
fileName: selectedEmpresaLogoFile.name,
contentType: selectedEmpresaLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
});
await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedEmpresaLogoFile);
logoUrlParaSalvar = signed.publicUrl;
} else {
logoUrlParaSalvar = empresaLogoUrl.trim().length > 0 ? empresaLogoUrl.trim() : null;
}
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
empresaRazaoSocial: empresaRazaoSocial.trim() || null,
empresaCnpj: empresaCnpj.trim() || null,
empresaLogoUrl: logoUrlParaSalvar,
});
setConfigAtual(updated);
setEmpresaRazaoSocial(updated.empresaRazaoSocial ?? "");
setEmpresaCnpj(updated.empresaCnpj ?? "");
setEmpresaLogoUrl(updated.empresaLogoUrl ?? "");
setEmpresaLogoPreviewUrl((prev) => {
if (prev) {
URL.revokeObjectURL(prev);
}
return null;
});
setSelectedEmpresaLogoFile(null);
if (empresaLogoFileInputRef.current) {
empresaLogoFileInputRef.current.value = "";
}
setAsanaToken(updated.asanaToken ?? "");
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
setConfigAsanaError(null);
toast.success("Dados da empresa salvos.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao salvar dados da empresa.";
toast.error(message);
} finally {
setSavingEmpresaDoc(false);
}
};
const handleSalvarUnidade = async () => { const handleSalvarUnidade = async () => {
const nome = nomeUnidade.trim(); const nome = nomeUnidade.trim();
if (!nome) { if (!nome) {
@@ -326,6 +491,129 @@ export default function Configuracoes() {
<Separator className="opacity-60" /> <Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard
className="rounded-lg"
gradientFrom="hsl(var(--primary))"
gradientTo="hsl(var(--secondary))"
gradientSize={220}
>
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
<CardTitle className="text-base font-semibold">Dados da empresa</CardTitle>
<CardDescription className="text-sm leading-relaxed">
Razão social, CNPJ e logo usados no cabeçalho do PDF de fechamento (estilo invoice). Envie JPG, PNG ou
WEBP de até 5MB.
</CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader>
<CardContent className="space-y-5 px-6 py-6">
{configAsanaError ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-950 dark:text-amber-100">
A integração Asana não pôde ser carregada; você ainda pode salvar os dados da empresa para o PDF.
</p>
) : null}
<div className="space-y-2">
<Label htmlFor="empresa-razao">Razão social</Label>
<Input
id="empresa-razao"
value={empresaRazaoSocial}
onChange={(e) => setEmpresaRazaoSocial(e.target.value)}
placeholder="Ex.: Minha Empresa LTDA"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label htmlFor="empresa-cnpj">CNPJ</Label>
<Input
id="empresa-cnpj"
value={empresaCnpj}
onChange={(e) => setEmpresaCnpj(e.target.value)}
placeholder="Somente números ou formatado"
className="h-11 text-sm"
disabled={!podeSalvarConfig}
/>
</div>
<div className="space-y-2">
<Label>Logo da empresa</Label>
<div className="flex flex-col items-start gap-3 rounded-md border border-border bg-muted/20 px-4 py-4 sm:flex-row sm:items-center">
<button
type="button"
className="group relative h-24 w-40 shrink-0 overflow-hidden rounded-md border bg-background shadow-sm transition hover:brightness-95 disabled:pointer-events-none disabled:opacity-50"
onClick={() => empresaLogoFileInputRef.current?.click()}
title="Clique para enviar uma imagem"
disabled={!podeSalvarConfig || savingEmpresaDoc}
>
{empresaLogoDisplayUrl ? (
<img
src={empresaLogoDisplayUrl}
alt="Pré-visualização do logo"
className="h-full w-full object-contain"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageIcon className="h-8 w-8" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity group-hover:opacity-100">
<span className="inline-flex items-center gap-1 text-xs font-medium">
<Upload className="h-3.5 w-3.5" />
Enviar imagem
</span>
</div>
</button>
<input
ref={empresaLogoFileInputRef}
id="empresa-logo-file"
type="file"
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={handleEmpresaLogoFileChange}
className="hidden"
disabled={!podeSalvarConfig}
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs leading-relaxed text-muted-foreground">{empresaLogoStatusLabel}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={clearEmpresaLogoSelection}
disabled={!podeSalvarConfig || savingEmpresaDoc || (!empresaLogoDisplayUrl && !selectedEmpresaLogoFile)}
>
Remover logo
</Button>
</div>
</div>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button
type="button"
onClick={() => void handleSalvarEmpresaDoc()}
disabled={savingEmpresaDoc || !podeSalvarConfig}
>
{savingEmpresaDoc ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvando
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salvar dados da empresa
</>
)}
</Button>
</div>
</CardContent>
</MagicCard>
</Card>
<Separator className="opacity-60" />
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm"> <Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
<MagicCard <MagicCard
className="rounded-lg" className="rounded-lg"
@@ -338,6 +626,11 @@ export default function Configuracoes() {
<CardDescription className="text-sm leading-relaxed"> <CardDescription className="text-sm leading-relaxed">
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações. Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
</CardDescription> </CardDescription>
{!podeSalvarConfig ? (
<p className="text-sm text-muted-foreground">
Seu perfil pode visualizar estas informações, mas não salvar alterações.
</p>
) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-6 px-6 py-6"> <CardContent className="space-y-6 px-6 py-6">
{configAsanaError ? ( {configAsanaError ? (
@@ -381,14 +674,15 @@ export default function Configuracoes() {
: "Cole o token do Asana (Personal Access Token)" : "Cole o token do Asana (Personal Access Token)"
} }
className="h-11 pr-11 font-mono text-sm" className="h-11 pr-11 font-mono text-sm"
disabled={Boolean(configAsanaError)} disabled={!podeSalvarConfig || Boolean(configAsanaError)}
autoComplete="off" autoComplete="off"
/> />
<button <button
type="button" type="button"
onClick={() => setShowToken((prev) => !prev)} onClick={() => setShowToken((prev) => !prev)}
title={showToken ? "Ocultar token" : "Mostrar token"} 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" disabled={!podeSalvarConfig || Boolean(configAsanaError)}
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 disabled:pointer-events-none disabled:opacity-50"
> >
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button> </button>
@@ -398,7 +692,7 @@ export default function Configuracoes() {
variant="secondary" variant="secondary"
className="h-11 shrink-0 whitespace-nowrap px-5 lg:self-stretch" className="h-11 shrink-0 whitespace-nowrap px-5 lg:self-stretch"
onClick={handleBuscarWorkspaces} onClick={handleBuscarWorkspaces}
disabled={!podeBuscarWorkspaces || Boolean(configAsanaError)} disabled={!podeSalvarConfig || !podeBuscarWorkspaces || Boolean(configAsanaError)}
> >
{loadingWorkspaces ? ( {loadingWorkspaces ? (
<> <>
@@ -426,7 +720,7 @@ export default function Configuracoes() {
const selected = workspaceOptions.find((item) => item.id === value); const selected = workspaceOptions.find((item) => item.id === value);
setSelectedWorkspaceNome(selected?.name ?? ""); setSelectedWorkspaceNome(selected?.name ?? "");
}} }}
disabled={Boolean(configAsanaError)} disabled={!podeSalvarConfig || Boolean(configAsanaError)}
> >
<SelectTrigger id="asana-workspace" className="h-11 w-full"> <SelectTrigger id="asana-workspace" className="h-11 w-full">
<SelectValue <SelectValue
@@ -456,7 +750,7 @@ export default function Configuracoes() {
size="lg" size="lg"
className="min-w-[200px]" className="min-w-[200px]"
onClick={handleSaveAsana} onClick={handleSaveAsana}
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError)} disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError) || !podeSalvarConfig}
> >
{saving ? ( {saving ? (
<> <>
@@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LayoutDashboard, Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { useAuthAccess } from "@/contexts/AuthAccessContext";
import {
dashboardPontosPorClienteService,
type DashboardPontosPorParceiro,
} from "@/services/fechamento/dashboardPontosPorCliente";
import { fechamentoParceirosService, type ParceiroItem } from "@/services/fechamento/parceiros";
function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
function primeiroDiaMesAtual(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
return `${y}-${m}-01`;
}
function hojeIsoDate(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
export default function DashboardPontos() {
const { papel, me } = useAuthAccess();
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
const [dataInicio, setDataInicio] = useState(primeiroDiaMesAtual);
const [dataFim, setDataFim] = useState(hojeIsoDate);
const [parceiroId, setParceiroId] = useState<string>("all");
const [cliente, setCliente] = useState("");
const [loading, setLoading] = useState(true);
const [dados, setDados] = useState<DashboardPontosPorParceiro[]>([]);
const clienteRef = useRef(cliente);
clienteRef.current = cliente;
const parceiroFixoSupervisor = papel === "supervisor" && me?.parceiroId;
const loadParceiros = useCallback(async () => {
if (parceiroFixoSupervisor && me?.parceiroId) {
try {
const res = await fechamentoParceirosService.listarParceiros({
estaAtivo: "all",
page: 1,
perPage: 200,
});
const um = res.data.find((p) => p.id === me.parceiroId);
setParceiros(um ? [um] : []);
setParceiroId(me.parceiroId);
} catch (e) {
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
toast.error(message);
setParceiros([]);
setParceiroId(me.parceiroId);
}
return;
}
try {
const lista = await fechamentoParceirosService.listarParceirosAtivos();
setParceiros(lista);
} catch (e) {
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
toast.error(message);
setParceiros([]);
}
}, [me?.parceiroId, parceiroFixoSupervisor]);
const loadRelatorio = useCallback(async () => {
try {
setLoading(true);
const pid =
parceiroFixoSupervisor && me?.parceiroId
? me.parceiroId
: parceiroId === "all"
? undefined
: parceiroId;
const res = await dashboardPontosPorClienteService.relatorioPontosPorCliente({
dataInicio: dataInicio.trim() || undefined,
dataFim: dataFim.trim() || undefined,
parceiroId: pid,
cliente: clienteRef.current.trim() || undefined,
});
setDados(res);
} catch (e) {
const message = e instanceof Error ? e.message : "Erro ao carregar dashboard.";
toast.error(message);
setDados([]);
} finally {
setLoading(false);
}
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, parceiroId]);
useEffect(() => {
void loadParceiros();
}, [loadParceiros]);
useEffect(() => {
void loadRelatorio();
}, [dataFim, dataInicio, loadRelatorio, parceiroId, me?.parceiroId, papel]);
const totalGeral = useMemo(
() => dados.reduce((acc, p) => acc + p.totalParceiro, 0),
[dados],
);
return (
<div className="mx-auto max-w-6xl space-y-6 p-4 pb-10 lg:p-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<LayoutDashboard className="h-7 w-7 text-primary" />
Dashboard
</h1>
<p className="text-muted-foreground">
Pontos alocados por cliente (tarefas revisadas), por parceiro.
</p>
</div>
<Button type="button" variant="outline" size="sm" className="gap-2 self-start" onClick={() => void loadRelatorio()}>
<RefreshCw className="h-4 w-4" />
Atualizar
</Button>
</div>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">Filtros</CardTitle>
<CardDescription>Período por data de conclusão da tarefa, ou data de criação se ainda não concluída.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="dash-data-ini">Data início</Label>
<Input
id="dash-data-ini"
type="date"
value={dataInicio}
onChange={(e) => setDataInicio(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dash-data-fim">Data fim</Label>
<Input id="dash-data-fim" type="date" value={dataFim} onChange={(e) => setDataFim(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Parceiro</Label>
<Select
value={parceiroId}
onValueChange={setParceiroId}
disabled={Boolean(parceiroFixoSupervisor)}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{!parceiroFixoSupervisor ? (
<SelectItem value="all">Todos os parceiros</SelectItem>
) : null}
{parceiroFixoSupervisor && parceiros.length === 0 && me?.parceiroId ? (
<SelectItem value={me.parceiroId}>Parceiro vinculado</SelectItem>
) : null}
{parceiros.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="dash-cliente">Cliente (contém)</Label>
<Input
id="dash-cliente"
placeholder="Nome do cliente"
value={cliente}
onChange={(e) => setCliente(e.target.value)}
/>
</div>
</CardContent>
<CardContent className="pt-0">
<Button type="button" onClick={() => void loadRelatorio()}>
Aplicar filtros
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-lg">Resumo</CardTitle>
<CardDescription>Soma dos pontos no período e filtros atuais.</CardDescription>
</div>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<span className="text-lg font-semibold tabular-nums">{formatPontos(totalGeral)} pts</span>
)}
</CardHeader>
</Card>
{loading && dados.length === 0 ? (
<div className="flex justify-center py-16">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
) : dados.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Nenhum dado para os filtros selecionados.
</CardContent>
</Card>
) : (
<div className="space-y-6">
{dados.map((bloco) => (
<Card key={bloco.parceiroId}>
<CardHeader className="pb-2">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base">{bloco.parceiroNome}</CardTitle>
<span className="text-sm font-medium text-muted-foreground tabular-nums">
Total: {formatPontos(bloco.totalParceiro)} pts
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Cliente</TableHead>
<TableHead className="text-right">Pontos</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bloco.linhas.map((linha) => (
<TableRow key={`${bloco.parceiroId}-${linha.cliente}`}>
<TableCell>{linha.cliente}</TableCell>
<TableCell className="text-right tabular-nums">{formatPontos(linha.pontos)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { import {
AlertCircle,
ArrowLeft, ArrowLeft,
CheckCircle2, CheckCircle2,
ExternalLink, ExternalLink,
FileText,
ListChecks, ListChecks,
Loader2, Loader2,
Pencil, Pencil,
@@ -17,6 +19,7 @@ import {
Wallet, Wallet,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -30,6 +33,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -103,8 +107,55 @@ function sanitizePontuacaoLancamentoDigitando(raw: string): string {
return intPart + sep + fracStr; return intPart + sep + fracStr;
} }
/** Valor em R$ no modal de lançamento: dígitos + um separador decimal, até 2 casas (pt-BR). */
function sanitizeValorRealLancamentoDigitando(raw: string): string {
const cleaned = raw.replace(/[^\d.,]/g, "");
if (!cleaned) return "";
let int = "";
let frac = "";
let sawSeparator = false;
for (const ch of cleaned) {
if (ch === "," || ch === ".") {
if (!sawSeparator) sawSeparator = true;
continue;
}
if (!sawSeparator) int += ch;
else if (frac.length < 2) frac += ch;
}
if (!sawSeparator) return int;
return frac.length > 0 ? `${int},${frac}` : `${int},`;
}
function parseValorRealMonetarioInput(raw: string): number {
let t = raw.trim().replace(/\s/g, "").replace(/R\$\s?/gi, "");
if (!t) return Number.NaN;
if (t.includes(",") && t.includes(".")) {
t = t.replace(/\./g, "").replace(",", ".");
} else if (t.includes(",")) {
t = t.replace(",", ".");
}
return Number(t);
}
/** Pontuação: no máximo 2 casas decimais, sempre para cima (ex.: 1,1111 → 1,12). Valores já em centésimos exatos (ex.: 1,00) não são inflados por erro de float. */
function pontuacaoParaCimaAte2Casas(value: number): number {
if (!Number.isFinite(value)) return value;
if (value === 0) return 0;
const sign = value < 0 ? -1 : 1;
const m = Math.abs(value);
const scaled = m * 100;
const nearest = Math.round(scaled);
const n =
Math.abs(scaled - nearest) < 1e-7 ? nearest : Math.ceil(scaled - 1e-9);
return sign * (n / 100);
}
function toPontuacaoInput(value: number): string { function toPontuacaoInput(value: number): string {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100; if (!Number.isFinite(value)) return "0";
const rounded = pontuacaoParaCimaAte2Casas(value);
return String(rounded); return String(rounded);
} }
@@ -164,7 +215,7 @@ type FechamentoDetalhesLocationState = {
export default function FechamentoDetalhes() { export default function FechamentoDetalhes() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { me } = useAuthAccess(); const { me, papel } = useAuthAccess();
const { id: fechamentoId = "" } = useParams(); const { id: fechamentoId = "" } = useParams();
const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null; const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null;
const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? ""); const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? "");
@@ -178,6 +229,7 @@ export default function FechamentoDetalhes() {
const [savingLancamento, setSavingLancamento] = useState(false); const [savingLancamento, setSavingLancamento] = useState(false);
const [isConcluirOpen, setIsConcluirOpen] = useState(false); const [isConcluirOpen, setIsConcluirOpen] = useState(false);
const [concluindo, setConcluindo] = useState(false); const [concluindo, setConcluindo] = useState(false);
const [exportandoPdf, setExportandoPdf] = useState(false);
const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState(""); const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState("");
const [motivoAjuste, setMotivoAjuste] = useState(""); const [motivoAjuste, setMotivoAjuste] = useState("");
const [isReabrirOpen, setIsReabrirOpen] = useState(false); const [isReabrirOpen, setIsReabrirOpen] = useState(false);
@@ -185,10 +237,13 @@ export default function FechamentoDetalhes() {
const [motivoReabertura, setMotivoReabertura] = useState(""); const [motivoReabertura, setMotivoReabertura] = useState("");
const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus"); const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus");
const [lancamentoDescricao, setLancamentoDescricao] = useState(""); const [lancamentoDescricao, setLancamentoDescricao] = useState("");
const [lancamentoModo, setLancamentoModo] = useState<"pontos" | "real">("pontos");
const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0"); const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
const [lancamentoValorReal, setLancamentoValorReal] = useState("");
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null); const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
const [parceiroId, setParceiroId] = useState<string | null>(null); const [parceiroId, setParceiroId] = useState<string | null>(null);
const [parceiroNome, setParceiroNome] = useState<string | null>(null); const [parceiroNome, setParceiroNome] = useState<string | null>(null);
const [parceiroFator, setParceiroFator] = useState<number | null>(null);
const [saldoBancoAtual, setSaldoBancoAtual] = useState<number | null>(null); const [saldoBancoAtual, setSaldoBancoAtual] = useState<number | null>(null);
const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false); const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false);
const [erroSaldoBanco, setErroSaldoBanco] = useState(false); const [erroSaldoBanco, setErroSaldoBanco] = useState(false);
@@ -224,7 +279,31 @@ export default function FechamentoDetalhes() {
const isFechado = fechamentoStatus === "fechado"; const isFechado = fechamentoStatus === "fechado";
const isCompetenciaConcluida = competenciaStatus === "concluido"; const isCompetenciaConcluida = competenciaStatus === "concluido";
const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida; const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida;
const isAdmin = me?.papel === "admin"; const caps = me?.capabilities;
const podeTarefaEditarRevisao = caps?.fechamentos.tarefaEditarRevisao ?? false;
const podeTarefaLancarAjuste = caps?.fechamentos.tarefaLancarAjuste ?? false;
const verFatorParceiro = caps?.parceiros.verFator ?? false;
const fatorParceiroValidoParaReal =
parceiroFator != null && Number.isFinite(parceiroFator) && parceiroFator > 0;
/** Quem vê o fator na API sabe se está cadastrado; sem fator não dá para lançar em Real no front. */
const lancamentoEmRealBloqueado = verFatorParceiro && !fatorParceiroValidoParaReal;
const podeReprocessarAsana = caps?.fechamentos.reprocessarAsana ?? false;
const podeConcluirFechamento = caps?.fechamentos.concluir ?? false;
const podeReabrirFechamento = caps?.fechamentos.reabrir ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const supervisorPodeExportarPdf =
podeExportarPdf &&
(papel !== "supervisor" || Boolean(me?.parceiroId && parceiroId != null && parceiroId === me.parceiroId));
const podeAlterarCompetenciaTarefa = caps?.fechamentos.tarefaAlterarCompetencia ?? false;
const podeExcluirLancamentoManual = caps?.fechamentos.tarefaExcluirManual ?? false;
const bloqueioEdicaoTarefa = isReadonly || !podeTarefaEditarRevisao;
const lancamentoPreviewPontos = useMemo(() => {
if (lancamentoModo !== "real" || parceiroFator == null || !(parceiroFator > 0)) return null;
const valor = parseValorRealMonetarioInput(lancamentoValorReal.trim());
if (!Number.isFinite(valor) || valor <= 0) return null;
return pontuacaoParaCimaAte2Casas(valor / (parceiroFator * 3));
}, [lancamentoModo, parceiroFator, lancamentoValorReal]);
const totais = useMemo(() => { const totais = useMemo(() => {
const aprovadas = tarefas.filter((t) => t.estaRevisada); const aprovadas = tarefas.filter((t) => t.estaRevisada);
@@ -322,6 +401,10 @@ export default function FechamentoDetalhes() {
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto"); setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
setParceiroId(current.parceiroId); setParceiroId(current.parceiroId);
setParceiroNome(current.parceiroNome); setParceiroNome(current.parceiroNome);
const f = current.parceiroFator;
setParceiroFator(
f !== undefined && f !== null && Number.isFinite(Number(f)) && Number(f) > 0 ? Number(f) : null,
);
if (!competenciaId) { if (!competenciaId) {
setCompetenciaId(current.competenciaId); setCompetenciaId(current.competenciaId);
} }
@@ -371,6 +454,11 @@ export default function FechamentoDetalhes() {
} }
}, [isFechado]); }, [isFechado]);
useEffect(() => {
if (!isLancamentoOpen || !lancamentoEmRealBloqueado) return;
setLancamentoModo("pontos");
}, [isLancamentoOpen, lancamentoEmRealBloqueado]);
useEffect(() => { useEffect(() => {
if (isConcluirOpen && parceiroId) { if (isConcluirOpen && parceiroId) {
void loadSaldoBanco(parceiroId); void loadSaldoBanco(parceiroId);
@@ -499,7 +587,9 @@ export default function FechamentoDetalhes() {
const resetLancamentoForm = () => { const resetLancamentoForm = () => {
setLancamentoTipo("bonus"); setLancamentoTipo("bonus");
setLancamentoDescricao(""); setLancamentoDescricao("");
setLancamentoModo("pontos");
setLancamentoPontuacao("0"); setLancamentoPontuacao("0");
setLancamentoValorReal("");
}; };
const handleSalvarLancamento = async () => { const handleSalvarLancamento = async () => {
@@ -512,27 +602,54 @@ export default function FechamentoDetalhes() {
return; return;
} }
const descricao = lancamentoDescricao.trim(); const descricao = lancamentoDescricao.trim();
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return Math.round(n * 100) / 100;
})();
if (!descricao) { if (!descricao) {
toast.error("Informe a descrição do lançamento."); toast.error("Informe a descrição do lançamento.");
return; return;
} }
if (lancamentoModo === "real") {
if (lancamentoEmRealBloqueado) {
toast.error("Primeiro cadastre o fator do parceiro para poder lançar em Real.");
return;
}
const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim());
if (!Number.isFinite(valorNum) || valorNum <= 0) {
toast.error("Informe um valor em real válido maior que zero.");
return;
}
} else {
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return pontuacaoParaCimaAte2Casas(n);
})();
if (!Number.isFinite(pontuacao) || pontuacao <= 0) { if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
toast.error("Informe uma pontuação válida maior que zero."); toast.error("Informe uma pontuação válida maior que zero.");
return; return;
} }
}
try { try {
setSavingLancamento(true); setSavingLancamento(true);
if (lancamentoModo === "real") {
const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim());
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
tipo: lancamentoTipo,
descricao,
valorReal: valorNum,
});
} else {
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return pontuacaoParaCimaAte2Casas(n);
})();
await fechamentoFechamentosService.criarLancamento(fechamentoId, { await fechamentoFechamentosService.criarLancamento(fechamentoId, {
tipo: lancamentoTipo, tipo: lancamentoTipo,
descricao, descricao,
pontuacao, pontuacao,
}); });
}
toast.success("Lançamento incluído com sucesso."); toast.success("Lançamento incluído com sucesso.");
setIsLancamentoOpen(false); setIsLancamentoOpen(false);
resetLancamentoForm(); resetLancamentoForm();
@@ -559,6 +676,29 @@ export default function FechamentoDetalhes() {
setIsConcluirOpen(true); setIsConcluirOpen(true);
}; };
const handleExportarPdf = async () => {
if (!fechamentoId || !isFechado) return;
try {
setExportandoPdf(true);
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportandoPdf(false);
}
};
const handleConcluirFechamento = async () => { const handleConcluirFechamento = async () => {
if (isCompetenciaConcluida) { if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida(); toastBloqueioCompetenciaConcluida();
@@ -590,6 +730,25 @@ export default function FechamentoDetalhes() {
}); });
toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`); toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`);
setFechamentoStatus("fechado"); setFechamentoStatus("fechado");
if (supervisorPodeExportarPdf) {
try {
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF do fechamento baixado.");
} catch {
toast.warning(
"Fechamento concluído, mas o PDF não pôde ser gerado automaticamente. Use Exportar PDF na tela.",
);
}
}
navigate(`/intelligence-score/competencias/${data.competenciaId}`); navigate(`/intelligence-score/competencias/${data.competenciaId}`);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento."; const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
@@ -705,8 +864,8 @@ export default function FechamentoDetalhes() {
}; };
const openAlterarCompetencia = async (tarefa: FechamentoTarefaItem) => { const openAlterarCompetencia = async (tarefa: FechamentoTarefaItem) => {
if (!isAdmin) { if (!podeAlterarCompetenciaTarefa) {
toast.error("Apenas administradores podem alterar competência."); toast.error("Sem permissão para alterar competência da tarefa.");
return; return;
} }
if (isCompetenciaConcluida) { if (isCompetenciaConcluida) {
@@ -768,7 +927,7 @@ export default function FechamentoDetalhes() {
} }
const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto"; const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto";
const descricao = edicaoDescricao.trim(); const descricao = edicaoDescricao.trim();
const pontuacao = parsePontuacaoInput(edicaoPontuacao); const pontuacao = pontuacaoParaCimaAte2Casas(parsePontuacaoInput(edicaoPontuacao));
if (!descricao) { if (!descricao) {
toast.error("Descrição é obrigatória."); toast.error("Descrição é obrigatória.");
return; return;
@@ -866,14 +1025,17 @@ export default function FechamentoDetalhes() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setIsLancamentoOpen(true)} onClick={() => {
disabled={isReadonly} resetLancamentoForm();
setIsLancamentoOpen(true);
}}
disabled={isReadonly || !podeTarefaLancarAjuste}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground" className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Fazer lançamento Fazer lançamento
</Button> </Button>
{!isReadonly ? ( {!isReadonly && podeReprocessarAsana ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -886,6 +1048,20 @@ export default function FechamentoDetalhes() {
</Button> </Button>
) : null} ) : null}
{isFechado ? ( {isFechado ? (
<>
{supervisorPodeExportarPdf ? (
<Button
size="sm"
variant="outline"
onClick={() => void handleExportarPdf()}
disabled={exportandoPdf || isCompetenciaConcluida}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<FileText className="mr-2 h-4 w-4" />
{exportandoPdf ? "Gerando PDF..." : "Exportar PDF"}
</Button>
) : null}
{podeReabrirFechamento ? (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -896,7 +1072,9 @@ export default function FechamentoDetalhes() {
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Reabrir fechamento Reabrir fechamento
</Button> </Button>
) : ( ) : null}
</>
) : podeConcluirFechamento ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -907,7 +1085,7 @@ export default function FechamentoDetalhes() {
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
Concluir fechamento Concluir fechamento
</Button> </Button>
)} ) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -980,7 +1158,7 @@ export default function FechamentoDetalhes() {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => void handleToggleTodasAprovadas()} onClick={() => void handleToggleTodasAprovadas()}
disabled={isReadonly || bulkUpdatingRevisao} disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao}
> >
{bulkUpdatingRevisao ? ( {bulkUpdatingRevisao ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -1070,7 +1248,7 @@ export default function FechamentoDetalhes() {
<Checkbox <Checkbox
checked={tarefa.estaRevisada} checked={tarefa.estaRevisada}
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))} onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
disabled={isReadonly || bulkUpdatingRevisao} disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao}
/> />
</div> </div>
</TableCell> </TableCell>
@@ -1110,18 +1288,18 @@ export default function FechamentoDetalhes() {
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => openEditarTarefa(tarefa)} onClick={() => openEditarTarefa(tarefa)}
disabled={isReadonly} disabled={bloqueioEdicaoTarefa}
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
<span className="ml-1">Editar</span> <span className="ml-1">Editar</span>
</Button> </Button>
{isAdmin && tarefa.tipo === "tarefa" ? ( {podeAlterarCompetenciaTarefa && tarefa.tipo === "tarefa" ? (
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => void openAlterarCompetencia(tarefa)} onClick={() => void openAlterarCompetencia(tarefa)}
disabled={isReadonly || savingAlteracaoCompetencia} disabled={bloqueioEdicaoTarefa || savingAlteracaoCompetencia}
> >
<Repeat className="h-4 w-4" /> <Repeat className="h-4 w-4" />
<span className="ml-1">Alterar competência</span> <span className="ml-1">Alterar competência</span>
@@ -1134,7 +1312,7 @@ export default function FechamentoDetalhes() {
variant="ghost" variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => void handleExcluirLancamento(tarefa)} onClick={() => void handleExcluirLancamento(tarefa)}
disabled={isReadonly || deletingTaskId === tarefa.id} disabled={bloqueioEdicaoTarefa || !podeExcluirLancamentoManual || deletingTaskId === tarefa.id}
> >
{deletingTaskId === tarefa.id ? ( {deletingTaskId === tarefa.id ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -1163,13 +1341,18 @@ export default function FechamentoDetalhes() {
)} )}
</div> </div>
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}> <Dialog
open={isLancamentoOpen}
onOpenChange={(open) => {
setIsLancamentoOpen(open);
if (!open) {
resetLancamentoForm();
}
}}
>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Fazer lançamento</DialogTitle> <DialogTitle>Fazer lançamento</DialogTitle>
<DialogDescription>
Adicione uma bonificação ou desconto em pontuação para este fechamento.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@@ -1189,6 +1372,42 @@ export default function FechamentoDetalhes() {
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label>Forma do lançamento</Label>
{lancamentoEmRealBloqueado ? (
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Primeiro cadastre o fator do parceiro para poder lançar em Real.
</AlertDescription>
</Alert>
) : null}
<RadioGroup
value={lancamentoModo}
onValueChange={(v) => setLancamentoModo(v as "pontos" | "real")}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pontos" id="lancamento-modo-pontos" />
<Label htmlFor="lancamento-modo-pontos" className="cursor-pointer font-normal">
Pontuação
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="real" id="lancamento-modo-real" disabled={lancamentoEmRealBloqueado} />
<Label
htmlFor="lancamento-modo-real"
className={
lancamentoEmRealBloqueado ? "cursor-not-allowed font-normal text-muted-foreground" : "cursor-pointer font-normal"
}
>
Valor em real (R$)
</Label>
</div>
</RadioGroup>
</div>
{lancamentoModo === "pontos" ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="lancamento-pontuacao">Pontuação</Label> <Label htmlFor="lancamento-pontuacao">Pontuação</Label>
<Input <Input
@@ -1200,6 +1419,29 @@ export default function FechamentoDetalhes() {
placeholder="Ex.: 1,5" placeholder="Ex.: 1,5"
/> />
</div> </div>
) : (
<div className="space-y-2">
<Label htmlFor="lancamento-valor-real">Valor (R$)</Label>
<Input
id="lancamento-valor-real"
type="text"
inputMode="decimal"
autoComplete="off"
value={lancamentoValorReal}
onChange={(e) => setLancamentoValorReal(sanitizeValorRealLancamentoDigitando(e.target.value))}
placeholder="Ex.: 90,00"
/>
{lancamentoPreviewPontos != null ? (
<p className="text-xs font-medium text-foreground">
Pontuação estimada: {formatPontos(lancamentoPreviewPontos)}
</p>
) : (
<p className="text-xs text-muted-foreground">
A conversão é feita ao salvar com o fator cadastrado do parceiro (valor ÷ (fator × 3)).
</p>
)}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="lancamento-descricao">Descrição</Label> <Label htmlFor="lancamento-descricao">Descrição</Label>
@@ -1224,7 +1466,15 @@ export default function FechamentoDetalhes() {
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}> <Button
onClick={() => void handleSalvarLancamento()}
disabled={
savingLancamento ||
isReadonly ||
!podeTarefaLancarAjuste ||
(lancamentoModo === "real" && lancamentoEmRealBloqueado)
}
>
{savingLancamento ? "Salvando..." : "Salvar lançamento"} {savingLancamento ? "Salvando..." : "Salvar lançamento"}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1531,7 +1781,7 @@ export default function FechamentoDetalhes() {
</Button> </Button>
<Button <Button
onClick={() => void handleConcluirFechamento()} onClick={() => void handleConcluirFechamento()}
disabled={concluindo || isReadonly} disabled={concluindo || isReadonly || !podeConcluirFechamento}
className="min-w-[160px] shadow-md hover:shadow-primary/40" className="min-w-[160px] shadow-md hover:shadow-primary/40"
> >
{concluindo ? ( {concluindo ? (
@@ -1717,7 +1967,7 @@ export default function FechamentoDetalhes() {
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}> <Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || bloqueioEdicaoTarefa}>
{savingEdicao ? "Salvando..." : "Salvar"} {savingEdicao ? "Salvando..." : "Salvar"}
</Button> </Button>
</DialogFooter> </DialogFooter>
+133 -30
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ClipboardList, ExternalLink, Plus } from "lucide-react"; import { ClipboardList, ExternalLink, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -8,6 +8,7 @@ import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/ca
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
@@ -15,6 +16,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias"; import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
const meses = [ const meses = [
@@ -36,8 +39,15 @@ function formatCompetenciaMes(mes: number, ano: number): string {
return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`; return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
} }
const TOOLTIP_EXCLUIR_COMPETENCIA_COM_FECHAMENTOS =
"Esta competência ainda possui fechamentos. Para excluir a competência, acesse-a e exclua todos os fechamentos primeiro; só então o botão Excluir será liberado.";
export default function Fechamentos() { export default function Fechamentos() {
const navigate = useNavigate(); const navigate = useNavigate();
const { me } = useAuthAccess();
const caps = me?.capabilities;
const podeCriarCompetencia = caps?.competencias.criar ?? false;
const podeExcluirCompetencia = caps?.competencias.excluir ?? false;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [allCompetencias, setAllCompetencias] = useState<CompetenciaItem[]>([]); const [allCompetencias, setAllCompetencias] = useState<CompetenciaItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -46,6 +56,22 @@ export default function Fechamentos() {
const [mesSelecionado, setMesSelecionado] = useState<string>(""); const [mesSelecionado, setMesSelecionado] = useState<string>("");
const [anoNovo, setAnoNovo] = useState<string>(""); const [anoNovo, setAnoNovo] = useState<string>("");
const [criando, setCriando] = useState(false); const [criando, setCriando] = useState(false);
const [competenciaExcluirId, setCompetenciaExcluirId] = useState<string | null>(null);
const [excluindoCompetencia, setExcluindoCompetencia] = useState(false);
const loadCompetencias = useCallback(async () => {
try {
setLoading(true);
const data = await fechamentoCompetenciasService.listarCompetencias({});
setAllCompetencias(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar competências.";
toast.error(message);
setAllCompetencias([]);
} finally {
setLoading(false);
}
}, []);
const anosDisponiveis = useMemo(() => { const anosDisponiveis = useMemo(() => {
const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))]; const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))];
@@ -64,33 +90,8 @@ export default function Fechamentos() {
}, [allCompetencias, anoSelecionado]); }, [allCompetencias, anoSelecionado]);
useEffect(() => { 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(); void loadCompetencias();
return () => { }, [loadCompetencias]);
cancelled = true;
};
}, []);
useEffect(() => { useEffect(() => {
if (anosDisponiveis.length > 0 && !anoSelecionado) { if (anosDisponiveis.length > 0 && !anoSelecionado) {
@@ -125,6 +126,25 @@ export default function Fechamentos() {
} }
}; };
const handleConfirmarExclusaoCompetencia = async () => {
if (!competenciaExcluirId || !me?.id) {
toast.error("Não foi possível identificar o usuário para excluir a competência.");
return;
}
setExcluindoCompetencia(true);
try {
await fechamentoCompetenciasService.excluirCompetencia(competenciaExcluirId, me.id);
toast.success("Competência excluída com sucesso.");
setCompetenciaExcluirId(null);
await loadCompetencias();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao excluir competência.";
toast.error(message);
} finally {
setExcluindoCompetencia(false);
}
};
return ( return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0"> <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="border-b border-border p-3 md:p-6">
@@ -134,12 +154,14 @@ export default function Fechamentos() {
Fechamentos Fechamentos
</h1> </h1>
<Dialog open={modalAberto} onOpenChange={setModalAberto}> <Dialog open={modalAberto} onOpenChange={setModalAberto}>
{podeCriarCompetencia ? (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm"> <Button size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Nova Competência Nova Competência
</Button> </Button>
</DialogTrigger> </DialogTrigger>
) : null}
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Nova Competência</DialogTitle> <DialogTitle>Nova Competência</DialogTitle>
@@ -225,7 +247,7 @@ export default function Fechamentos() {
<TableHead className="min-w-[220px]">Mês</TableHead> <TableHead className="min-w-[220px]">Mês</TableHead>
<TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead> <TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead> <TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Acessar</TableHead> <TableHead className="text-center min-w-[200px]">Ações</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -254,7 +276,7 @@ export default function Fechamentos() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-center"> <div className="flex flex-wrap justify-center gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -263,6 +285,47 @@ export default function Fechamentos() {
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" />
Acessar Acessar
</Button> </Button>
{podeExcluirCompetencia ? (
competencia.quantidadeFechamentos > 0 ? (
<span className="relative inline-flex">
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive opacity-80"
disabled
aria-label="Excluir competência (indisponível: há fechamentos)"
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<span
className="absolute inset-0 z-10 cursor-help rounded-md bg-transparent"
aria-hidden
/>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-left leading-snug">
{TOOLTIP_EXCLUIR_COMPETENCIA_COM_FECHAMENTOS}
</TooltipContent>
</Tooltip>
</span>
) : (
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
title="Excluir competência"
onClick={() => setCompetenciaExcluirId(competencia.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
)
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -273,6 +336,46 @@ export default function Fechamentos() {
</div> </div>
)} )}
</div> </div>
<Dialog open={competenciaExcluirId !== null} onOpenChange={(open) => !open && setCompetenciaExcluirId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir competência</DialogTitle>
<DialogDescription>
Esta ação não pode ser desfeita. é permitida quando não fechamentos vinculados.
{competenciaExcluirId ? (
<>
{" "}
Competência:{" "}
<span className="font-medium text-foreground">
{(() => {
const c = allCompetencias.find((x) => x.id === competenciaExcluirId);
return c ? formatCompetenciaMes(c.mes, c.ano) : "";
})()}
</span>
</>
) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setCompetenciaExcluirId(null)}
disabled={excluindoCompetencia}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void handleConfirmarExclusaoCompetencia()}
disabled={excluindoCompetencia}
>
{excluindoCompetencia ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, FileSpreadsheet, Landmark, ListChecks, Loader2, Target } from "lucide-react"; import { ExternalLink, FileSpreadsheet, FileText, Landmark, ListChecks, Loader2, Target } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,9 +37,12 @@ function formatPontos(valor: number): string {
export default function MeuFechamento() { export default function MeuFechamento() {
const navigate = useNavigate(); const navigate = useNavigate();
const { me } = useAuthAccess(); const { me } = useAuthAccess();
const caps = me?.capabilities;
const podeExportarPlanilha = caps?.fechamentos.exportarPlanilha ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null); const [exportacao, setExportacao] = useState<{ fechamentoId: string; tipo: "planilha" | "pdf" } | null>(null);
const [anoSelecionado, setAnoSelecionado] = useState<string>(String(currentYear)); const [anoSelecionado, setAnoSelecionado] = useState<string>(String(currentYear));
const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuFechamento>> | null>(null); const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuFechamento>> | null>(null);
@@ -103,7 +106,7 @@ export default function MeuFechamento() {
const handleExportar = async (fechamentoId: string) => { const handleExportar = async (fechamentoId: string) => {
try { try {
setExportingFechamentoId(fechamentoId); setExportacao({ fechamentoId, tipo: "planilha" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId); const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
const blob = new Blob([buffer], { const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -121,7 +124,29 @@ export default function MeuFechamento() {
const message = error instanceof Error ? error.message : "Erro ao exportar planilha."; const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
toast.error(message); toast.error(message);
} finally { } finally {
setExportingFechamentoId(null); setExportacao(null);
}
};
const handleExportarPdf = async (fechamentoId: string) => {
try {
setExportacao({ fechamentoId, tipo: "pdf" });
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportacao(null);
} }
}; };
@@ -251,15 +276,40 @@ export default function MeuFechamento() {
</TableCell> </TableCell>
<TableCell className="text-right">{formatPontos(Number(item.fechamento?.pontuacaoTotalEntregue ?? 0))}</TableCell> <TableCell className="text-right">{formatPontos(Number(item.fechamento?.pontuacaoTotalEntregue ?? 0))}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex flex-wrap items-center justify-center gap-2">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={!item.fechamento || item.fechamento.status !== "fechado" || exportingFechamentoId === item.fechamento.id} disabled={
!item.fechamento ||
item.fechamento.status !== "fechado" ||
!podeExportarPlanilha ||
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf")
}
onClick={() => item.fechamento && void handleExportar(item.fechamento.id)} onClick={() => item.fechamento && void handleExportar(item.fechamento.id)}
> >
<FileSpreadsheet className="mr-2 h-4 w-4" /> <FileSpreadsheet className="mr-2 h-4 w-4" />
{exportingFechamentoId === item.fechamento?.id ? "Exportando..." : "Exportar"} {exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha"
? "Exportando..."
: "Excel"}
</Button>
<Button
size="sm"
variant="outline"
disabled={
!item.fechamento ||
item.fechamento.status !== "fechado" ||
!podeExportarPdf ||
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "planilha") ||
(exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf")
}
onClick={() => item.fechamento && void handleExportarPdf(item.fechamento.id)}
>
<FileText className="mr-2 h-4 w-4" />
{exportacao?.fechamentoId === item.fechamento?.id && exportacao?.tipo === "pdf"
? "Exportando..."
: "PDF"}
</Button> </Button>
<Button <Button
size="sm" size="sm"
+10 -2
View File
@@ -1,8 +1,16 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
export default function NotFound() { export default function NotFound() {
const location = useLocation(); const location = useLocation();
const { papel } = useAuthAccess();
const homeHref =
papel === "admin" || papel === "supervisor"
? "/intelligence-score/dashboard"
: "/intelligence-score/meu-fechamento";
const homeLabel =
papel === "admin" || papel === "supervisor" ? "Voltar ao início" : "Voltar ao meu fechamento";
useEffect(() => { useEffect(() => {
console.error( console.error(
@@ -16,8 +24,8 @@ export default function NotFound() {
<div className="text-center"> <div className="text-center">
<h1 className="mb-3 text-4xl font-bold">404</h1> <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> <p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
<Link to="/intelligence-score" className="text-primary underline hover:text-primary/90"> <Link to={homeHref} className="text-primary underline hover:text-primary/90">
Voltar para Fechamentos {homeLabel}
</Link> </Link>
</div> </div>
</div> </div>
+149 -8
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react"; import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
import { Building2, Edit, Image as ImageIcon, Loader2, Plus, Power, Upload } from "lucide-react"; import { Building2, Edit, Eye, EyeOff, Image as ImageIcon, Loader2, Plus, Power, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -38,6 +38,7 @@ import {
type ParceiroItem, type ParceiroItem,
type ParceiroStatusFiltro, type ParceiroStatusFiltro,
type ParceiroTipoPessoa, type ParceiroTipoPessoa,
type SalvarParceiroPayload,
} from "@/services/fechamento/parceiros"; } from "@/services/fechamento/parceiros";
import { fechamentoUploadsService } from "@/services/fechamento/uploads"; import { fechamentoUploadsService } from "@/services/fechamento/uploads";
import { fechamentoConfiguracoesService } from "@/services/fechamento/configuracoes"; import { fechamentoConfiguracoesService } from "@/services/fechamento/configuracoes";
@@ -45,6 +46,7 @@ import {
fechamentoAsanaWorkspaceUsersService, fechamentoAsanaWorkspaceUsersService,
type AsanaWorkspaceUser, type AsanaWorkspaceUser,
} from "@/services/fechamento/asanaWorkspaceUsers"; } from "@/services/fechamento/asanaWorkspaceUsers";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
@@ -61,8 +63,11 @@ type ParceiroForm = {
logoUrl: string; logoUrl: string;
observacoes: string; observacoes: string;
pontuacaoMeta: string; pontuacaoMeta: string;
fator: string;
}; };
const FATOR_CENSURADO_VISUAL = "••••••";
const FORM_INICIAL: ParceiroForm = { const FORM_INICIAL: ParceiroForm = {
nome: "", nome: "",
codinome: "", codinome: "",
@@ -75,6 +80,7 @@ const FORM_INICIAL: ParceiroForm = {
logoUrl: "", logoUrl: "",
observacoes: "", observacoes: "",
pontuacaoMeta: "0", pontuacaoMeta: "0",
fator: "",
}; };
function normalizeOptionalText(value: string): string | null { function normalizeOptionalText(value: string): string | null {
@@ -185,7 +191,62 @@ function getTipoPessoaLabel(tipoPessoa: ParceiroTipoPessoa): string {
return tipoPessoa === "fisica" ? "Física" : "Jurídica"; return tipoPessoa === "fisica" ? "Física" : "Jurídica";
} }
/** Exibe número do fator no formulário (pt-BR, até 2 decimais). */
function formatFatorForFormInput(valor: number): string {
return Number(valor).toLocaleString("pt-BR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
useGrouping: false,
});
}
/**
* Aceita apenas dígitos e um separador decimal (`,` ou `.`), no máximo 2 casas decimais.
* Saída normalizada com vírgula decimal (pt-BR).
*/
function sanitizeFatorInputValue(raw: string): string {
const cleaned = raw.replace(/[^\d.,]/g, "");
if (!cleaned) return "";
let int = "";
let frac = "";
let sawSeparator = false;
for (const ch of cleaned) {
if (ch === "," || ch === ".") {
if (!sawSeparator) sawSeparator = true;
continue;
}
if (!sawSeparator) int += ch;
else if (frac.length < 2) frac += ch;
}
if (!sawSeparator) return int;
return frac.length > 0 ? `${int},${frac}` : `${int},`;
}
/** Parse valor monetário (pt-BR com vírgula decimal ou milhar com ponto). */
function parseFatorMonetarioInput(raw: string): number {
let t = raw.trim().replace(/\s/g, "").replace(/R\$\s?/gi, "");
if (!t) return Number.NaN;
if (t.includes(",") && t.includes(".")) {
t = t.replace(/\./g, "").replace(",", ".");
} else if (t.includes(",")) {
t = t.replace(",", ".");
}
return Number(t);
}
export default function Parceiros() { export default function Parceiros() {
const { me } = useAuthAccess();
const caps = me?.capabilities;
const podeCriarParceiro = caps?.parceiros.criar ?? false;
const podeEditarParceiro = caps?.parceiros.editar ?? false;
const podeToggleParceiro = caps?.parceiros.inativarToggle ?? false;
const verFator = caps?.parceiros.verFator ?? false;
const editarFator = caps?.parceiros.editarFator ?? false;
const tabelaColSpan = 7;
const logoFileInputRef = useRef<HTMLInputElement | null>(null); const logoFileInputRef = useRef<HTMLInputElement | null>(null);
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]); const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
const [loadingList, setLoadingList] = useState(true); const [loadingList, setLoadingList] = useState(true);
@@ -206,6 +267,7 @@ export default function Parceiros() {
const [isToggleOpen, setIsToggleOpen] = useState(false); const [isToggleOpen, setIsToggleOpen] = useState(false);
const [selectedParceiro, setSelectedParceiro] = useState<ParceiroItem | null>(null); const [selectedParceiro, setSelectedParceiro] = useState<ParceiroItem | null>(null);
const [form, setForm] = useState<ParceiroForm>(FORM_INICIAL); const [form, setForm] = useState<ParceiroForm>(FORM_INICIAL);
const [mostrarFatorClaro, setMostrarFatorClaro] = useState(false);
const [selectedLogoFile, setSelectedLogoFile] = useState<File | null>(null); const [selectedLogoFile, setSelectedLogoFile] = useState<File | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [asanaUsers, setAsanaUsers] = useState<AsanaWorkspaceUser[]>([]); const [asanaUsers, setAsanaUsers] = useState<AsanaWorkspaceUser[]>([]);
@@ -360,6 +422,7 @@ export default function Parceiros() {
setAsanaUsers([]); setAsanaUsers([]);
setAsanaWorkspaceId(""); setAsanaWorkspaceId("");
setAsanaWorkspaceNome(""); setAsanaWorkspaceNome("");
setMostrarFatorClaro(false);
}; };
const openCreateDialog = () => { const openCreateDialog = () => {
@@ -383,6 +446,7 @@ export default function Parceiros() {
logoUrl: parceiro.logoUrl ?? "", logoUrl: parceiro.logoUrl ?? "",
observacoes: parceiro.observacoes ?? "", observacoes: parceiro.observacoes ?? "",
pontuacaoMeta: String(parceiro.pontuacaoMeta ?? 0), pontuacaoMeta: String(parceiro.pontuacaoMeta ?? 0),
fator: verFator && typeof parceiro.fator === "number" ? formatFatorForFormInput(parceiro.fator) : "",
}); });
setIsFormOpen(true); setIsFormOpen(true);
}; };
@@ -485,6 +549,19 @@ export default function Parceiros() {
return false; return false;
} }
if (editarFator) {
const fatorRaw = form.fator.trim();
if (!fatorRaw) {
toast.error("Informe o fator (valor monetário maior que zero).");
return false;
}
const fatorNum = parseFatorMonetarioInput(fatorRaw);
if (!Number.isFinite(fatorNum) || fatorNum <= 0) {
toast.error("O fator deve ser um valor monetário válido e maior que zero.");
return false;
}
}
return true; return true;
}; };
@@ -510,9 +587,9 @@ export default function Parceiros() {
} }
}; };
const buildPayload = async () => { const buildPayload = async (): Promise<SalvarParceiroPayload> => {
const logoUrl = await uploadLogoIfNeeded(); const logoUrl = await uploadLogoIfNeeded();
return { const payload: SalvarParceiroPayload = {
nome: form.nome.trim(), nome: form.nome.trim(),
codinome: normalizeOptionalText(form.codinome), codinome: normalizeOptionalText(form.codinome),
tipoPessoa: form.tipoPessoa, tipoPessoa: form.tipoPessoa,
@@ -525,6 +602,11 @@ export default function Parceiros() {
observacoes: normalizeOptionalText(form.observacoes), observacoes: normalizeOptionalText(form.observacoes),
pontuacaoMeta: Number(form.pontuacaoMeta), pontuacaoMeta: Number(form.pontuacaoMeta),
}; };
if (editarFator) {
const fatorNum = parseFatorMonetarioInput(form.fator.trim());
payload.fator = fatorNum;
}
return payload;
}; };
const handleSaveParceiro = async () => { const handleSaveParceiro = async () => {
@@ -587,7 +669,7 @@ export default function Parceiros() {
<Building2 className="h-5 w-5 md:h-6 md:w-6" /> <Building2 className="h-5 w-5 md:h-6 md:w-6" />
Parceiros Parceiros
</h1> </h1>
<Button onClick={openCreateDialog} className="gap-2"> <Button onClick={openCreateDialog} className="gap-2" disabled={!podeCriarParceiro}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Novo parceiro Novo parceiro
</Button> </Button>
@@ -643,10 +725,14 @@ export default function Parceiros() {
<CardDescription>Crie o primeiro parceiro para iniciar o módulo de fechamentos.</CardDescription> <CardDescription>Crie o primeiro parceiro para iniciar o módulo de fechamentos.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{podeCriarParceiro ? (
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Novo parceiro Novo parceiro
</Button> </Button>
) : (
<p className="text-sm text-muted-foreground">Não permissão para criar parceiros neste perfil.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -667,13 +753,13 @@ export default function Parceiros() {
<TableBody> <TableBody>
{loadingList ? ( {loadingList ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground"> <TableCell colSpan={tabelaColSpan} className="py-8 text-center text-muted-foreground">
Carregando... Carregando...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : totalRegistros === 0 ? ( ) : totalRegistros === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground"> <TableCell colSpan={tabelaColSpan} className="py-8 text-center text-muted-foreground">
Nenhum parceiro encontrado para os filtros atuais. Nenhum parceiro encontrado para os filtros atuais.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -702,6 +788,7 @@ export default function Parceiros() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{podeEditarParceiro ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -710,6 +797,8 @@ export default function Parceiros() {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
) : null}
{podeToggleParceiro ? (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -718,6 +807,7 @@ export default function Parceiros() {
> >
<Power className="h-4 w-4" /> <Power className="h-4 w-4" />
</Button> </Button>
) : null}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -993,6 +1083,50 @@ export default function Parceiros() {
/> />
</div> </div>
{editarFator ? (
<div>
<Label htmlFor="parceiro-fator">
Fator <span className="text-destructive">*</span>
</Label>
<div className="relative">
<Input
id="parceiro-fator"
type={mostrarFatorClaro ? "text" : "password"}
inputMode="decimal"
autoComplete="off"
value={form.fator}
onChange={(e) =>
setForm((prev) => ({ ...prev, fator: sanitizeFatorInputValue(e.target.value) }))
}
placeholder="R$ 50,00"
className="pr-10"
/>
<button
type="button"
onClick={() => setMostrarFatorClaro((prev) => !prev)}
title={mostrarFatorClaro ? "Ocultar valor" : "Mostrar valor"}
aria-label={mostrarFatorClaro ? "Ocultar valor do fator" : "Mostrar valor do fator"}
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
{mostrarFatorClaro ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">Valor monetário real (ex.: R$ 50,00)</p>
</div>
) : podeEditarParceiro ? (
<div>
<Label htmlFor="parceiro-fator-censurado">Fator</Label>
<div
id="parceiro-fator-censurado"
className="flex h-10 items-center rounded-md border border-input bg-muted px-3 font-mono tracking-[0.35em] text-muted-foreground"
aria-hidden
>
{FATOR_CENSURADO_VISUAL}
</div>
<p className="mt-1 text-xs text-muted-foreground">O valor é visível para o admin.</p>
</div>
) : null}
<div className="md:col-span-2"> <div className="md:col-span-2">
<Label htmlFor="parceiro-observacoes">Observações</Label> <Label htmlFor="parceiro-observacoes">Observações</Label>
<Textarea <Textarea
@@ -1016,7 +1150,14 @@ export default function Parceiros() {
> >
Cancelar Cancelar
</Button> </Button>
<Button onClick={handleSaveParceiro} disabled={saving || uploadingLogo}> <Button
onClick={handleSaveParceiro}
disabled={
saving ||
uploadingLogo ||
(isEditMode ? !podeEditarParceiro : !podeCriarParceiro)
}
>
{uploadingLogo ? "Enviando logo..." : formActionLabel} {uploadingLogo ? "Enviando logo..." : formActionLabel}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1045,7 +1186,7 @@ export default function Parceiros() {
<AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={toggling}> <AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={toggling}>
Cancelar Cancelar
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedParceiro}> <AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedParceiro || !podeToggleParceiro}>
{toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"} {toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
+27 -1
View File
@@ -42,6 +42,13 @@ import {
fechamentoParceirosService, fechamentoParceirosService,
type ParceiroItem, type ParceiroItem,
} from "@/services/fechamento/parceiros"; } from "@/services/fechamento/parceiros";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
function labelUsuarioPapel(papel: UsuarioPapel): string {
if (papel === "admin") return "Admin";
if (papel === "supervisor") return "Supervisor";
return "Parceiro";
}
type UsuarioForm = { type UsuarioForm = {
nome: string; nome: string;
@@ -75,6 +82,8 @@ function getEmailUsuario(usuario: UsuarioItem | null | undefined): string {
} }
export default function Usuarios() { export default function Usuarios() {
const { me } = useAuthAccess();
const podeEditarUsuarios = me?.capabilities.usuarios.criarEditarToggle ?? false;
const [usuarios, setUsuarios] = useState<UsuarioItem[]>([]); const [usuarios, setUsuarios] = useState<UsuarioItem[]>([]);
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]); const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
@@ -309,10 +318,12 @@ export default function Usuarios() {
<Users className="h-5 w-5 md:h-6 md:w-6" /> <Users className="h-5 w-5 md:h-6 md:w-6" />
Usuários Usuários
</h1> </h1>
{podeEditarUsuarios ? (
<Button onClick={openCreateDialog} className="gap-2"> <Button onClick={openCreateDialog} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Novo usuário Novo usuário
</Button> </Button>
) : null}
</div> </div>
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4"> <div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4">
@@ -332,6 +343,7 @@ export default function Usuarios() {
<SelectContent> <SelectContent>
<SelectItem value="all">Todos os perfis</SelectItem> <SelectItem value="all">Todos os perfis</SelectItem>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="supervisor">Supervisor</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem> <SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -380,10 +392,14 @@ export default function Usuarios() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{podeEditarUsuarios ? (
<Button onClick={openCreateDialog}> <Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Novo usuário Novo usuário
</Button> </Button>
) : (
<p className="text-sm text-muted-foreground">Não usuários cadastrados nesta unidade.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -422,10 +438,12 @@ export default function Usuarios() {
className={ className={
usuario.papel === "admin" usuario.papel === "admin"
? "border-blue-600/30 bg-blue-50 font-normal text-blue-800 hover:bg-blue-50 dark:bg-blue-950/40 dark:text-blue-200" ? "border-blue-600/30 bg-blue-50 font-normal text-blue-800 hover:bg-blue-50 dark:bg-blue-950/40 dark:text-blue-200"
: usuario.papel === "supervisor"
? "border-violet-600/30 bg-violet-50 font-normal text-violet-800 hover:bg-violet-50 dark:bg-violet-950/40 dark:text-violet-200"
: "border-slate-500/25 bg-slate-100 font-normal text-slate-700 hover:bg-slate-100 dark:bg-slate-800/60 dark:text-slate-200" : "border-slate-500/25 bg-slate-100 font-normal text-slate-700 hover:bg-slate-100 dark:bg-slate-800/60 dark:text-slate-200"
} }
> >
{usuario.papel === "admin" ? "Admin" : "Parceiro"} {labelUsuarioPapel(usuario.papel)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -441,6 +459,8 @@ export default function Usuarios() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{podeEditarUsuarios ? (
<>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -457,6 +477,10 @@ export default function Usuarios() {
> >
<Power className="h-4 w-4" /> <Power className="h-4 w-4" />
</Button> </Button>
</>
) : (
<span className="text-xs text-muted-foreground">Somente leitura</span>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -554,6 +578,7 @@ export default function Usuarios() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="supervisor">Supervisor</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem> <SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -649,6 +674,7 @@ export default function Usuarios() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="supervisor">Supervisor</SelectItem>
<SelectItem value="parceiro">Parceiro</SelectItem> <SelectItem value="parceiro">Parceiro</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
+6 -2
View File
@@ -2,8 +2,9 @@ import axios from "axios";
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp"; import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
import { normalizeBancoPontosExtratoItem } from "./bancoPontos"; import { normalizeBancoPontosExtratoItem } from "./bancoPontos";
import { type Capabilities, type PapelUsuario, getCapabilitiesForPapel } from "./commanderCapabilities";
export type PapelUsuario = "admin" | "parceiro"; export type { Capabilities, PapelUsuario } from "./commanderCapabilities";
export type MeData = { export type MeData = {
id: string; id: string;
@@ -13,6 +14,7 @@ export type MeData = {
parceiroId: string | null; parceiroId: string | null;
estaAtivo: boolean; estaAtivo: boolean;
unidadeId: string; unidadeId: string;
capabilities: Capabilities;
}; };
export type BootstrapStatusData = { export type BootstrapStatusData = {
@@ -194,7 +196,9 @@ class AuthMeService {
params: { usuarioEmail }, params: { usuarioEmail },
headers, headers,
}); });
return response.data.data; const row = response.data.data;
const capabilities = row.capabilities ?? getCapabilitiesForPapel(row.papel);
return { ...row, capabilities };
} catch (error: unknown) { } catch (error: unknown) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const status = error.response?.status; const status = error.response?.status;
@@ -0,0 +1,150 @@
/** Espelha `OPEN_COMMANDER_BACKEND/src/security/capabilities.ts` para fallback e tipagem no app. */
export type PapelUsuario = "admin" | "parceiro" | "supervisor";
export type Capabilities = {
competencias: {
listar: boolean;
criar: boolean;
excluir: boolean;
concluir: boolean;
reabrir: boolean;
importarAsana: boolean;
};
fechamentos: {
exportarPlanilha: boolean;
exportarPdf: boolean;
concluir: boolean;
reabrir: boolean;
excluir: boolean;
reprocessarAsana: boolean;
tarefaAlterarCompetencia: boolean;
tarefaLancarAjuste: boolean;
tarefaEditarRevisao: boolean;
tarefaExcluirManual: boolean;
};
bancoPontos: { acessoAdmin: boolean };
parceiros: {
listar: boolean;
criar: boolean;
editar: boolean;
inativarToggle: boolean;
verFator: boolean;
editarFator: boolean;
};
usuarios: { listar: boolean; criarEditarToggle: boolean };
configuracoes: { salvar: boolean };
};
function adminCapabilities(): Capabilities {
return {
competencias: {
listar: true,
criar: true,
excluir: true,
concluir: true,
reabrir: true,
importarAsana: true,
},
fechamentos: {
exportarPlanilha: true,
exportarPdf: true,
concluir: true,
reabrir: true,
excluir: true,
reprocessarAsana: true,
tarefaAlterarCompetencia: true,
tarefaLancarAjuste: true,
tarefaEditarRevisao: true,
tarefaExcluirManual: true,
},
bancoPontos: { acessoAdmin: true },
parceiros: {
listar: true,
criar: true,
editar: true,
inativarToggle: true,
verFator: true,
editarFator: true,
},
usuarios: { listar: true, criarEditarToggle: true },
configuracoes: { salvar: true },
};
}
function parceiroCapabilities(): Capabilities {
return {
competencias: {
listar: false,
criar: false,
excluir: false,
concluir: false,
reabrir: false,
importarAsana: false,
},
fechamentos: {
exportarPlanilha: false,
exportarPdf: false,
concluir: false,
reabrir: false,
excluir: false,
reprocessarAsana: false,
tarefaAlterarCompetencia: false,
tarefaLancarAjuste: false,
tarefaEditarRevisao: false,
tarefaExcluirManual: false,
},
bancoPontos: { acessoAdmin: false },
parceiros: {
listar: false,
criar: false,
editar: false,
inativarToggle: false,
verFator: false,
editarFator: false,
},
usuarios: { listar: false, criarEditarToggle: false },
configuracoes: { salvar: false },
};
}
function supervisorCapabilities(): Capabilities {
return {
competencias: {
listar: true,
criar: false,
excluir: false,
concluir: false,
reabrir: false,
importarAsana: false,
},
fechamentos: {
exportarPlanilha: true,
exportarPdf: true,
concluir: false,
reabrir: false,
excluir: false,
reprocessarAsana: false,
tarefaAlterarCompetencia: false,
tarefaLancarAjuste: false,
tarefaEditarRevisao: false,
tarefaExcluirManual: false,
},
bancoPontos: { acessoAdmin: true },
parceiros: {
listar: true,
criar: true,
editar: true,
inativarToggle: true,
verFator: false,
editarFator: false,
},
usuarios: { listar: true, criarEditarToggle: false },
configuracoes: { salvar: false },
};
}
export function getCapabilitiesForPapel(papel: string): Capabilities {
if (papel === "admin") return adminCapabilities();
if (papel === "supervisor") return supervisorCapabilities();
return parceiroCapabilities();
}
+16 -1
View File
@@ -1,4 +1,4 @@
import { GlobalFunctions } from "@/GlobalFunctions"; import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
import { resolveCommanderUnidadeId } from "./unidadeContext"; import { resolveCommanderUnidadeId } from "./unidadeContext";
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl"; import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
@@ -10,6 +10,17 @@ export type BuildCommanderHeadersOptions = {
omitUnidadeId?: boolean; omitUnidadeId?: boolean;
}; };
function resolveUsuarioEmailForCommanderHeader(): string | undefined {
let email = GlobalFunctions.getUsuarioLogado().email?.trim();
if (!email) {
const transfer = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
if (typeof transfer === "string" && transfer.trim()) {
email = transfer.trim();
}
}
return email || undefined;
}
export async function buildCommanderHeaders( export async function buildCommanderHeaders(
opts?: BuildCommanderHeadersOptions, opts?: BuildCommanderHeadersOptions,
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
@@ -20,6 +31,10 @@ export async function buildCommanderHeaders(
...(apiKey ? { apikey: apiKey } : {}), ...(apiKey ? { apikey: apiKey } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
}; };
const usuarioEmail = resolveUsuarioEmailForCommanderHeader();
if (usuarioEmail) {
headers["X-Usuario-Email"] = usuarioEmail;
}
if (!opts?.omitUnidadeId) { if (!opts?.omitUnidadeId) {
headers["X-Unidade-Id"] = await resolveCommanderUnidadeId(); headers["X-Unidade-Id"] = await resolveCommanderUnidadeId();
} }
+16
View File
@@ -21,13 +21,16 @@ export type FechamentoDaCompetenciaItem = {
parceiroNome: string; parceiroNome: string;
parceiroCodinome: string | null; parceiroCodinome: string | null;
parceiroLogoUrl: string | null; parceiroLogoUrl: string | null;
parceiroFator?: number | null;
horasTotal: number; horasTotal: number;
status: "em_aberto" | "fechado"; status: "em_aberto" | "fechado";
versao: number;
pontuacaoTotalEntregue: number; pontuacaoTotalEntregue: number;
pontuacaoMeta: number; pontuacaoMeta: number;
pontuacaoPaga: number; pontuacaoPaga: number;
pontuacaoBanco: number; pontuacaoBanco: number;
exportadoFinanceiro: boolean; exportadoFinanceiro: boolean;
fechadoPorId: string | null;
fechadoEm: string | null; fechadoEm: string | null;
criadoEm: string; criadoEm: string;
atualizadoEm: string; atualizadoEm: string;
@@ -175,6 +178,19 @@ class FechamentoCompetenciasService {
} }
} }
async excluirCompetencia(competenciaId: string, excluidoPorId: string): Promise<void> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
await axios.delete(`${baseUrl}/competencias/${competenciaId}`, {
headers,
data: { excluido_por_id: excluidoPorId },
});
} catch (error) {
this.handleAxiosError(error, "Erro ao excluir competência.");
}
}
async importarDoAsana( async importarDoAsana(
competenciaId: string, competenciaId: string,
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] }, opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
+6
View File
@@ -8,6 +8,9 @@ export type ConfiguracaoPublica = {
asanaWorkspaceNome: string | null; asanaWorkspaceNome: string | null;
asanaToken: string | null; asanaToken: string | null;
asanaTokenConfigured: boolean; asanaTokenConfigured: boolean;
empresaRazaoSocial: string | null;
empresaCnpj: string | null;
empresaLogoUrl: string | null;
atualizadoEm: string; atualizadoEm: string;
atualizadoPorId: string | null; atualizadoPorId: string | null;
}; };
@@ -20,6 +23,9 @@ type SalvarConfiguracoesBody = {
asanaToken?: string; asanaToken?: string;
asanaWorkspaceId?: string | null; asanaWorkspaceId?: string | null;
asanaWorkspaceNome?: string | null; asanaWorkspaceNome?: string | null;
empresaRazaoSocial?: string | null;
empresaCnpj?: string | null;
empresaLogoUrl?: string | null;
}; };
type SalvarConfiguracoesResponse = { type SalvarConfiguracoesResponse = {
@@ -0,0 +1,73 @@
import axios from "axios";
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
export type DashboardPontosClienteLinha = {
cliente: string;
pontos: number;
};
export type DashboardPontosPorParceiro = {
parceiroId: string;
parceiroNome: string;
linhas: DashboardPontosClienteLinha[];
totalParceiro: number;
};
type RelatorioResponse = {
data: DashboardPontosPorParceiro[];
};
type ApiErrorShape = {
error?: {
message?: string;
};
};
export type RelatorioPontosPorClienteParams = {
dataInicio?: string;
dataFim?: string;
parceiroId?: string;
cliente?: string;
};
class DashboardPontosPorClienteService {
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 relatorioPontosPorCliente(params: RelatorioPontosPorClienteParams): Promise<DashboardPontosPorParceiro[]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<RelatorioResponse>(
`${baseUrl}/fechamentos/relatorio/pontos-por-cliente`,
{
headers,
params: {
data_inicio: params.dataInicio?.trim() || undefined,
data_fim: params.dataFim?.trim() || undefined,
parceiro_id: params.parceiroId?.trim() || undefined,
cliente: params.cliente?.trim() || undefined,
},
},
);
return response.data.data ?? [];
} catch (error) {
this.handleAxiosError(error, "Erro ao carregar relatório de pontos por cliente.");
}
}
}
export const dashboardPontosPorClienteService = new DashboardPontosPorClienteService();
+70 -11
View File
@@ -121,16 +121,25 @@ class FechamentoFechamentosService {
private extractFilenameFromContentDisposition(value: string | undefined): string | null { private extractFilenameFromContentDisposition(value: string | undefined): string | null {
if (!value) return null; if (!value) return null;
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); const utf8Match = value.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/i);
if (utf8Match?.[1]) { if (utf8Match?.[1]) {
try { try {
return decodeURIComponent(utf8Match[1]); return decodeURIComponent(utf8Match[1].trim());
} catch { } catch {
return utf8Match[1]; return utf8Match[1].trim();
} }
} }
const regularMatch = value.match(/filename="?([^";]+)"?/i); const regularMatch = value.match(/filename\s*=\s*"([^"]+)"/i) ?? value.match(/filename\s*=\s*([^;\s]+)/i);
return regularMatch?.[1] ?? null; return regularMatch?.[1]?.trim() ?? null;
}
private contentDispositionFromHeaders(headers: unknown): string | undefined {
if (!headers || typeof headers !== "object") return undefined;
const h = headers as { get?: (name: string) => string | undefined };
const fromGet = h.get?.("content-disposition") ?? h.get?.("Content-Disposition");
if (fromGet) return fromGet;
const rec = headers as Record<string, string | undefined>;
return rec["content-disposition"] ?? rec["Content-Disposition"];
} }
private handleAxiosError(error: unknown, fallback: string): never { private handleAxiosError(error: unknown, fallback: string): never {
@@ -202,18 +211,28 @@ class FechamentoFechamentosService {
async criarLancamento( async criarLancamento(
fechamentoId: string, fechamentoId: string,
input: { tipo: "bonus" | "desconto"; descricao: string; pontuacao: number }, input:
| { tipo: "bonus" | "desconto"; descricao: string; pontuacao: number }
| { tipo: "bonus" | "desconto"; descricao: string; valorReal: number },
): Promise<FechamentoTarefaItem> { ): Promise<FechamentoTarefaItem> {
try { try {
const headers = await buildCommanderHeaders(); const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl(); const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<CriarLancamentoResponse>( const body =
`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, "valorReal" in input
{ ? {
tipo: input.tipo,
descricao: input.descricao,
valorReal: input.valorReal,
}
: {
tipo: input.tipo, tipo: input.tipo,
descricao: input.descricao, descricao: input.descricao,
pontuacao: input.pontuacao, pontuacao: input.pontuacao,
}, };
const response = await axios.post<CriarLancamentoResponse>(
`${baseUrl}/fechamentos/${fechamentoId}/tarefas`,
body,
{ headers }, { headers },
); );
return response.data.data; return response.data.data;
@@ -268,13 +287,34 @@ class FechamentoFechamentosService {
}); });
return { return {
buffer: response.data, buffer: response.data,
filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]), filename: this.extractFilenameFromContentDisposition(
this.contentDispositionFromHeaders(response.headers),
),
}; };
} catch (error) { } catch (error) {
this.handleAxiosError(error, "Erro ao exportar planilha do fechamento."); this.handleAxiosError(error, "Erro ao exportar planilha do fechamento.");
} }
} }
async exportarPdf(fechamentoId: string): Promise<ExportarPlanilhaResponse> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.get<ArrayBuffer>(`${baseUrl}/fechamentos/${fechamentoId}/exportar-pdf`, {
headers,
responseType: "arraybuffer",
});
return {
buffer: response.data,
filename: this.extractFilenameFromContentDisposition(
this.contentDispositionFromHeaders(response.headers),
),
};
} catch (error) {
this.handleAxiosError(error, "Erro ao exportar PDF do fechamento.");
}
}
async reabrirFechamento( async reabrirFechamento(
fechamentoId: string, fechamentoId: string,
input: { reabertoPorId: string; motivo: string }, input: { reabertoPorId: string; motivo: string },
@@ -296,6 +336,25 @@ class FechamentoFechamentosService {
} }
} }
async excluirFechamento(
fechamentoId: string,
input: { excluidoPorId: string; motivo: string },
): Promise<void> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
await axios.delete(`${baseUrl}/fechamentos/${fechamentoId}`, {
headers,
data: {
excluido_por_id: input.excluidoPorId,
motivo: input.motivo.trim(),
},
});
} catch (error) {
this.handleAxiosError(error, "Erro ao excluir fechamento.");
}
}
async reprocessarAsana(fechamentoId: string): Promise<ReprocessarAsanaResponse["data"]> { async reprocessarAsana(fechamentoId: string): Promise<ReprocessarAsanaResponse["data"]> {
try { try {
const headers = await buildCommanderHeaders(); const headers = await buildCommanderHeaders();
+2
View File
@@ -18,6 +18,7 @@ export type ParceiroItem = {
logoUrl: string | null; logoUrl: string | null;
observacoes: string | null; observacoes: string | null;
pontuacaoMeta: number; pontuacaoMeta: number;
fator?: number | null;
estaAtivo: boolean; estaAtivo: boolean;
criadoEm: string; criadoEm: string;
atualizadoEm: string; atualizadoEm: string;
@@ -59,6 +60,7 @@ export type SalvarParceiroPayload = {
logoUrl?: string | null; logoUrl?: string | null;
observacoes?: string | null; observacoes?: string | null;
pontuacaoMeta: number; pontuacaoMeta: number;
fator?: number | null;
estaAtivo?: boolean; estaAtivo?: boolean;
}; };
+17
View File
@@ -22,6 +22,8 @@ export type PresignParceiroLogoPayload = {
contentType: "image/jpeg" | "image/png" | "image/webp"; contentType: "image/jpeg" | "image/png" | "image/webp";
}; };
export type PresignEmpresaLogoPayload = PresignParceiroLogoPayload;
class FechamentoUploadsService { class FechamentoUploadsService {
private handleAxiosError(error: unknown, fallback: string): never { private handleAxiosError(error: unknown, fallback: string): never {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -53,6 +55,21 @@ class FechamentoUploadsService {
} }
} }
async presignUploadEmpresaLogo(payload: PresignEmpresaLogoPayload) {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<PresignParceiroLogoResponse>(
`${baseUrl}/uploads/configuracoes/empresa-logo/presign`,
payload,
{ headers },
);
return response.data.data;
} catch (error) {
this.handleAxiosError(error, "Erro ao preparar upload da logo da empresa.");
}
}
async uploadFileToSignedUrl(uploadUrl: string, file: File): Promise<void> { async uploadFileToSignedUrl(uploadUrl: string, file: File): Promise<void> {
try { try {
await axios.put(uploadUrl, file, { await axios.put(uploadUrl, file, {
+1 -1
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp"; import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
export type UsuarioPapel = "admin" | "parceiro"; export type UsuarioPapel = "admin" | "parceiro" | "supervisor";
export type UsuarioStatusFiltro = "all" | "true" | "false"; export type UsuarioStatusFiltro = "all" | "true" | "false";
export type UsuarioItem = { export type UsuarioItem = {