atualizacoes modulo fechamento
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
||||
import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
||||
import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { AuthGate } from "@/components/auth/AuthGate";
|
||||
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
|
||||
|
||||
function RequireAdminRoute({ children }: { children: JSX.Element }) {
|
||||
const { papel } = useAuthAccess();
|
||||
|
||||
if (papel === "admin") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Navigate to="/fechamento-hgtx" replace />;
|
||||
}
|
||||
|
||||
const FechamentoHgtxApp = () => {
|
||||
return (
|
||||
<AuthAccessProvider>
|
||||
<AuthGate>
|
||||
<UnidadeGate>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route index element={<Fechamentos />} />
|
||||
<Route path="competencias/:id" element={<CompetenciaFechamentos />} />
|
||||
<Route path="fechamentos/:id" element={<FechamentoDetalhes />} />
|
||||
<Route path="banco-pontos" element={<BancoPontos />} />
|
||||
<Route
|
||||
path="parceiros"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Parceiros />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usuarios"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Usuarios />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuracoes"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Configuracoes />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</UnidadeGate>
|
||||
</AuthGate>
|
||||
</AuthAccessProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FechamentoHgtxApp;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ClipboardList,
|
||||
Landmark,
|
||||
Menu,
|
||||
Settings,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
|
||||
const navItems = [
|
||||
{ title: "Fechamentos", path: "", icon: ClipboardList, onlyAdmin: false },
|
||||
{ title: "Banco de Pontos", path: "banco-pontos", icon: Landmark, onlyAdmin: false },
|
||||
{ title: "Parceiros", path: "parceiros", icon: Wallet, onlyAdmin: true },
|
||||
{ title: "Usuários", path: "usuarios", icon: Users, onlyAdmin: true },
|
||||
{ title: "Configurações", path: "configuracoes", icon: Settings, onlyAdmin: true },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { papel } = useAuthAccess();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const isAdmin = papel === "admin";
|
||||
const allowedNavItems = navItems.filter((item) => isAdmin || !item.onlyAdmin);
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{allowedNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === ""}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden border-t border-sidebar-border px-3 py-4 lg:block">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="nav-item flex w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent hover:text-foreground lg:justify-start"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||
collapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Recolher</span>}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-300 lg:hidden",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-300 lg:flex",
|
||||
collapsed ? "w-[72px]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } from "react";
|
||||
import { AppSidebar } from "@/modules/fechamento-hgtx/components/layout/AppSidebar";
|
||||
|
||||
type MainLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-4 pt-16 lg:p-8 lg:pt-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function BancoPontos() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Banco de Pontos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Consulte saldos por parceiro e extrato de créditos/débitos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="metric-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Estrutura inicial pronta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Download, ExternalLink, FileSpreadsheet, FolderKanban, RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||
|
||||
function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
|
||||
if (row.parceiroCodinome?.trim()) {
|
||||
return `${row.parceiroNome} (${row.parceiroCodinome.trim()})`;
|
||||
}
|
||||
return row.parceiroNome;
|
||||
}
|
||||
|
||||
function formatHoras(minutos: number | null): string {
|
||||
if (!minutos || minutos <= 0) return "0h";
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
export default function CompetenciaFechamentos() {
|
||||
const { id: competenciaId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
|
||||
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
|
||||
const [reprocessMode, setReprocessMode] = useState<
|
||||
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
|
||||
>("reprocessar_tudo");
|
||||
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
|
||||
|
||||
const loadFechamentos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId);
|
||||
setFechamentos(data);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
|
||||
toast.error(message);
|
||||
setFechamentos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportarAsana = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
|
||||
toast.success(
|
||||
`Importação concluída: ${resultado.tarefasImportadas} tasks, ${resultado.fechamentosCriados} fechamentos criados.`,
|
||||
);
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutarReprocessamento = async () => {
|
||||
if (reprocessMode === "reprocessar_alguns" && selectedParceiroIds.length === 0) {
|
||||
toast.error("Selecione ao menos um fechamento/parceiro para reprocessar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
|
||||
modo: reprocessMode,
|
||||
parceiroIds: reprocessMode === "reprocessar_alguns" ? selectedParceiroIds : undefined,
|
||||
});
|
||||
if (reprocessMode === "buscar_novos_fechamentos") {
|
||||
toast.success(
|
||||
`Busca concluída: ${resultado.fechamentosCriados} novos fechamento(s) criado(s), sem alterar os atuais.`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Reprocessamento concluído: ${resultado.tarefasImportadas} tasks processadas e ${resultado.fechamentosCriados} fechamento(s) criado(s).`,
|
||||
);
|
||||
}
|
||||
setIsReprocessModalOpen(false);
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportar = async (fechamentoId: string) => {
|
||||
try {
|
||||
setExportingFechamentoId(fechamentoId);
|
||||
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename ?? `fechamento-${fechamentoId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Planilha exportada com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setExportingFechamentoId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (competenciaId) {
|
||||
void loadFechamentos();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [competenciaId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
|
||||
}, [fechamentos]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento-hgtx")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos da Competência
|
||||
</h1>
|
||||
{!loading && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fechamentos.length} fechamento(s)
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsReprocessModalOpen(true)}
|
||||
disabled={importing}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && fechamentos.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||
<CardDescription>
|
||||
Importe as tasks do Asana para criar os fechamentos automaticamente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-6">
|
||||
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{importing ? "Importando..." : "Importar tasks do Asana"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">Logo</TableHead>
|
||||
<TableHead className="min-w-[280px]">Nome</TableHead>
|
||||
<TableHead className="min-w-[140px]">Pontuação Total</TableHead>
|
||||
<TableHead className="min-w-[120px]">Horas Total</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
Carregando fechamentos...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
fechamentos.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
{row.parceiroLogoUrl ? (
|
||||
<img
|
||||
src={row.parceiroLogoUrl}
|
||||
alt={`Logo ${row.parceiroNome}`}
|
||||
className="h-8 w-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
|
||||
<TableCell>{row.pontuacaoTotalEntregue}</TableCell>
|
||||
<TableCell>{formatHoras(row.horasTotal * 60)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
row.status === "fechado"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
}
|
||||
>
|
||||
{row.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleExportar(row.id)}
|
||||
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
|
||||
title="Exportar planilha financeira (XLSX)"
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
{exportingFechamentoId === row.id ? "Exportando..." : "Exportar"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
|
||||
state: { competenciaId, status: row.status },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Acessar
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reprocessar Asana</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha uma estratégia de reprocessamento para esta competência.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("reprocessar_tudo")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "reprocessar_tudo"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Reprocessar tudo</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("reprocessar_alguns")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "reprocessar_alguns"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Reprocessar apenas alguns</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reprocessa somente os parceiros selecionados abaixo.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("buscar_novos_fechamentos")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "buscar_novos_fechamentos"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Buscar novos fechamentos</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Busca no Asana e cria apenas os fechamentos que ainda não existem.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reprocessMode === "reprocessar_alguns" ? (
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Selecione os fechamentos/parceiros</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
|
||||
>
|
||||
Marcar todos
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedParceiroIds([])}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fechamentos.map((f) => {
|
||||
const checked = selectedParceiroIds.includes(f.parceiroId);
|
||||
return (
|
||||
<label
|
||||
key={f.id}
|
||||
htmlFor={`sel-${f.id}`}
|
||||
className={`flex cursor-pointer items-center space-x-3 rounded-md border p-2 transition ${
|
||||
checked ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`sel-${f.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
const on = Boolean(value);
|
||||
setSelectedParceiroIds((prev) =>
|
||||
on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`sel-${f.id}`} className="cursor-pointer text-sm">
|
||||
{getDisplayNome(f)}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{f.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
</span>
|
||||
</Label>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
|
||||
{importing ? "Processando..." : "Executar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { asanaService, type AsanaWorkspace } from "@/services/asana";
|
||||
import {
|
||||
fechamentoConfiguracoesService,
|
||||
type ConfiguracaoPublica,
|
||||
} from "@/services/fechamento/configuracoes";
|
||||
import { fechamentoUnidadesService } from "@/services/fechamento/unidades";
|
||||
import {
|
||||
clearCommanderUnidadeIdCache,
|
||||
getEstabelecimentoCodigoFromTransfer,
|
||||
lookupUnidadeByEstabelecimento,
|
||||
type UnidadeLookupRow,
|
||||
} from "@/services/fechamento/unidadeContext";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
|
||||
export default function Configuracoes() {
|
||||
const navigate = useNavigate();
|
||||
const { papel } = useAuthAccess();
|
||||
const isAdmin = papel === "admin";
|
||||
const unidadeSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const [configAtual, setConfigAtual] = useState<ConfiguracaoPublica | null>(null);
|
||||
const [asanaToken, setAsanaToken] = useState("");
|
||||
const [workspaces, setWorkspaces] = useState<AsanaWorkspace[]>([]);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
|
||||
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
|
||||
|
||||
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
|
||||
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null);
|
||||
const [nomeUnidade, setNomeUnidade] = useState("");
|
||||
const [loadingUnidade, setLoadingUnidade] = useState(true);
|
||||
const [savingUnidade, setSavingUnidade] = useState(false);
|
||||
|
||||
const tokenJaConfigurado = Boolean(configAtual?.asanaTokenConfigured);
|
||||
const tokenDigitado = asanaToken.trim();
|
||||
const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
if (!selectedWorkspaceId || !selectedWorkspaceNome) {
|
||||
return workspaces;
|
||||
}
|
||||
if (workspaces.some((item) => item.id === selectedWorkspaceId)) {
|
||||
return workspaces;
|
||||
}
|
||||
return [{ id: selectedWorkspaceId, name: selectedWorkspaceNome }, ...workspaces];
|
||||
}, [workspaces, selectedWorkspaceId, selectedWorkspaceNome]);
|
||||
|
||||
const loadUnidade = async () => {
|
||||
try {
|
||||
setLoadingUnidade(true);
|
||||
const codigo = getEstabelecimentoCodigoFromTransfer();
|
||||
setCodigoEstabelecimento(codigo);
|
||||
if (!codigo) {
|
||||
setUnidadeExistente(null);
|
||||
setNomeUnidade("");
|
||||
return;
|
||||
}
|
||||
const row = await lookupUnidadeByEstabelecimento(codigo);
|
||||
setUnidadeExistente(row);
|
||||
setNomeUnidade(row?.nome ?? "");
|
||||
} catch {
|
||||
setUnidadeExistente(null);
|
||||
setNomeUnidade("");
|
||||
} finally {
|
||||
setLoadingUnidade(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfiguracoesAsana = async () => {
|
||||
try {
|
||||
setConfigAsanaError(null);
|
||||
const data = await fechamentoConfiguracoesService.getConfiguracoes();
|
||||
setConfigAtual(data);
|
||||
setAsanaToken(data?.asanaToken ?? "");
|
||||
setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
|
||||
setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
|
||||
setConfigAsanaError(message);
|
||||
setConfigAtual(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([loadUnidade(), loadConfiguracoesAsana()]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || loading) return;
|
||||
if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
|
||||
unidadeSectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const handleBuscarWorkspaces = async () => {
|
||||
if (!tokenDigitado) {
|
||||
toast.error("Informe o token do Asana para buscar workspaces.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingWorkspaces(true);
|
||||
const ws = await asanaService.getWorkspaces(tokenDigitado);
|
||||
setWorkspaces(ws);
|
||||
if (ws.length === 0) {
|
||||
toast.warning("Nenhum workspace foi encontrado para este token.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkspaceId && ws.some((item) => item.id === selectedWorkspaceId)) {
|
||||
const selected = ws.find((item) => item.id === selectedWorkspaceId);
|
||||
setSelectedWorkspaceNome(selected?.name ?? selectedWorkspaceNome);
|
||||
} else {
|
||||
setSelectedWorkspaceId("");
|
||||
setSelectedWorkspaceNome("");
|
||||
}
|
||||
|
||||
toast.success(`${ws.length} workspace(s) carregado(s).`);
|
||||
} catch (error) {
|
||||
const message = error && typeof error === "object" && "message" in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: "Erro ao buscar workspaces do Asana.";
|
||||
toast.error(message);
|
||||
setWorkspaces([]);
|
||||
} finally {
|
||||
setLoadingWorkspaces(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsana = async () => {
|
||||
if (!selectedWorkspaceId) {
|
||||
toast.error("Selecione um workspace antes de salvar.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const workspaceSelecionado = workspaceOptions.find((item) => item.id === selectedWorkspaceId);
|
||||
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
|
||||
asanaToken: tokenDigitado ? tokenDigitado : undefined,
|
||||
asanaWorkspaceId: selectedWorkspaceId,
|
||||
asanaWorkspaceNome: workspaceSelecionado?.name ?? selectedWorkspaceNome,
|
||||
});
|
||||
|
||||
setConfigAtual(updated);
|
||||
setAsanaToken(updated.asanaToken ?? "");
|
||||
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
|
||||
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
|
||||
setConfigAsanaError(null);
|
||||
toast.success("Configurações salvas com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar configurações.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSalvarUnidade = async () => {
|
||||
const nome = nomeUnidade.trim();
|
||||
if (!nome) {
|
||||
toast.error("Informe o nome da unidade.");
|
||||
return;
|
||||
}
|
||||
const codigo = codigoEstabelecimento.trim();
|
||||
if (!codigo) {
|
||||
toast.error("Código do estabelecimento não disponível no transfer.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingUnidade(true);
|
||||
if (unidadeExistente) {
|
||||
await fechamentoUnidadesService.updateUnidade(unidadeExistente.id, { nome });
|
||||
toast.success("Unidade atualizada.");
|
||||
clearCommanderUnidadeIdCache();
|
||||
await loadUnidade();
|
||||
} else {
|
||||
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
|
||||
toast.success("Unidade cadastrada.");
|
||||
clearCommanderUnidadeIdCache();
|
||||
navigate("/fechamento-hgtx", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingUnidade(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Carregando configurações...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-8 pb-8">
|
||||
<header className="border-b border-border pb-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground md:text-2xl">Configurações do sistema</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
|
||||
Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto
|
||||
atual do Commander.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div ref={unidadeSectionRef} id="unidade">
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Unidade</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
|
||||
estabelecimento é somente leitura.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-6 py-6">
|
||||
{loadingUnidade ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Carregando dados da unidade...
|
||||
</div>
|
||||
) : !isAdmin ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apenas administradores podem cadastrar ou editar a unidade aqui.
|
||||
</p>
|
||||
) : !codigoEstabelecimento ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Abra o módulo pelo Codex com o estabelecimento no transfer para exibir o código e cadastrar a
|
||||
unidade.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Código do estabelecimento (transfer)</Label>
|
||||
<Input value={codigoEstabelecimento} readOnly className="h-11 font-mono text-sm bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome-unidade">Nome da unidade</Label>
|
||||
<Input
|
||||
id="nome-unidade"
|
||||
value={nomeUnidade}
|
||||
onChange={(e) => setNomeUnidade(e.target.value)}
|
||||
placeholder="Ex.: Unidade Matriz"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{unidadeExistente ? (
|
||||
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
|
||||
Unidade cadastrada
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="max-w-full shrink-0 whitespace-normal sm:whitespace-nowrap">
|
||||
Pendente: informe o nome e salve para criar a unidade
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-border pt-4">
|
||||
<Button type="button" onClick={handleSalvarUnidade} disabled={savingUnidade}>
|
||||
{savingUnidade ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-6 py-6">
|
||||
{configAsanaError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<p className="font-medium">Não foi possível carregar as configurações do Asana</p>
|
||||
<p className="mt-1 leading-relaxed opacity-90">
|
||||
{configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label htmlFor="asana-token" className="text-sm font-medium">
|
||||
Token de acesso
|
||||
</Label>
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
{tokenJaConfigurado ? (
|
||||
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
|
||||
Token configurado no servidor
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 whitespace-nowrap">
|
||||
Token ainda não salvo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
id="asana-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={asanaToken}
|
||||
onChange={(e) => setAsanaToken(e.target.value)}
|
||||
placeholder={
|
||||
tokenJaConfigurado
|
||||
? "Substitua o token ou mantenha em branco para não alterar ao salvar"
|
||||
: "Cole o token do Asana (Personal Access Token)"
|
||||
}
|
||||
className="h-11 pr-11 font-mono text-sm"
|
||||
disabled={Boolean(configAsanaError)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((prev) => !prev)}
|
||||
title={showToken ? "Ocultar token" : "Mostrar token"}
|
||||
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-11 shrink-0 whitespace-nowrap px-5 lg:self-stretch"
|
||||
onClick={handleBuscarWorkspaces}
|
||||
disabled={!podeBuscarWorkspaces || Boolean(configAsanaError)}
|
||||
>
|
||||
{loadingWorkspaces ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Buscando…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Listar workspaces
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="asana-workspace" className="text-sm font-medium">
|
||||
Workspace padrão
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedWorkspaceId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWorkspaceId(value);
|
||||
const selected = workspaceOptions.find((item) => item.id === value);
|
||||
setSelectedWorkspaceNome(selected?.name ?? "");
|
||||
}}
|
||||
disabled={Boolean(configAsanaError)}
|
||||
>
|
||||
<SelectTrigger id="asana-workspace" className="h-11 w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
workspaceOptions.length > 0
|
||||
? "Selecione um workspace"
|
||||
: "Informe o token e clique em Listar workspaces"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<SelectItem key={workspace.id} value={workspace.id}>
|
||||
{workspace.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Selecionado: <span className="font-medium text-foreground">{selectedWorkspaceNome || "—"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="min-w-[200px]"
|
||||
onClick={handleSaveAsana}
|
||||
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Salvar integração Asana
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, CheckCircle2, Clock3, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { fechamentoCompetenciasService } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos";
|
||||
|
||||
function formatHoras(minutos: number | null): string {
|
||||
if (!minutos || minutos <= 0) return "0h";
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatHorasResumo(minutos: number): string {
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function parsePontuacaoInput(value: string): number {
|
||||
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
type FechamentoDetalhesLocationState = {
|
||||
competenciaId?: string;
|
||||
status?: "em_aberto" | "fechado";
|
||||
};
|
||||
|
||||
export default function FechamentoDetalhes() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAuthAccess();
|
||||
const { id: fechamentoId = "" } = useParams();
|
||||
const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null;
|
||||
const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? "");
|
||||
const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tarefas, setTarefas] = useState<FechamentoTarefaItem[]>([]);
|
||||
const [togglingTaskId, setTogglingTaskId] = useState<string | null>(null);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
|
||||
const [savingLancamento, setSavingLancamento] = useState(false);
|
||||
const [isConcluirOpen, setIsConcluirOpen] = useState(false);
|
||||
const [concluindo, setConcluindo] = useState(false);
|
||||
const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState("");
|
||||
const [motivoAjuste, setMotivoAjuste] = useState("");
|
||||
const [isReabrirOpen, setIsReabrirOpen] = useState(false);
|
||||
const [reabrindo, setReabrindo] = useState(false);
|
||||
const [motivoReabertura, setMotivoReabertura] = useState("");
|
||||
const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus");
|
||||
const [lancamentoDescricao, setLancamentoDescricao] = useState("");
|
||||
const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
|
||||
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(null);
|
||||
const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false);
|
||||
const [reprocessandoAsana, setReprocessandoAsana] = useState(false);
|
||||
const [isEditarOpen, setIsEditarOpen] = useState(false);
|
||||
const [savingEdicao, setSavingEdicao] = useState(false);
|
||||
const [editingTarefa, setEditingTarefa] = useState<FechamentoTarefaItem | null>(null);
|
||||
const [edicaoNumeroTicket, setEdicaoNumeroTicket] = useState("");
|
||||
const [edicaoDescricao, setEdicaoDescricao] = useState("");
|
||||
const [edicaoCliente, setEdicaoCliente] = useState("");
|
||||
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
|
||||
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
|
||||
const isFechado = fechamentoStatus === "fechado";
|
||||
|
||||
const totais = useMemo(() => {
|
||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
||||
const pontos = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
|
||||
const minutos = aprovadas.reduce((acc, t) => acc + Number(t.tempoMinutos || 0), 0);
|
||||
return {
|
||||
pontos,
|
||||
horas: formatHorasResumo(minutos),
|
||||
aprovadas: aprovadas.length,
|
||||
};
|
||||
}, [tarefas]);
|
||||
const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
|
||||
const bancoCalculado = totais.pontos - pontuacaoPagaNumero;
|
||||
const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
|
||||
const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
|
||||
const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
|
||||
const pontuacaoTotalLabel = String(totais.pontos);
|
||||
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
|
||||
|
||||
const loadTarefas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
|
||||
setTarefas(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
||||
toast.error(message);
|
||||
setTarefas([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
|
||||
if (!currentCompetenciaId) return;
|
||||
try {
|
||||
const rows = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
|
||||
const current = rows.find((row) => row.id === fechamentoId);
|
||||
if (!current) return;
|
||||
setFechamentoStatus(current.status);
|
||||
setPontuacaoMeta(current.pontuacaoMeta);
|
||||
if (!competenciaId) {
|
||||
setCompetenciaId(current.competenciaId);
|
||||
}
|
||||
} catch {
|
||||
// mantém status atual em caso de erro para evitar bloquear navegação
|
||||
}
|
||||
};
|
||||
|
||||
const toastBloqueioFechado = () => {
|
||||
toast.error("Fechamento está fechado. Reabra para editar.");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fechamentoId) {
|
||||
void loadTarefas();
|
||||
}
|
||||
}, [fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (competenciaId) {
|
||||
void loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
}, [competenciaId, fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFechado) {
|
||||
setIsConcluirOpen(false);
|
||||
setIsLancamentoOpen(false);
|
||||
}
|
||||
}, [isFechado]);
|
||||
|
||||
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const previous = tarefas;
|
||||
setTogglingTaskId(tarefa.id);
|
||||
setTarefas((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === tarefa.id
|
||||
? {
|
||||
...item,
|
||||
estaRevisada: approved,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (!me?.id) {
|
||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
}
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
||||
estaRevisada: approved,
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setTarefas(previous);
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar aprovação da tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setTogglingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLancamentoForm = () => {
|
||||
setLancamentoTipo("bonus");
|
||||
setLancamentoDescricao("");
|
||||
setLancamentoPontuacao("0");
|
||||
};
|
||||
|
||||
const handleSalvarLancamento = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const descricao = lancamentoDescricao.trim();
|
||||
const pontuacao = Number(lancamentoPontuacao);
|
||||
if (!descricao) {
|
||||
toast.error("Informe a descrição do lançamento.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
|
||||
toast.error("Informe uma pontuação válida maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLancamento(true);
|
||||
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
|
||||
tipo: lancamentoTipo,
|
||||
descricao,
|
||||
pontuacao,
|
||||
});
|
||||
toast.success("Lançamento incluído com sucesso.");
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao incluir lançamento.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingLancamento(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbrirConcluir = () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
setPontuacaoPagaInput(pontuacaoTotalLabel);
|
||||
setMotivoAjuste("");
|
||||
setIsConcluirOpen(true);
|
||||
};
|
||||
|
||||
const handleConcluirFechamento = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const pontuacaoPagaRaw = parsePontuacaoInput(pontuacaoPagaInput);
|
||||
if (!Number.isFinite(pontuacaoPagaRaw)) {
|
||||
toast.error("Informe uma pontuação paga válida.");
|
||||
return;
|
||||
}
|
||||
if (pontuacaoPagaRaw <= 0) {
|
||||
toast.error("A pontuação paga deve ser maior que zero.");
|
||||
return;
|
||||
}
|
||||
if (requerMotivoAjuste && !motivoAjuste.trim()) {
|
||||
toast.error("Informe o motivo do ajuste quando houver diferença de saldo.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setConcluindo(true);
|
||||
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
||||
pontuacaoPaga: pontuacaoPagaRaw,
|
||||
motivoAjuste: motivoAjuste.trim() || undefined,
|
||||
});
|
||||
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
||||
setFechamentoStatus("fechado");
|
||||
navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
|
||||
toast.error(message);
|
||||
if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
await loadTarefas();
|
||||
} finally {
|
||||
setConcluindo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReabrirFechamento = async () => {
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para reabertura.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReabrindo(true);
|
||||
const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, {
|
||||
reabertoPorId: me.id,
|
||||
motivo: motivoReabertura,
|
||||
});
|
||||
toast.success("Fechamento reaberto com sucesso.");
|
||||
setFechamentoStatus(data.status);
|
||||
setIsReabrirOpen(false);
|
||||
setMotivoReabertura("");
|
||||
await loadTarefas();
|
||||
if (data.competenciaId) {
|
||||
setCompetenciaId(data.competenciaId);
|
||||
} else if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao reabrir fechamento.";
|
||||
toast.error(message);
|
||||
if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
} finally {
|
||||
setReabrindo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const isManual = tarefa.tipo === "bonus" || tarefa.tipo === "desconto";
|
||||
if (!isManual) return;
|
||||
setDeletingTarefa(tarefa);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteLancamento = async () => {
|
||||
if (!deletingTarefa) return;
|
||||
try {
|
||||
setDeletingTaskId(deletingTarefa.id);
|
||||
await fechamentoFechamentosService.excluirLancamento(fechamentoId, deletingTarefa.id);
|
||||
toast.success("Lançamento excluído com sucesso.");
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingTarefa(null);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao excluir lançamento.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setDeletingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
setEditingTarefa(tarefa);
|
||||
setEdicaoNumeroTicket(tarefa.numeroTicket ?? "");
|
||||
setEdicaoDescricao(tarefa.descricao ?? "");
|
||||
setEdicaoCliente(tarefa.cliente ?? "");
|
||||
setEdicaoTempoMinutos(tarefa.tempoMinutos != null ? String(tarefa.tempoMinutos) : "");
|
||||
setEdicaoPontuacao(String(Number(tarefa.pontuacao ?? 0)));
|
||||
setIsEditarOpen(true);
|
||||
};
|
||||
|
||||
const handleSalvarEdicao = async () => {
|
||||
if (!editingTarefa) return;
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
return;
|
||||
}
|
||||
const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto";
|
||||
const descricao = edicaoDescricao.trim();
|
||||
const pontuacao = parsePontuacaoInput(edicaoPontuacao);
|
||||
if (!descricao) {
|
||||
toast.error("Descrição é obrigatória.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
|
||||
toast.error("Pontuação deve ser maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
let tempoMinutos: number | null | undefined = undefined;
|
||||
if (!isManual) {
|
||||
const tempoRaw = edicaoTempoMinutos.trim();
|
||||
if (tempoRaw.length > 0) {
|
||||
const tempoParsed = Number(tempoRaw);
|
||||
if (!Number.isFinite(tempoParsed) || tempoParsed < 0 || !Number.isInteger(tempoParsed)) {
|
||||
toast.error("Horas/minutos deve ser um número inteiro maior ou igual a zero.");
|
||||
return;
|
||||
}
|
||||
tempoMinutos = tempoParsed;
|
||||
} else {
|
||||
tempoMinutos = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingEdicao(true);
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, editingTarefa.id, {
|
||||
descricao,
|
||||
pontuacao,
|
||||
...(isManual
|
||||
? {}
|
||||
: {
|
||||
numeroTicket: edicaoNumeroTicket.trim() ? edicaoNumeroTicket.trim() : null,
|
||||
cliente: edicaoCliente.trim() ? edicaoCliente.trim() : null,
|
||||
tempoMinutos,
|
||||
}),
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
toast.success("Tarefa atualizada com sucesso.");
|
||||
setIsEditarOpen(false);
|
||||
setEditingTarefa(null);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao editar tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingEdicao(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocessarAsana = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReprocessandoAsana(true);
|
||||
const data = await fechamentoFechamentosService.reprocessarAsana(fechamentoId);
|
||||
toast.success(
|
||||
`Reprocessamento concluído: ${data.tarefasImportadas} tarefa(s) atualizada(s) para este parceiro.`,
|
||||
);
|
||||
setIsReprocessAsanaOpen(false);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao reprocessar Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setReprocessandoAsana(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||
<div className="mb-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<ListChecks className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Detalhes do Fechamento
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Revisão operacional de tarefas, pontos e horas.</p>
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Badge variant={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsLancamentoOpen(true)}
|
||||
disabled={isFechado}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Fazer lançamento
|
||||
</Button>
|
||||
{!isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(true)}
|
||||
disabled={reprocessandoAsana}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
) : null}
|
||||
{isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReabrirOpen(true)}
|
||||
disabled={reabrindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reabrir fechamento
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAbrirConcluir}
|
||||
disabled={tarefas.length === 0 || concluindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Concluir fechamento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Total de tarefas</CardDescription>
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{tarefas.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Tarefas aprovadas</CardDescription>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Pontuação aprovada</CardDescription>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Horas aprovadas</CardDescription>
|
||||
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{totais.horas}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && tarefas.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhuma tarefa encontrada</CardTitle>
|
||||
<CardDescription>Este fechamento não possui tarefas cadastradas.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">Aprovada</TableHead>
|
||||
<TableHead className="min-w-[120px]">Ticket</TableHead>
|
||||
<TableHead className="min-w-[320px]">Descrição</TableHead>
|
||||
<TableHead className="min-w-[180px]">Cliente</TableHead>
|
||||
<TableHead className="min-w-[110px]">Tipo</TableHead>
|
||||
<TableHead className="min-w-[120px]">Horas</TableHead>
|
||||
<TableHead className="min-w-[120px]">Pontuação</TableHead>
|
||||
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
Carregando tarefas...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tarefas.map((tarefa) => (
|
||||
<TableRow key={tarefa.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={tarefa.estaRevisada}
|
||||
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
|
||||
disabled={togglingTaskId === tarefa.id || isFechado}
|
||||
/>
|
||||
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
||||
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
||||
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
||||
<TableCell>{tarefa.tipo}</TableCell>
|
||||
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
|
||||
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditarTarefa(tarefa)}
|
||||
disabled={isFechado}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="ml-1">Editar</span>
|
||||
</Button>
|
||||
{tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => void handleExcluirLancamento(tarefa)}
|
||||
disabled={isFechado || deletingTaskId === tarefa.id}
|
||||
>
|
||||
{deletingTaskId === tarefa.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1">Excluir</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fazer lançamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adicione uma bonificação ou desconto em pontuação para este fechamento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-tipo">Tipo de lançamento</Label>
|
||||
<Select
|
||||
value={lancamentoTipo}
|
||||
onValueChange={(value) => setLancamentoTipo(value as "bonus" | "desconto")}
|
||||
>
|
||||
<SelectTrigger id="lancamento-tipo">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bonus">Bonificação</SelectItem>
|
||||
<SelectItem value="desconto">Desconto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
|
||||
<Input
|
||||
id="lancamento-pontuacao"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={lancamentoPontuacao}
|
||||
onChange={(e) => setLancamentoPontuacao(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-descricao">Descrição</Label>
|
||||
<Input
|
||||
id="lancamento-descricao"
|
||||
value={lancamentoDescricao}
|
||||
onChange={(e) => setLancamentoDescricao(e.target.value)}
|
||||
placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
}}
|
||||
disabled={savingLancamento}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
|
||||
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Concluir fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os totais e confirme a pontuação paga para concluir este fechamento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Aprovada</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(totais.pontos)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Meta</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(Number(pontuacaoMeta ?? 0))}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Diferença</p>
|
||||
<p className={`text-2xl font-bold ${diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(diferencaParaMeta)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Banco de Pontos</p>
|
||||
<p className={`text-2xl font-bold ${bancoCalculado >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(bancoCalculado)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Pagar total aprovado
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
|
||||
>
|
||||
Pagar meta
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pontuacao-paga">Pontuação paga</Label>
|
||||
{isValorEditado ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition hover:text-foreground"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Usar valor total
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
id="pontuacao-paga"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={pontuacaoPagaInput}
|
||||
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
|
||||
placeholder="Ex.: 10,5"
|
||||
/>
|
||||
</div>
|
||||
{requerMotivoAjuste ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-ajuste">Motivo do ajuste *</Label>
|
||||
<Input
|
||||
id="motivo-ajuste"
|
||||
value={motivoAjuste}
|
||||
onChange={(e) => setMotivoAjuste(e.target.value)}
|
||||
placeholder="Ex.: pagamento parcial acordado com o parceiro"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
|
||||
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isReabrirOpen} onOpenChange={setIsReabrirOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reabrir fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-reabertura">Motivo (opcional)</Label>
|
||||
<Input
|
||||
id="motivo-reabertura"
|
||||
value={motivoReabertura}
|
||||
onChange={(e) => setMotivoReabertura(e.target.value)}
|
||||
placeholder="Ex.: ajuste após revisão financeira"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
||||
{reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir lançamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente excluir o lançamento manual{" "}
|
||||
<strong>{deletingTarefa?.descricao}</strong>? Esta ação não pode ser desfeita.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingTarefa(null);
|
||||
}}
|
||||
disabled={deletingTaskId !== null}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDeleteLancamento()}
|
||||
disabled={deletingTaskId !== null}
|
||||
>
|
||||
{deletingTaskId !== null ? "Excluindo..." : "Confirmar exclusão"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isReprocessAsanaOpen} onOpenChange={setIsReprocessAsanaOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reprocessar Asana deste fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Esta ação atualiza somente as tarefas do Asana para este parceiro no período da competência. Lançamentos
|
||||
manuais (bônus/desconto) serão preservados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(false)}
|
||||
disabled={reprocessandoAsana}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
|
||||
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isEditarOpen} onOpenChange={setIsEditarOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar tarefa</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingTarefa?.tipo === "bonus" || editingTarefa?.tipo === "desconto"
|
||||
? "Para bônus/desconto, você pode editar apenas descrição e pontuação."
|
||||
: "Edite os campos da tarefa e salve as alterações."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{editingTarefa?.tipo === "tarefa" ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-ticket">Ticket</Label>
|
||||
<Input id="edit-ticket" value={edicaoNumeroTicket} onChange={(e) => setEdicaoNumeroTicket(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-descricao">Descrição</Label>
|
||||
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-cliente">Cliente</Label>
|
||||
<Input id="edit-cliente" value={edicaoCliente} onChange={(e) => setEdicaoCliente(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-tempo">Horas (minutos)</Label>
|
||||
<Input
|
||||
id="edit-tempo"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={edicaoTempoMinutos}
|
||||
onChange={(e) => setEdicaoTempoMinutos(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{editingTarefa?.tipo !== "tarefa" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-descricao">Descrição</Label>
|
||||
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-pontuacao">Pontuação</Label>
|
||||
<Input
|
||||
id="edit-pontuacao"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={edicaoPontuacao}
|
||||
onChange={(e) => setEdicaoPontuacao(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
|
||||
{savingEdicao ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ClipboardList, ExternalLink, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
|
||||
|
||||
const meses = [
|
||||
"Janeiro",
|
||||
"Fevereiro",
|
||||
"Março",
|
||||
"Abril",
|
||||
"Maio",
|
||||
"Junho",
|
||||
"Julho",
|
||||
"Agosto",
|
||||
"Setembro",
|
||||
"Outubro",
|
||||
"Novembro",
|
||||
"Dezembro",
|
||||
];
|
||||
|
||||
function formatCompetenciaMes(mes: number, ano: number): string {
|
||||
return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
|
||||
}
|
||||
|
||||
export default function Fechamentos() {
|
||||
const navigate = useNavigate();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [allCompetencias, setAllCompetencias] = useState<CompetenciaItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [anoSelecionado, setAnoSelecionado] = useState<string>("");
|
||||
const [modalAberto, setModalAberto] = useState(false);
|
||||
const [mesSelecionado, setMesSelecionado] = useState<string>("");
|
||||
const [anoNovo, setAnoNovo] = useState<string>("");
|
||||
const [criando, setCriando] = useState(false);
|
||||
|
||||
const anosDisponiveis = useMemo(() => {
|
||||
const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))];
|
||||
if (!anosUnicos.includes(currentYear)) {
|
||||
anosUnicos.push(currentYear);
|
||||
}
|
||||
return anosUnicos.sort((a, b) => b - a).map(String);
|
||||
}, [allCompetencias, currentYear]);
|
||||
|
||||
const competenciasFiltradas = useMemo(() => {
|
||||
if (!anoSelecionado) return [];
|
||||
return allCompetencias.filter((c) => c.ano === Number(anoSelecionado));
|
||||
}, [allCompetencias, anoSelecionado]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadCompetencias = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoCompetenciasService.listarCompetencias({});
|
||||
if (!cancelled) {
|
||||
setAllCompetencias(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar competências.";
|
||||
toast.error(message);
|
||||
setAllCompetencias([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadCompetencias();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (anosDisponiveis.length > 0 && !anoSelecionado) {
|
||||
const defaultAno = anosDisponiveis.includes(String(currentYear))
|
||||
? String(currentYear)
|
||||
: anosDisponiveis[0];
|
||||
setAnoSelecionado(defaultAno);
|
||||
}
|
||||
}, [anosDisponiveis, anoSelecionado, currentYear]);
|
||||
|
||||
const handleCriarCompetencia = async () => {
|
||||
const mes = Number(mesSelecionado);
|
||||
const ano = Number(anoNovo);
|
||||
if (!mes || !ano) {
|
||||
toast.error("Selecione mês e ano.");
|
||||
return;
|
||||
}
|
||||
setCriando(true);
|
||||
try {
|
||||
const nova = await fechamentoCompetenciasService.criarCompetencia({ mes, ano });
|
||||
setAllCompetencias((prev) => [...prev, nova]);
|
||||
setModalAberto(false);
|
||||
setMesSelecionado("");
|
||||
setAnoNovo("");
|
||||
toast.success("Competência criada com sucesso.");
|
||||
setAnoSelecionado(String(ano));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao criar competência.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setCriando(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<ClipboardList className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos
|
||||
</h1>
|
||||
<Dialog open={modalAberto} onOpenChange={setModalAberto}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nova Competência
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Competência</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Mês</label>
|
||||
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{meses.map ((m, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Ano</label>
|
||||
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{anosDisponiveis.map((ano) => (
|
||||
<SelectItem key={ano} value={ano}>
|
||||
{ano}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalAberto(false)} disabled={criando}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
||||
{criando ? "Criando..." : "Criar Competência"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Ano:</span>
|
||||
<Select value={anoSelecionado} onValueChange={setAnoSelecionado}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{anosDisponiveis.map((ano) => (
|
||||
<SelectItem key={ano} value={ano}>
|
||||
{ano}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && competenciasFiltradas.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhuma competência encontrada</CardTitle>
|
||||
<CardDescription>Não há competências cadastradas para o ano selecionado.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[220px]">Mês</TableHead>
|
||||
<TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Acessar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||
Carregando competências...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
competenciasFiltradas.map((competencia) => (
|
||||
<TableRow key={competencia.id}>
|
||||
<TableCell className="font-medium">
|
||||
{formatCompetenciaMes(competencia.mes, competencia.ano)}
|
||||
</TableCell>
|
||||
<TableCell>{competencia.quantidadeFechamentos}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
competencia.status === "concluido"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
}
|
||||
>
|
||||
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Acessar
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
export default function NotFound() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
"404 Error: User attempted to access non-existent fechamento-hgtx route:",
|
||||
location.pathname,
|
||||
);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-3 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
|
||||
<Link to="/fechamento-hgtx" className="text-primary underline hover:text-primary/90">
|
||||
Voltar para Fechamentos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,736 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Edit, Plus, Power, Users, UserCheck, UserX } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
fechamentoUsuariosService,
|
||||
type UsuarioItem,
|
||||
type UsuarioPapel,
|
||||
type UsuarioStatusFiltro,
|
||||
} from "@/services/fechamento/usuarios";
|
||||
import {
|
||||
fechamentoParceirosService,
|
||||
type ParceiroItem,
|
||||
} from "@/services/fechamento/parceiros";
|
||||
|
||||
type UsuarioForm = {
|
||||
nome: string;
|
||||
email: string;
|
||||
papel: UsuarioPapel;
|
||||
parceiroId: string;
|
||||
};
|
||||
|
||||
const formInicial: UsuarioForm = {
|
||||
nome: "",
|
||||
email: "",
|
||||
papel: "admin",
|
||||
parceiroId: "",
|
||||
};
|
||||
const parceiroSemVinculoValue = "__none__";
|
||||
|
||||
function getNomeUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||
if (!usuario) {
|
||||
return "—";
|
||||
}
|
||||
const nome = usuario.nome?.trim();
|
||||
return nome && nome.length > 0 ? nome : "—";
|
||||
}
|
||||
|
||||
function getEmailUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||
if (!usuario) {
|
||||
return "—";
|
||||
}
|
||||
const email = usuario.email?.trim();
|
||||
return email && email.length > 0 ? email : "—";
|
||||
}
|
||||
|
||||
export default function Usuarios() {
|
||||
const [usuarios, setUsuarios] = useState<UsuarioItem[]>([]);
|
||||
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
||||
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingParceiros, setLoadingParceiros] = useState(true);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isToggleOpen, setIsToggleOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const [selectedUsuario, setSelectedUsuario] = useState<UsuarioItem | null>(null);
|
||||
const [form, setForm] = useState<UsuarioForm>(formInicial);
|
||||
|
||||
const [busca, setBusca] = useState("");
|
||||
const [filtroPapel, setFiltroPapel] = useState<"all" | UsuarioPapel>("all");
|
||||
const [filtroStatus, setFiltroStatus] = useState<UsuarioStatusFiltro>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
const totalPages = Math.max(1, totalPaginas);
|
||||
const hasActiveFilters =
|
||||
busca.trim().length > 0 || filtroPapel !== "all" || filtroStatus !== "all";
|
||||
const parceiroObrigatorio = form.papel === "parceiro";
|
||||
const parceiroSelecionado = useMemo(
|
||||
() => parceiros.find((p) => p.id === form.parceiroId) ?? null,
|
||||
[parceiros, form.parceiroId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingParceiros(true);
|
||||
fechamentoParceirosService
|
||||
.listarParceirosAtivos()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setParceiros(data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao listar parceiros.";
|
||||
toast.error(message);
|
||||
setParceiros([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingParceiros(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingList(true);
|
||||
fechamentoUsuariosService
|
||||
.listarUsuarios({
|
||||
busca: busca.trim() || undefined,
|
||||
papel: filtroPapel === "all" ? undefined : filtroPapel,
|
||||
estaAtivo: filtroStatus,
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setUsuarios(res.data ?? []);
|
||||
setTotalRegistros(res.meta?.total ?? 0);
|
||||
setTotalPaginas(res.meta?.totalPaginas ?? 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao listar usuários.";
|
||||
toast.error(message);
|
||||
setUsuarios([]);
|
||||
setTotalRegistros(0);
|
||||
setTotalPaginas(1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingList(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [busca, filtroPapel, filtroStatus, currentPage, itemsPerPage, reloadNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [busca, filtroPapel, filtroStatus]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setBusca("");
|
||||
setFiltroPapel("all");
|
||||
setFiltroStatus("all");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setForm(formInicial);
|
||||
setIsCreateOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (usuario: UsuarioItem) => {
|
||||
setSelectedUsuario(usuario);
|
||||
setForm({
|
||||
nome: usuario.nome ?? "",
|
||||
email: usuario.email ?? "",
|
||||
papel: usuario.papel,
|
||||
parceiroId: usuario.parceiroId ?? "",
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const openToggleDialog = (usuario: UsuarioItem) => {
|
||||
setSelectedUsuario(usuario);
|
||||
setIsToggleOpen(true);
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
if (!form.nome.trim()) {
|
||||
toast.error("Nome é obrigatório.");
|
||||
return false;
|
||||
}
|
||||
if (!form.email.trim()) {
|
||||
toast.error("E-mail é obrigatório.");
|
||||
return false;
|
||||
}
|
||||
if (parceiroObrigatorio && !form.parceiroId) {
|
||||
toast.error("Selecione um parceiro para o perfil parceiro.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
await fechamentoUsuariosService.criarUsuario({
|
||||
nome: form.nome.trim(),
|
||||
email: form.email.trim(),
|
||||
papel: form.papel,
|
||||
parceiroId: form.parceiroId || null,
|
||||
estaAtivo: true,
|
||||
});
|
||||
toast.success("Usuário criado com sucesso.");
|
||||
setIsCreateOpen(false);
|
||||
setCurrentPage(1);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao criar usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!selectedUsuario || !validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
await fechamentoUsuariosService.editarUsuario(selectedUsuario.id, {
|
||||
nome: form.nome.trim(),
|
||||
email: form.email.trim(),
|
||||
papel: form.papel,
|
||||
parceiroId: form.parceiroId || null,
|
||||
});
|
||||
toast.success("Usuário atualizado com sucesso.");
|
||||
setIsEditOpen(false);
|
||||
setSelectedUsuario(null);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao editar usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!selectedUsuario) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setToggling(true);
|
||||
const updated = await fechamentoUsuariosService.toggleAtivoUsuario(selectedUsuario.id);
|
||||
toast.success(updated.estaAtivo ? "Usuário reativado com sucesso." : "Usuário inativado com sucesso.");
|
||||
setIsToggleOpen(false);
|
||||
setSelectedUsuario(null);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar status do usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const onChangePapel = (value: UsuarioPapel) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
papel: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<Users className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Usuários
|
||||
</h1>
|
||||
<Button onClick={openCreateDialog} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4">
|
||||
<Input
|
||||
placeholder="Buscar por nome ou e-mail..."
|
||||
value={busca}
|
||||
onChange={(e) => setBusca(e.target.value)}
|
||||
className="w-full md:max-w-sm"
|
||||
/>
|
||||
<Select
|
||||
value={filtroPapel}
|
||||
onValueChange={(value) => setFiltroPapel(value as "all" | UsuarioPapel)}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-[170px]">
|
||||
<SelectValue placeholder="Perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filtroStatus} onValueChange={(value) => setFiltroStatus(value as UsuarioStatusFiltro)}>
|
||||
<SelectTrigger className="w-full md:w-[170px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="true">Ativos</SelectItem>
|
||||
<SelectItem value="false">Inativos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm text-muted-foreground">Itens:</span>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Nenhum usuário cadastrado
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Crie o primeiro usuário para iniciar o gerenciamento de acessos.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[220px]">Nome</TableHead>
|
||||
<TableHead className="min-w-[220px]">E-mail</TableHead>
|
||||
<TableHead className="min-w-[120px]">Perfil</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingList ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
Carregando...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : totalRegistros === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
Nenhum usuário encontrado para os filtros atuais.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
usuarios.map((usuario) => (
|
||||
<TableRow key={usuario.id}>
|
||||
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
||||
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.papel === "admin" ? "default" : "secondary"}>
|
||||
{usuario.papel === "admin" ? "Admin" : "Parceiro"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.estaAtivo ? "secondary" : "outline"}>
|
||||
{usuario.estaAtivo ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(usuario)}
|
||||
title="Editar usuário"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openToggleDialog(usuario)}
|
||||
title={usuario.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!loadingList && totalRegistros > 0 && (
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-center text-sm text-muted-foreground sm:text-left">
|
||||
Mostrando {totalRegistros} {totalRegistros === 1 ? "usuário" : "usuários"}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="h-9 w-10 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo usuário</DialogTitle>
|
||||
<DialogDescription>Preencha os dados para criar um novo usuário.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="create-nome">Nome</Label>
|
||||
<Input
|
||||
id="create-nome"
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-email">E-mail</Label>
|
||||
<Input
|
||||
id="create-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Perfil</Label>
|
||||
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.parceiroId || parceiroSemVinculoValue}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||
}))
|
||||
}
|
||||
disabled={loadingParceiros}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
parceiroObrigatorio
|
||||
? "Selecione um parceiro"
|
||||
: "Opcional para admin, obrigatório para parceiro"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||
Sem parceiro
|
||||
</SelectItem>
|
||||
{parceiros.map((parceiro) => (
|
||||
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||
{parceiro.codinome?.trim()
|
||||
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||
: parceiro.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? "Criando..." : "Criar usuário"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar usuário</DialogTitle>
|
||||
<DialogDescription>Atualize os dados do usuário selecionado.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-nome">Nome</Label>
|
||||
<Input
|
||||
id="edit-nome"
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-email">E-mail</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Perfil</Label>
|
||||
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.parceiroId || parceiroSemVinculoValue}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||
}))
|
||||
}
|
||||
disabled={loadingParceiros}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
parceiroObrigatorio
|
||||
? "Selecione um parceiro"
|
||||
: "Opcional para admin, obrigatório para parceiro"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||
Sem parceiro
|
||||
</SelectItem>
|
||||
{parceiros.map((parceiro) => (
|
||||
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||
{parceiro.codinome?.trim()
|
||||
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||
: parceiro.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{parceiroSelecionado && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Vinculado: {parceiroSelecionado.codinome?.trim() || parceiroSelecionado.nome}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleEdit} disabled={saving}>
|
||||
{saving ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={isToggleOpen} onOpenChange={setIsToggleOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{selectedUsuario?.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{selectedUsuario?.estaAtivo ? (
|
||||
<>
|
||||
Deseja inativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Deseja reativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={toggling}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
|
||||
{toggling ? (
|
||||
"Processando..."
|
||||
) : selectedUsuario?.estaAtivo ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<UserX className="h-4 w-4" />
|
||||
Inativar
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Reativar
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user