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