novas atualizacoes do sistema de fechamento
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
||||
import BancoPontosExtrato from "@/modules/fechamento-hgtx/pages/BancoPontosExtrato";
|
||||
import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
||||
import MeuPerfil from "@/modules/fechamento-hgtx/pages/MeuPerfil";
|
||||
import MeuFechamento from "@/modules/fechamento-hgtx/pages/MeuFechamento";
|
||||
import MeuFechamentoBancoPontos from "@/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos";
|
||||
import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { AuthGate } from "@/components/auth/AuthGate";
|
||||
@@ -19,7 +23,23 @@ function RequireAdminRoute({ children }: { children: JSX.Element }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Navigate to="/fechamento-hgtx" replace />;
|
||||
return <Navigate to="/fechamento/meu-fechamento" replace />;
|
||||
}
|
||||
|
||||
function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element }) {
|
||||
const { papel } = useAuthAccess();
|
||||
const location = useLocation();
|
||||
const readonlyView = Boolean((location.state as { readonlyView?: boolean } | null)?.readonlyView);
|
||||
|
||||
if (papel === "admin") {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (papel === "parceiro" && readonlyView) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Navigate to="/fechamento/meu-fechamento" replace />;
|
||||
}
|
||||
|
||||
const FechamentoHgtxApp = () => {
|
||||
@@ -29,10 +49,49 @@ const FechamentoHgtxApp = () => {
|
||||
<UnidadeGate>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route index element={<Fechamentos />} />
|
||||
<Route path="competencias/:id" element={<CompetenciaFechamentos />} />
|
||||
<Route path="fechamentos/:id" element={<FechamentoDetalhes />} />
|
||||
<Route path="banco-pontos" element={<BancoPontos />} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Fechamentos />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="competencias/:id"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<CompetenciaFechamentos />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="fechamentos/:id"
|
||||
element={
|
||||
<RequireFechamentoDetalheRoute>
|
||||
<FechamentoDetalhes />
|
||||
</RequireFechamentoDetalheRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="banco-pontos"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<BancoPontos />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="banco-pontos/:parceiroId"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<BancoPontosExtrato />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="meu-perfil" element={<MeuPerfil />} />
|
||||
<Route path="meu-fechamento" element={<MeuFechamento />} />
|
||||
<Route path="meu-fechamento/banco-pontos" element={<MeuFechamentoBancoPontos />} />
|
||||
<Route
|
||||
path="parceiros"
|
||||
element={
|
||||
|
||||
@@ -1,24 +1,74 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
Landmark,
|
||||
Menu,
|
||||
ReceiptText,
|
||||
Settings,
|
||||
UserCircle2,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
|
||||
const navItems = [
|
||||
{ title: "Fechamentos", path: "", icon: ClipboardList, onlyAdmin: false },
|
||||
{ title: "Banco de Pontos", path: "banco-pontos", icon: Landmark, onlyAdmin: false },
|
||||
{ title: "Parceiros", path: "parceiros", icon: Wallet, onlyAdmin: true },
|
||||
{ title: "Usuários", path: "usuarios", icon: Users, onlyAdmin: true },
|
||||
{ title: "Configurações", path: "configuracoes", icon: Settings, onlyAdmin: true },
|
||||
{
|
||||
section: "Fechamento",
|
||||
title: "Gerenciar Fechamentos",
|
||||
path: "",
|
||||
icon: ClipboardList,
|
||||
roles: ["admin"] as const,
|
||||
},
|
||||
{
|
||||
section: "Fechamento",
|
||||
title: "Banco de Pontos",
|
||||
path: "banco-pontos",
|
||||
icon: Landmark,
|
||||
roles: ["admin"] as const,
|
||||
},
|
||||
{
|
||||
section: "Fechamento",
|
||||
title: "Meu Fechamento",
|
||||
path: "meu-fechamento",
|
||||
icon: ReceiptText,
|
||||
roles: ["admin", "parceiro"] as const,
|
||||
},
|
||||
{
|
||||
section: "Configurações",
|
||||
title: "Parceiros",
|
||||
path: "parceiros",
|
||||
icon: Wallet,
|
||||
roles: ["admin"] as const,
|
||||
},
|
||||
{
|
||||
section: "Configurações",
|
||||
title: "Usuários",
|
||||
path: "usuarios",
|
||||
icon: Users,
|
||||
roles: ["admin"] as const,
|
||||
},
|
||||
{
|
||||
section: "Configurações",
|
||||
title: "Configurações",
|
||||
path: "configuracoes",
|
||||
icon: Settings,
|
||||
roles: ["admin"] as const,
|
||||
},
|
||||
{
|
||||
section: "Configurações",
|
||||
title: "Meu Perfil",
|
||||
path: "meu-perfil",
|
||||
icon: UserCircle2,
|
||||
roles: ["admin", "parceiro"] as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
@@ -26,59 +76,118 @@ export function AppSidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const isAdmin = papel === "admin";
|
||||
const allowedNavItems = navItems.filter((item) => isAdmin || !item.onlyAdmin);
|
||||
const role = papel ?? "parceiro";
|
||||
const allowedNavItems = navItems.filter((item) => item.roles.includes(role));
|
||||
const sections = ["Fechamento", "Configurações"] as const;
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border-b border-sidebar-border max-lg:pt-12",
|
||||
collapsed
|
||||
? "flex flex-col items-center gap-2 px-2 py-3"
|
||||
: "flex items-center gap-2 px-3 py-4 lg:gap-3",
|
||||
)}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 animate-fade-in">
|
||||
<h1 className="truncate text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
|
||||
<p className="text-xs text-muted-foreground">{isAdmin ? "Painel Admin" : "Painel Parceiro"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 flex-shrink-0 text-muted-foreground hover:text-foreground lg:inline-flex"
|
||||
title="Recolher menu"
|
||||
aria-label="Recolher menu"
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden h-8 w-8 text-muted-foreground hover:text-foreground lg:inline-flex"
|
||||
title="Expandir menu"
|
||||
aria-label="Expandir menu"
|
||||
onClick={() => setCollapsed(false)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{allowedNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === ""}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
<nav className="flex-1 space-y-3 px-3 py-4">
|
||||
{sections.map((section) => {
|
||||
const sectionItems = allowedNavItems.filter((item) => item.section === section);
|
||||
if (sectionItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={section} className="space-y-1">
|
||||
{!collapsed ? (
|
||||
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||
{section}
|
||||
</p>
|
||||
) : null}
|
||||
{sectionItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === ""}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="hidden border-t border-sidebar-border px-3 py-4 lg:block">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="nav-item flex w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent hover:text-foreground lg:justify-start"
|
||||
<div className="mt-auto border-t border-sidebar-border p-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
GlobalFunctions.handleNavigateBack();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full gap-2 text-sidebar-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
collapsed ? "justify-center px-0" : "justify-start",
|
||||
)}
|
||||
title="Voltar ao CORE HGTX"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||
collapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Recolher</span>}
|
||||
</button>
|
||||
<ArrowLeft className="h-4 w-4 flex-shrink-0" />
|
||||
{!collapsed && <span>Voltar</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -86,7 +195,10 @@ export function AppSidebar() {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
setMobileOpen(true);
|
||||
}}
|
||||
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
|
||||
@@ -1,23 +1,261 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Building2, ChevronLeft, ChevronRight, Landmark, Loader2, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
fechamentoBancoPontosService,
|
||||
type BancoPontosEstaAtivoFiltro,
|
||||
type BancoPontosSaldoItem,
|
||||
} from "@/services/fechamento/bancoPontos";
|
||||
import { resolveParceiroFotoUrl } from "@/services/fechamento/parceiroFotoUrl";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function iniciais(nome: string): string {
|
||||
const p = nome.trim().split(/\s+/).slice(0, 2);
|
||||
return p.map((w) => w[0]?.toUpperCase() ?? "").join("") || "?";
|
||||
}
|
||||
|
||||
function getDisplayNome(row: BancoPontosSaldoItem): string {
|
||||
if (row.codinome?.trim()) {
|
||||
return `${row.nome} (${row.codinome.trim()})`;
|
||||
}
|
||||
return row.nome;
|
||||
}
|
||||
|
||||
function ParceiroFotoCell({
|
||||
nome,
|
||||
logoUrl,
|
||||
fotoUrl,
|
||||
}: {
|
||||
nome: string;
|
||||
logoUrl: string | null;
|
||||
fotoUrl?: string | null;
|
||||
}) {
|
||||
const [imgErro, setImgErro] = useState(false);
|
||||
const src = resolveParceiroFotoUrl(fotoUrl ?? logoUrl);
|
||||
|
||||
useEffect(() => {
|
||||
setImgErro(false);
|
||||
}, [src]);
|
||||
const classes =
|
||||
"h-12 w-12 rounded-lg border border-border object-cover";
|
||||
|
||||
if (!src || imgErro) {
|
||||
return (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{iniciais(nome)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={classes}
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
onError={() => setImgErro(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BancoPontos() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rows, setRows] = useState<BancoPontosSaldoItem[]>([]);
|
||||
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
|
||||
const [buscaInput, setBuscaInput] = useState("");
|
||||
const [buscaDebounced, setBuscaDebounced] = useState("");
|
||||
const [estaAtivo, setEstaAtivo] = useState<BancoPontosEstaAtivoFiltro>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fechamentoBancoPontosService.listarSaldos({
|
||||
estaAtivo,
|
||||
busca: buscaDebounced.trim() || undefined,
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
});
|
||||
setRows(res.data ?? []);
|
||||
setMeta(res.meta);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Erro ao carregar banco de pontos.";
|
||||
toast.error(msg);
|
||||
setRows([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [estaAtivo, buscaDebounced, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => setBuscaDebounced(buscaInput), 350);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [buscaInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [estaAtivo, buscaDebounced]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Banco de Pontos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Consulte saldos por parceiro e extrato de créditos/débitos.
|
||||
</p>
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Banco de Pontos
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Lista de parceiros e saldo; use Detalhes para ver o extrato completo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="metric-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Estrutura inicial pronta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-1 flex-col gap-4 p-3 md:p-6">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/80 bg-card/50 p-4 md:flex-row md:flex-wrap md:items-end">
|
||||
<div className="min-w-[200px] flex-1 space-y-2">
|
||||
<Label htmlFor="banco-busca">Buscar parceiro</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="banco-busca"
|
||||
className="pl-9"
|
||||
placeholder="Nome ou codinome..."
|
||||
value={buscaInput}
|
||||
onChange={(e) => setBuscaInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full space-y-2 md:w-48">
|
||||
<Label>Status</Label>
|
||||
<Select value={estaAtivo} onValueChange={(v) => setEstaAtivo(v as BancoPontosEstaAtivoFiltro)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="true">Ativos</SelectItem>
|
||||
<SelectItem value="false">Inativos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[88px]">Foto</TableHead>
|
||||
<TableHead className="min-w-[220px]">Nome</TableHead>
|
||||
<TableHead className="min-w-[120px] text-right">Saldo</TableHead>
|
||||
<TableHead className="w-[140px] text-center">Detalhes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-14 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
|
||||
Carregando parceiros...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
|
||||
<Building2 className="mx-auto mb-2 h-10 w-10 opacity-40" />
|
||||
Nenhum parceiro encontrado com os filtros atuais.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.parceiroId}>
|
||||
<TableCell>
|
||||
<ParceiroFotoCell
|
||||
key={row.parceiroId}
|
||||
nome={row.nome}
|
||||
logoUrl={row.logoUrl}
|
||||
fotoUrl={row.fotoUrl}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
|
||||
<TableCell
|
||||
className={`text-right font-semibold tabular-nums ${
|
||||
Number(row.saldo) >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatPontos(Number(row.saldo))}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
onClick={() =>
|
||||
navigate(`/fechamento/banco-pontos/${row.parceiroId}`, {
|
||||
state: { nomeExibicao: getDisplayNome(row) },
|
||||
})
|
||||
}
|
||||
>
|
||||
Detalhes
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!loading && meta.totalPaginas > 1 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Página {meta.paginaAtual} de {meta.totalPaginas}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= meta.totalPaginas}
|
||||
onClick={() => setPage((p) => Math.min(meta.totalPaginas, p + 1))}
|
||||
>
|
||||
Próxima
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Landmark,
|
||||
Loader2,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
fechamentoBancoPontosService,
|
||||
type BancoPontosExtratoItem,
|
||||
type BancoPontosParceiroExtrato,
|
||||
} from "@/services/fechamento/bancoPontos";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function formatData(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Competência do fechamento (mês/ano), quando a API envia `competenciaMes` e `competenciaAno`. */
|
||||
function formatCompetenciaMesAno(mes: number | null, ano: number | null): string {
|
||||
if (mes == null || ano == null) return "—";
|
||||
const m = Math.trunc(Number(mes));
|
||||
const y = Math.trunc(Number(ano));
|
||||
if (!Number.isFinite(m) || !Number.isFinite(y) || m < 1 || m > 12) return "—";
|
||||
return `${String(m).padStart(2, "0")}/${y}`;
|
||||
}
|
||||
|
||||
type ExtratoLocationState = { nomeExibicao?: string };
|
||||
|
||||
function nomeExibicaoParceiro(p: BancoPontosParceiroExtrato): string {
|
||||
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
|
||||
}
|
||||
|
||||
export default function BancoPontosExtrato() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { parceiroId = "" } = useParams();
|
||||
const nomeDoState = (location.state as ExtratoLocationState | null)?.nomeExibicao?.trim();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exportando, setExportando] = useState(false);
|
||||
const [parceiro, setParceiro] = useState<BancoPontosParceiroExtrato | null>(null);
|
||||
const [linhas, setLinhas] = useState<BancoPontosExtratoItem[]>([]);
|
||||
const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!parceiroId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fechamentoBancoPontosService.listarExtrato(parceiroId, {
|
||||
page,
|
||||
perPage: PER_PAGE,
|
||||
});
|
||||
setParceiro(res.parceiro);
|
||||
setLinhas(res.data ?? []);
|
||||
setMeta(res.meta);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Erro ao carregar extrato.";
|
||||
toast.error(msg);
|
||||
setParceiro(null);
|
||||
setLinhas([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [parceiroId, page]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setMeta({ total: 0, paginaAtual: 1, totalPaginas: 1 });
|
||||
}, [parceiroId]);
|
||||
|
||||
const tituloParceiro = parceiro
|
||||
? nomeExibicaoParceiro(parceiro)
|
||||
: nomeDoState ?? (loading ? "Carregando…" : "Parceiro");
|
||||
|
||||
const saldoAtual = parceiro != null ? Number(parceiro.saldo ?? 0) : null;
|
||||
const saldoFormatado =
|
||||
parceiro != null && saldoAtual !== null && Number.isFinite(saldoAtual) ? formatPontos(saldoAtual) : null;
|
||||
const linhasOrdenadas = useMemo(
|
||||
() =>
|
||||
[...linhas].sort((a, b) => {
|
||||
const timeA = new Date(a.criadoEm).getTime();
|
||||
const timeB = new Date(b.criadoEm).getTime();
|
||||
if (Number.isNaN(timeA) || Number.isNaN(timeB)) {
|
||||
return String(a.criadoEm).localeCompare(String(b.criadoEm));
|
||||
}
|
||||
return timeA - timeB;
|
||||
}),
|
||||
[linhas],
|
||||
);
|
||||
|
||||
const intervaloLabel = (() => {
|
||||
if (meta.total === 0) return "Mostrando 0 de 0";
|
||||
const inicio = (page - 1) * PER_PAGE + 1;
|
||||
const fim = Math.min(page * PER_PAGE, meta.total);
|
||||
return `Mostrando ${inicio}–${fim} de ${meta.total}`;
|
||||
})();
|
||||
|
||||
const handleExportarXlsx = async () => {
|
||||
if (!parceiroId) return;
|
||||
try {
|
||||
setExportando(true);
|
||||
const { buffer, filename } = await fechamentoBancoPontosService.exportarExtrato(parceiroId);
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename ?? `banco-pontos-${parceiroId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Planilha exportada com sucesso.");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Erro ao exportar extrato.";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setExportando(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||
<div className="mb-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento/banco-pontos")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar ao banco de pontos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="flex flex-wrap items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
|
||||
<span className="truncate">Extrato — {tituloParceiro}</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Histórico de créditos e débitos (mais antigos primeiro).
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">Carregando saldo atual…</p>
|
||||
) : parceiro != null && saldoFormatado != null ? (
|
||||
<div className="mt-3 flex flex-wrap items-baseline gap-2 rounded-lg border border-border/80 bg-muted/30 px-3 py-2 md:px-4 md:py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Wallet className="h-4 w-4 shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">Saldo atual</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-2xl font-bold tabular-nums md:text-3xl ${
|
||||
(saldoAtual ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{saldoFormatado}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">pontos</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 p-3 md:p-6">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border bg-background text-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
disabled={!parceiroId || exportando}
|
||||
onClick={() => void handleExportarXlsx()}
|
||||
>
|
||||
{exportando ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Exportar XLSX
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[150px]">Data</TableHead>
|
||||
<TableHead className="min-w-[110px] whitespace-nowrap">Competência</TableHead>
|
||||
<TableHead className="min-w-[280px]">Descrição</TableHead>
|
||||
<TableHead className="min-w-[100px]">Tipo</TableHead>
|
||||
<TableHead className="min-w-[130px] text-right">Valor (Pontos)</TableHead>
|
||||
<TableHead className="min-w-[120px] text-center">Fechamento</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-14 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
|
||||
Carregando extrato...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : linhasOrdenadas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
Nenhuma movimentação registrada para este parceiro.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
linhasOrdenadas.map((linha) => {
|
||||
const isCredito = linha.tipo === "credito";
|
||||
const q = Number(linha.quantidade);
|
||||
return (
|
||||
<TableRow key={linha.id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatData(linha.criadoEm)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[420px] text-sm font-medium leading-snug">{linha.descricao}</TableCell>
|
||||
<TableCell>
|
||||
{isCredito ? (
|
||||
<Badge className="border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
Crédito
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50 dark:bg-red-950/40 dark:text-red-200">
|
||||
Débito
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right font-semibold tabular-nums ${
|
||||
isCredito ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isCredito ? "+" : "−"}
|
||||
{formatPontos(q)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{linha.fechamentoId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1 text-foreground hover:bg-zinc-200 hover:text-foreground dark:hover:bg-zinc-700"
|
||||
onClick={() =>
|
||||
navigate(`/fechamento/fechamentos/${linha.fechamentoId}`, {
|
||||
state: { competenciaId: undefined, status: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Abrir
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? (
|
||||
"Carregando paginação…"
|
||||
) : (
|
||||
<>
|
||||
{intervaloLabel}
|
||||
{" · "}
|
||||
Página {meta.paginaAtual} de{" "}
|
||||
{meta.total === 0 ? 1 : Math.max(1, meta.totalPaginas)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || page >= meta.totalPaginas || meta.totalPaginas < 1}
|
||||
onClick={() => setPage((p) => Math.min(Math.max(1, meta.totalPaginas), p + 1))}
|
||||
>
|
||||
Próxima
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Download, ExternalLink, FileSpreadsheet, FolderKanban, RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileSpreadsheet,
|
||||
FolderKanban,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,8 +22,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||
|
||||
@@ -35,14 +45,58 @@ function formatHoras(minutos: number | null): string {
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function AsanaImportLoadingCard() {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-6 py-10 text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="relative flex h-20 w-20 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-full border-4 border-muted" />
|
||||
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
|
||||
<Download className="relative h-8 w-8 text-primary" aria-hidden />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-2">
|
||||
<p className="text-lg font-semibold text-foreground">Sincronizando com o Asana</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Buscando tarefas concluídas no período da competência e montando os fechamentos. Pode levar um minuto —
|
||||
não feche esta página.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<div className="h-2 animate-pulse rounded-full bg-muted" />
|
||||
<div className="mx-auto h-2 w-[85%] animate-pulse rounded-full bg-muted" />
|
||||
<div className="mx-auto h-2 w-[60%] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reprocessamentoLabel(modo: "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"): string {
|
||||
if (modo === "reprocessar_tudo") return "Reimportando toda a competência a partir do Asana.";
|
||||
if (modo === "reprocessar_alguns") return "Atualizando os parceiros selecionados no Asana.";
|
||||
return "Buscando novos fechamentos no Asana.";
|
||||
}
|
||||
|
||||
export default function CompetenciaFechamentos() {
|
||||
const { me } = useAuthAccess();
|
||||
const { id: competenciaId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
/** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */
|
||||
const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null);
|
||||
const importing = importKind !== null;
|
||||
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
|
||||
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
|
||||
const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false);
|
||||
const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false);
|
||||
const [concluindoCompetencia, setConcluindoCompetencia] = useState(false);
|
||||
const [reabrindoCompetencia, setReabrindoCompetencia] = useState(false);
|
||||
const [motivoReabertura, setMotivoReabertura] = useState("");
|
||||
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
|
||||
const [reprocessMode, setReprocessMode] = useState<
|
||||
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
|
||||
>("reprocessar_tudo");
|
||||
@@ -51,8 +105,13 @@ export default function CompetenciaFechamentos() {
|
||||
const loadFechamentos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId);
|
||||
const [data, competencias] = await Promise.all([
|
||||
fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId),
|
||||
fechamentoCompetenciasService.listarCompetencias({}),
|
||||
]);
|
||||
const competenciaAtual = competencias.find((item) => item.id === competenciaId);
|
||||
setFechamentos(data);
|
||||
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
|
||||
@@ -64,7 +123,7 @@ export default function CompetenciaFechamentos() {
|
||||
};
|
||||
|
||||
const handleImportarAsana = async () => {
|
||||
setImporting(true);
|
||||
setImportKind("sheet");
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
|
||||
toast.success(
|
||||
@@ -75,7 +134,7 @@ export default function CompetenciaFechamentos() {
|
||||
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
setImportKind(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +144,7 @@ export default function CompetenciaFechamentos() {
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
setImportKind("modal");
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
|
||||
modo: reprocessMode,
|
||||
@@ -106,7 +165,46 @@ export default function CompetenciaFechamentos() {
|
||||
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
setImportKind(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConcluirCompetencia = async () => {
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para concluir a competência.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setConcluindoCompetencia(true);
|
||||
await fechamentoCompetenciasService.concluirCompetencia(competenciaId, me.id);
|
||||
toast.success("Competência concluída com sucesso.");
|
||||
setIsConcluirModalOpen(false);
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao concluir competência.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setConcluindoCompetencia(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReabrirCompetencia = async () => {
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para reabrir a competência.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReabrindoCompetencia(true);
|
||||
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim() || undefined);
|
||||
toast.success("Competência reaberta com sucesso.");
|
||||
setIsReabrirModalOpen(false);
|
||||
setMotivoReabertura("");
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao reabrir competência.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setReabrindoCompetencia(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,40 +248,63 @@ export default function CompetenciaFechamentos() {
|
||||
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
|
||||
}, [fechamentos]);
|
||||
|
||||
const temFechamentos = fechamentos.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento-hgtx")}>
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos da Competência
|
||||
</h1>
|
||||
{!loading && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fechamentos.length} fechamento(s)
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsReprocessModalOpen(true)}
|
||||
disabled={importing}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos da Competência
|
||||
</h1>
|
||||
{!loading && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
|
||||
{competenciaStatus === "concluido" ? (
|
||||
<Button
|
||||
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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsConcluirModalOpen(true)}
|
||||
disabled={concluindoCompetencia}
|
||||
>
|
||||
{concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && fechamentos.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<Card className="mx-auto mt-12 max-w-2xl border-border/80 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -191,10 +312,14 @@ export default function CompetenciaFechamentos() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-6">
|
||||
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{importing ? "Importando..." : "Importar tasks do Asana"}
|
||||
</Button>
|
||||
{importKind === "sheet" ? (
|
||||
<AsanaImportLoadingCard />
|
||||
) : (
|
||||
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Importar tasks do Asana
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -213,8 +338,18 @@ export default function CompetenciaFechamentos() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
Carregando fechamentos...
|
||||
<TableCell colSpan={6} className="py-16">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 className="h-9 w-9 animate-spin text-primary" aria-hidden />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Carregando fechamentos</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Aguarde um instante.</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -238,8 +373,8 @@ export default function CompetenciaFechamentos() {
|
||||
<Badge
|
||||
className={
|
||||
row.status === "fechado"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-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"
|
||||
}
|
||||
>
|
||||
{row.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
@@ -250,6 +385,7 @@ export default function CompetenciaFechamentos() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
onClick={() => void handleExportar(row.id)}
|
||||
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
|
||||
title="Exportar planilha financeira (XLSX)"
|
||||
@@ -260,8 +396,9 @@ export default function CompetenciaFechamentos() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
onClick={() =>
|
||||
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
|
||||
navigate(`/fechamento/fechamentos/${row.id}`, {
|
||||
state: { competenciaId, status: row.status },
|
||||
})
|
||||
}
|
||||
@@ -280,17 +417,57 @@ export default function CompetenciaFechamentos() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reprocessar Asana</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha uma estratégia de reprocessamento para esta competência.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog
|
||||
open={isReprocessModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && importing) return;
|
||||
setIsReprocessModalOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="flex min-h-0 max-h-[85vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:w-full"
|
||||
hideClose={importing}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (importing) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (importing) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{importKind === "modal" ? (
|
||||
<div
|
||||
className="absolute inset-0 z-[60] flex flex-col items-center justify-center gap-4 rounded-b-lg rounded-t-lg bg-background/90 p-6 text-center backdrop-blur-sm sm:rounded-lg"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="relative flex h-16 w-16 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-full border-4 border-muted" />
|
||||
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
|
||||
<RefreshCcw className="relative h-7 w-7 text-primary" aria-hidden />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<p className="text-lg font-semibold text-foreground">Processando no Asana</p>
|
||||
<p className="text-sm text-muted-foreground">{reprocessamentoLabel(reprocessMode)}</p>
|
||||
<p className="text-xs text-muted-foreground">Não feche esta janela até a operação terminar.</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-xs flex-col gap-2">
|
||||
<div className="h-1.5 animate-pulse rounded-full bg-muted" />
|
||||
<div className="mx-auto h-1.5 w-[75%] animate-pulse rounded-full bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-6 pb-0 pt-12 sm:pt-14">
|
||||
<DialogHeader className="shrink-0 space-y-1.5 pr-8 text-left sm:pr-0">
|
||||
<DialogTitle>Reprocessar Asana</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha uma estratégia de reprocessamento para esta competência.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4 pr-1">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("reprocessar_tudo")}
|
||||
@@ -389,14 +566,95 @@ export default function CompetenciaFechamentos() {
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
|
||||
<DialogFooter className="shrink-0 gap-2 border-t bg-background p-4 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsReprocessModalOpen(false)}
|
||||
disabled={importing}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
|
||||
{importing ? "Processando..." : "Executar"}
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
Processando...
|
||||
</>
|
||||
) : (
|
||||
"Executar"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isReabrirModalOpen} onOpenChange={setIsReabrirModalOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reabrir competência</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-reabrir-competencia">Motivo (opcional)</Label>
|
||||
<Input
|
||||
id="motivo-reabrir-competencia"
|
||||
value={motivoReabertura}
|
||||
onChange={(e) => setMotivoReabertura(e.target.value)}
|
||||
placeholder="Ex.: correção de ajustes pós-fechamento"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsReabrirModalOpen(false)}
|
||||
disabled={reabrindoCompetencia}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReabrirCompetencia()} disabled={reabrindoCompetencia}>
|
||||
{reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isConcluirModalOpen} onOpenChange={setIsConcluirModalOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Concluir competência</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja concluir esta competência? A operação só será permitida se todos os fechamentos estiverem fechados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsConcluirModalOpen(false)}
|
||||
disabled={concluindoCompetencia}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleConcluirCompetencia()}
|
||||
disabled={concluindoCompetencia}
|
||||
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
|
||||
>
|
||||
{concluindoCompetencia ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Concluindo...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar conclusão"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MagicCard } from "@/components/ui/magic-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -210,7 +211,7 @@ export default function Configuracoes() {
|
||||
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
|
||||
toast.success("Unidade cadastrada.");
|
||||
clearCommanderUnidadeIdCache();
|
||||
navigate("/fechamento-hgtx", { replace: true });
|
||||
navigate("/fechamento", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
|
||||
@@ -242,15 +243,19 @@ export default function Configuracoes() {
|
||||
</header>
|
||||
|
||||
<div ref={unidadeSectionRef} id="unidade">
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Unidade</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
|
||||
estabelecimento é somente leitura.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-6 py-6">
|
||||
<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-0 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle id="titulo-codigo-estabelecimento" className="text-base font-semibold">
|
||||
Código do estabelecimento
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-6 py-6">
|
||||
{loadingUnidade ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -268,8 +273,13 @@ export default function Configuracoes() {
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Código do estabelecimento (transfer)</Label>
|
||||
<Input value={codigoEstabelecimento} readOnly className="h-11 font-mono text-sm bg-muted" />
|
||||
<Input
|
||||
id="codigo-estabelecimento"
|
||||
aria-labelledby="titulo-codigo-estabelecimento"
|
||||
value={codigoEstabelecimento}
|
||||
readOnly
|
||||
className="h-11 font-mono text-sm bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome-unidade">Nome da unidade</Label>
|
||||
@@ -310,19 +320,26 @@ export default function Configuracoes() {
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</MagicCard>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-6 py-6">
|
||||
<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">Integração Asana</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-6 py-6">
|
||||
{configAsanaError ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -455,6 +472,7 @@ export default function Configuracoes() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</MagicCard>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, CheckCircle2, Clock3, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
|
||||
import { ArrowLeft, CheckCircle2, Clock3, ExternalLink, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -49,9 +49,44 @@ function parsePontuacaoInput(value: string): number {
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
/** Pontuação do modal de lançamento: só inteiros ≥ 0 (o tipo bonus/desconto define o sinal no backend). */
|
||||
function sanitizePontuacaoLancamentoDigitando(raw: string): string {
|
||||
const t = raw.trim();
|
||||
if (t === "") return "";
|
||||
const n = parsePontuacaoInput(t);
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
return String(Math.trunc(Math.min(n, Number.MAX_SAFE_INTEGER)));
|
||||
}
|
||||
|
||||
function toPontuacaoInput(value: number): string {
|
||||
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
return String(rounded);
|
||||
}
|
||||
|
||||
function formatDateOnly(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "—";
|
||||
return parsed.toLocaleDateString("pt-BR");
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "—";
|
||||
return parsed.toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
type FechamentoDetalhesLocationState = {
|
||||
competenciaId?: string;
|
||||
status?: "em_aberto" | "fechado";
|
||||
readonlyView?: boolean;
|
||||
};
|
||||
|
||||
export default function FechamentoDetalhes() {
|
||||
@@ -64,7 +99,8 @@ export default function FechamentoDetalhes() {
|
||||
const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tarefas, setTarefas] = useState<FechamentoTarefaItem[]>([]);
|
||||
const [togglingTaskId, setTogglingTaskId] = useState<string | null>(null);
|
||||
const [estadoRevisaoInicial, setEstadoRevisaoInicial] = useState<Record<string, boolean>>({});
|
||||
const [bulkUpdatingRevisao, setBulkUpdatingRevisao] = useState(false);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
|
||||
const [savingLancamento, setSavingLancamento] = useState(false);
|
||||
@@ -91,7 +127,10 @@ export default function FechamentoDetalhes() {
|
||||
const [edicaoCliente, setEdicaoCliente] = useState("");
|
||||
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
|
||||
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
|
||||
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
|
||||
const isFechado = fechamentoStatus === "fechado";
|
||||
const isCompetenciaConcluida = competenciaStatus === "concluido";
|
||||
const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida;
|
||||
|
||||
const totais = useMemo(() => {
|
||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
||||
@@ -108,14 +147,26 @@ export default function FechamentoDetalhes() {
|
||||
const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
|
||||
const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
|
||||
const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
|
||||
const pontuacaoTotalLabel = String(totais.pontos);
|
||||
const pontuacaoTotalLabel = toPontuacaoInput(totais.pontos);
|
||||
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
|
||||
const todasTarefasRevisadas = tarefas.length > 0 && tarefas.every((tarefa) => tarefa.estaRevisada);
|
||||
|
||||
const loadTarefas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
|
||||
setTarefas(data);
|
||||
const tarefasOrdenadas = [...data].sort((a, b) => {
|
||||
const timeA = a.dataConclusao ? new Date(a.dataConclusao).getTime() : Number.POSITIVE_INFINITY;
|
||||
const timeB = b.dataConclusao ? new Date(b.dataConclusao).getTime() : Number.POSITIVE_INFINITY;
|
||||
return timeA - timeB;
|
||||
});
|
||||
const estadoInicialBanco = Object.fromEntries(tarefasOrdenadas.map((tarefa) => [tarefa.id, Boolean(tarefa.estaRevisada)]));
|
||||
const tarefasDefaultRevisadas = tarefasOrdenadas.map((tarefa) => ({
|
||||
...tarefa,
|
||||
estaRevisada: true,
|
||||
}));
|
||||
setTarefas(tarefasDefaultRevisadas);
|
||||
setEstadoRevisaoInicial(estadoInicialBanco);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
||||
toast.error(message);
|
||||
@@ -128,11 +179,16 @@ export default function FechamentoDetalhes() {
|
||||
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
|
||||
if (!currentCompetenciaId) return;
|
||||
try {
|
||||
const rows = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
|
||||
const [rows, competencias] = await Promise.all([
|
||||
fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId),
|
||||
fechamentoCompetenciasService.listarCompetencias({}),
|
||||
]);
|
||||
const current = rows.find((row) => row.id === fechamentoId);
|
||||
const competenciaAtual = competencias.find((item) => item.id === currentCompetenciaId);
|
||||
if (!current) return;
|
||||
setFechamentoStatus(current.status);
|
||||
setPontuacaoMeta(current.pontuacaoMeta);
|
||||
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
|
||||
if (!competenciaId) {
|
||||
setCompetenciaId(current.competenciaId);
|
||||
}
|
||||
@@ -145,6 +201,10 @@ export default function FechamentoDetalhes() {
|
||||
toast.error("Fechamento está fechado. Reabra para editar.");
|
||||
};
|
||||
|
||||
const toastBloqueioCompetenciaConcluida = () => {
|
||||
toast.error("Competência concluída. Reabra a competência para editar.");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fechamentoId) {
|
||||
void loadTarefas();
|
||||
@@ -165,12 +225,14 @@ export default function FechamentoDetalhes() {
|
||||
}, [isFechado]);
|
||||
|
||||
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const previous = tarefas;
|
||||
setTogglingTaskId(tarefa.id);
|
||||
setTarefas((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === tarefa.id
|
||||
@@ -181,21 +243,48 @@ export default function FechamentoDetalhes() {
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (!me?.id) {
|
||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
}
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
||||
estaRevisada: approved,
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setTarefas(previous);
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar aprovação da tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setTogglingTaskId(null);
|
||||
};
|
||||
|
||||
const handleToggleTodasAprovadas = async () => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const novoValor = !todasTarefasRevisadas;
|
||||
const tarefasParaAtualizar = tarefas.filter((tarefa) => tarefa.estaRevisada !== novoValor);
|
||||
if (tarefasParaAtualizar.length === 0) return;
|
||||
setBulkUpdatingRevisao(true);
|
||||
setTarefas((prev) => prev.map((tarefa) => ({ ...tarefa, estaRevisada: novoValor })));
|
||||
setBulkUpdatingRevisao(false);
|
||||
};
|
||||
|
||||
const persistirRevisoesPendentes = async () => {
|
||||
if (!me?.id) {
|
||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
}
|
||||
const tarefasAlteradas = tarefas.filter(
|
||||
(tarefa) => estadoRevisaoInicial[tarefa.id] !== undefined && estadoRevisaoInicial[tarefa.id] !== tarefa.estaRevisada,
|
||||
);
|
||||
if (tarefasAlteradas.length === 0) return;
|
||||
await Promise.all(
|
||||
tarefasAlteradas.map((tarefa) =>
|
||||
fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
||||
estaRevisada: tarefa.estaRevisada,
|
||||
editadoPorId: me.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
setEstadoRevisaoInicial((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const tarefa of tarefasAlteradas) {
|
||||
next[tarefa.id] = tarefa.estaRevisada;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const resetLancamentoForm = () => {
|
||||
@@ -205,12 +294,16 @@ export default function FechamentoDetalhes() {
|
||||
};
|
||||
|
||||
const handleSalvarLancamento = async () => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const descricao = lancamentoDescricao.trim();
|
||||
const pontuacao = Number(lancamentoPontuacao);
|
||||
const pontuacao = Math.trunc(Number(lancamentoPontuacao));
|
||||
if (!descricao) {
|
||||
toast.error("Informe a descrição do lançamento.");
|
||||
return;
|
||||
@@ -240,6 +333,10 @@ export default function FechamentoDetalhes() {
|
||||
};
|
||||
|
||||
const handleAbrirConcluir = () => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
@@ -250,6 +347,10 @@ export default function FechamentoDetalhes() {
|
||||
};
|
||||
|
||||
const handleConcluirFechamento = async () => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
@@ -269,13 +370,14 @@ export default function FechamentoDetalhes() {
|
||||
}
|
||||
try {
|
||||
setConcluindo(true);
|
||||
await persistirRevisoesPendentes();
|
||||
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
||||
pontuacaoPaga: pontuacaoPagaRaw,
|
||||
motivoAjuste: motivoAjuste.trim() || undefined,
|
||||
});
|
||||
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
||||
setFechamentoStatus("fechado");
|
||||
navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
|
||||
navigate(`/fechamento/competencias/${data.competenciaId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
|
||||
toast.error(message);
|
||||
@@ -321,6 +423,10 @@ export default function FechamentoDetalhes() {
|
||||
};
|
||||
|
||||
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
@@ -349,6 +455,10 @@ export default function FechamentoDetalhes() {
|
||||
};
|
||||
|
||||
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isCompetenciaConcluida) {
|
||||
toastBloqueioCompetenciaConcluida();
|
||||
return;
|
||||
}
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
@@ -446,7 +556,7 @@ export default function FechamentoDetalhes() {
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||
<div className="mb-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||
<Button variant="ghost" size="sm" className="hover:bg-muted/60 hover:text-foreground" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -463,24 +573,25 @@ export default function FechamentoDetalhes() {
|
||||
|
||||
{!loading ? (
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Badge variant={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
|
||||
<Badge variant={isReadonly ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
|
||||
{isCompetenciaConcluida ? <Badge variant="secondary">Competência concluída</Badge> : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsLancamentoOpen(true)}
|
||||
disabled={isFechado}
|
||||
className="min-w-[152px]"
|
||||
disabled={isReadonly}
|
||||
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Fazer lançamento
|
||||
</Button>
|
||||
{!isFechado ? (
|
||||
{!isReadonly ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(true)}
|
||||
disabled={reprocessandoAsana}
|
||||
className="min-w-[152px]"
|
||||
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
@@ -491,7 +602,7 @@ export default function FechamentoDetalhes() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReabrirOpen(true)}
|
||||
disabled={reabrindo}
|
||||
disabled={reabrindo || isCompetenciaConcluida}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
@@ -502,7 +613,7 @@ export default function FechamentoDetalhes() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAbrirConcluir}
|
||||
disabled={tarefas.length === 0 || concluindo}
|
||||
disabled={tarefas.length === 0 || concluindo || isCompetenciaConcluida}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
@@ -515,6 +626,11 @@ export default function FechamentoDetalhes() {
|
||||
|
||||
{!loading ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{isCompetenciaConcluida ? (
|
||||
<div className="sm:col-span-2 xl:col-span-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
Competência concluída. Reabra a competência para editar este fechamento.
|
||||
</div>
|
||||
) : null}
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -564,7 +680,26 @@ export default function FechamentoDetalhes() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<div className="space-y-3">
|
||||
{!loading ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleToggleTodasAprovadas()}
|
||||
disabled={isReadonly || bulkUpdatingRevisao}
|
||||
>
|
||||
{bulkUpdatingRevisao ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{todasTarefasRevisadas ? "Desmarcar todas" : "Marcar todas"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -573,6 +708,10 @@ export default function FechamentoDetalhes() {
|
||||
<TableHead className="min-w-[320px]">Descrição</TableHead>
|
||||
<TableHead className="min-w-[180px]">Cliente</TableHead>
|
||||
<TableHead className="min-w-[110px]">Tipo</TableHead>
|
||||
<TableHead className="min-w-[130px]">Data início</TableHead>
|
||||
<TableHead className="min-w-[140px]">Data vencimento</TableHead>
|
||||
<TableHead className="min-w-[170px]">Data conclusão</TableHead>
|
||||
<TableHead className="min-w-[240px]">Etiquetas</TableHead>
|
||||
<TableHead className="min-w-[120px]">Horas</TableHead>
|
||||
<TableHead className="min-w-[120px]">Pontuação</TableHead>
|
||||
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
|
||||
@@ -581,7 +720,7 @@ export default function FechamentoDetalhes() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
<TableCell colSpan={12} className="py-8 text-center text-muted-foreground">
|
||||
Carregando tarefas...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -593,25 +732,48 @@ export default function FechamentoDetalhes() {
|
||||
<Checkbox
|
||||
checked={tarefa.estaRevisada}
|
||||
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
|
||||
disabled={togglingTaskId === tarefa.id || isFechado}
|
||||
disabled={isReadonly || bulkUpdatingRevisao}
|
||||
/>
|
||||
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
||||
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
||||
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
||||
<TableCell>{tarefa.tipo}</TableCell>
|
||||
<TableCell>{formatDateOnly(tarefa.dataInicio)}</TableCell>
|
||||
<TableCell>{formatDateOnly(tarefa.dataVencimento)}</TableCell>
|
||||
<TableCell>{formatDateTime(tarefa.dataConclusao)}</TableCell>
|
||||
<TableCell>
|
||||
{tarefa.etiquetas && tarefa.etiquetas.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tarefa.etiquetas.map((etiqueta) => (
|
||||
<Badge key={`${tarefa.id}-${etiqueta.gid}`} variant="outline" className="font-normal">
|
||||
{etiqueta.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
|
||||
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{tarefa.linkAsana ? (
|
||||
<Button type="button" size="sm" variant="ghost" asChild>
|
||||
<a href={tarefa.linkAsana} target="_blank" rel="noopener noreferrer" title="Abrir tarefa no Asana">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="ml-1">Abrir</span>
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditarTarefa(tarefa)}
|
||||
disabled={isFechado}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="ml-1">Editar</span>
|
||||
@@ -623,7 +785,7 @@ export default function FechamentoDetalhes() {
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => void handleExcluirLancamento(tarefa)}
|
||||
disabled={isFechado || deletingTaskId === tarefa.id}
|
||||
disabled={isReadonly || deletingTaskId === tarefa.id}
|
||||
>
|
||||
{deletingTaskId === tarefa.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -641,6 +803,7 @@ export default function FechamentoDetalhes() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -675,10 +838,16 @@ export default function FechamentoDetalhes() {
|
||||
<Input
|
||||
id="lancamento-pontuacao"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
step={1}
|
||||
value={lancamentoPontuacao}
|
||||
onChange={(e) => setLancamentoPontuacao(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "-" || e.key === "+" || e.key === "e" || e.key === "E" || e.key === "," || e.key === ".") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -696,6 +865,7 @@ export default function FechamentoDetalhes() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
@@ -704,7 +874,7 @@ export default function FechamentoDetalhes() {
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
|
||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}>
|
||||
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -799,11 +969,27 @@ export default function FechamentoDetalhes() {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsConcluirOpen(false)}
|
||||
disabled={concluindo}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
|
||||
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
|
||||
<Button
|
||||
onClick={() => void handleConcluirFechamento()}
|
||||
disabled={concluindo || isReadonly}
|
||||
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
|
||||
>
|
||||
{concluindo ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Concluindo...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar conclusão"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -827,7 +1013,12 @@ export default function FechamentoDetalhes() {
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsReabrirOpen(false)}
|
||||
disabled={reabrindo}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
||||
@@ -849,6 +1040,7 @@ export default function FechamentoDetalhes() {
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingTarefa(null);
|
||||
@@ -880,13 +1072,26 @@ export default function FechamentoDetalhes() {
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsReprocessAsanaOpen(false)}
|
||||
disabled={reprocessandoAsana}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
|
||||
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
|
||||
<Button
|
||||
onClick={() => void handleReprocessarAsana()}
|
||||
disabled={reprocessandoAsana}
|
||||
variant="secondary"
|
||||
className="shadow-md hover:shadow-secondary/40"
|
||||
>
|
||||
{reprocessandoAsana ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Reprocessando...
|
||||
</>
|
||||
) : (
|
||||
"Confirmar reprocessamento"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -948,10 +1153,15 @@ export default function FechamentoDetalhes() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsEditarOpen(false)}
|
||||
disabled={savingEdicao}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
|
||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}>
|
||||
{savingEdicao ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -57,7 +57,10 @@ export default function Fechamentos() {
|
||||
|
||||
const competenciasFiltradas = useMemo(() => {
|
||||
if (!anoSelecionado) return [];
|
||||
return allCompetencias.filter((c) => c.ano === Number(anoSelecionado));
|
||||
return allCompetencias
|
||||
.filter((c) => c.ano === Number(anoSelecionado))
|
||||
.slice()
|
||||
.sort((a, b) => a.mes - b.mes);
|
||||
}, [allCompetencias, anoSelecionado]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -141,15 +144,15 @@ export default function Fechamentos() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Competência</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Mês</label>
|
||||
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione o mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{meses.map ((m, i) => (
|
||||
{meses.map((m, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
@@ -160,7 +163,7 @@ export default function Fechamentos() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Ano</label>
|
||||
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecione o ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -174,7 +177,12 @@ export default function Fechamentos() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalAberto(false)} disabled={criando}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setModalAberto(false)}
|
||||
disabled={criando}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
||||
@@ -238,8 +246,8 @@ export default function Fechamentos() {
|
||||
<Badge
|
||||
className={
|
||||
competencia.status === "concluido"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-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"
|
||||
}
|
||||
>
|
||||
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
|
||||
@@ -250,7 +258,7 @@ export default function Fechamentos() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
|
||||
onClick={() => navigate(`/fechamento/competencias/${competencia.id}`)}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Acessar
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, FileSpreadsheet, Landmark, ListChecks, Loader2, Target } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { 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 { authMeService } from "@/services/fechamento/authMe";
|
||||
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const meses = [
|
||||
"Janeiro",
|
||||
"Fevereiro",
|
||||
"Março",
|
||||
"Abril",
|
||||
"Maio",
|
||||
"Junho",
|
||||
"Julho",
|
||||
"Agosto",
|
||||
"Setembro",
|
||||
"Outubro",
|
||||
"Novembro",
|
||||
"Dezembro",
|
||||
];
|
||||
|
||||
function formatCompetenciaMes(mes: number, ano: number): string {
|
||||
return `${meses[mes - 1] ?? `Mês ${mes}`} / ${ano}`;
|
||||
}
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function MeuFechamento() {
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAuthAccess();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
|
||||
const [anoSelecionado, setAnoSelecionado] = useState<string>(String(currentYear));
|
||||
const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuFechamento>> | null>(null);
|
||||
|
||||
const semVinculo = !me?.parceiroId;
|
||||
const competenciasOrdenadas = useMemo(() => {
|
||||
if (!dados) return [];
|
||||
return [...dados.competencias].sort((a, b) => a.mes - b.mes);
|
||||
}, [dados]);
|
||||
const anosDisponiveis = useMemo(() => {
|
||||
const anos = new Set<number>([currentYear]);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
anos.add(currentYear - i);
|
||||
}
|
||||
if (dados) {
|
||||
dados.competencias.forEach((item) => anos.add(item.ano));
|
||||
}
|
||||
return Array.from(anos).sort((a, b) => b - a).map(String);
|
||||
}, [currentYear, dados]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anosDisponiveis.includes(anoSelecionado)) {
|
||||
setAnoSelecionado(anosDisponiveis[0] ?? String(currentYear));
|
||||
}
|
||||
}, [anoSelecionado, anosDisponiveis, currentYear]);
|
||||
|
||||
useEffect(() => {
|
||||
if (semVinculo) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!me?.email) {
|
||||
setLoading(false);
|
||||
toast.error("Não foi possível identificar o usuário logado.");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authMeService.getMeuFechamento(me.email, Number(anoSelecionado));
|
||||
if (!cancelled) {
|
||||
setDados(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar meu fechamento.";
|
||||
toast.error(message);
|
||||
setDados(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [me?.email, semVinculo, anoSelecionado]);
|
||||
|
||||
const handleExportar = async (fechamentoId: string) => {
|
||||
try {
|
||||
setExportingFechamentoId(fechamentoId);
|
||||
const { buffer, filename } = await fechamentoFechamentosService.exportarPlanilha(fechamentoId);
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename ?? `fechamento-${fechamentoId}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Planilha exportada com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao exportar planilha.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setExportingFechamentoId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Carregando meu fechamento...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (semVinculo) {
|
||||
return (
|
||||
<div className="p-3 md:p-6">
|
||||
<Card className="mx-auto max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Usuário não vinculado</CardTitle>
|
||||
<CardDescription>
|
||||
Este usuário não está vinculado a um parceiro. Para consultar seu fechamento, é necessário vínculo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<ListChecks className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Meu Fechamento
|
||||
</h1>
|
||||
<Button onClick={() => navigate("/fechamento/meu-fechamento/banco-pontos")} className="gap-2">
|
||||
<Landmark className="h-4 w-4" />
|
||||
Banco de Pontos
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Ano:</span>
|
||||
<Select value={anoSelecionado} onValueChange={setAnoSelecionado}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{anosDisponiveis.map((ano) => (
|
||||
<SelectItem key={ano} value={ano}>
|
||||
{ano}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Card className="border-border/70 bg-card shadow-sm">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Total de pontos</CardDescription>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.totalPontos ?? 0)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Saldo dos fechamentos</CardDescription>
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.saldoFechamentos ?? 0)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Saldo banco de pontos</CardDescription>
|
||||
<Landmark className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">{formatPontos(dados?.indicadores.saldoBancoPontos ?? 0)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!dados || competenciasOrdenadas.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||
<CardDescription>Não há fechamento para o ano selecionado.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[220px]">Competência</TableHead>
|
||||
<TableHead className="min-w-[140px]">Status fechamento</TableHead>
|
||||
<TableHead className="min-w-[130px] text-right">Pontos</TableHead>
|
||||
<TableHead className="min-w-[240px] text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{competenciasOrdenadas.map((item) => (
|
||||
<TableRow key={item.competenciaId}>
|
||||
<TableCell className="font-medium">{formatCompetenciaMes(item.mes, item.ano)}</TableCell>
|
||||
<TableCell>
|
||||
{item.fechamento ? (
|
||||
<Badge
|
||||
className={
|
||||
item.fechamento.status === "fechado"
|
||||
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-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"
|
||||
}
|
||||
>
|
||||
{item.fechamento.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatPontos(Number(item.fechamento?.pontuacaoTotalEntregue ?? 0))}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!item.fechamento || item.fechamento.status !== "fechado" || exportingFechamentoId === item.fechamento.id}
|
||||
onClick={() => item.fechamento && void handleExportar(item.fechamento.id)}
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
{exportingFechamentoId === item.fechamento?.id ? "Exportando..." : "Exportar"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!item.fechamento || item.fechamento.status !== "fechado"}
|
||||
onClick={() =>
|
||||
navigate(`/fechamento/fechamentos/${item.fechamento?.id}`, {
|
||||
state: {
|
||||
competenciaId: item.competenciaId,
|
||||
status: item.fechamento?.status,
|
||||
readonlyView: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Detalhes
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, Landmark, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { authMeService } from "@/services/fechamento/authMe";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
const PER_PAGE = 20;
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function formatData(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCompetenciaMesAno(mes: number | null, ano: number | null): string {
|
||||
if (mes == null || ano == null) return "—";
|
||||
const m = Math.trunc(Number(mes));
|
||||
const y = Math.trunc(Number(ano));
|
||||
if (!Number.isFinite(m) || !Number.isFinite(y) || m < 1 || m > 12) return "—";
|
||||
return `${String(m).padStart(2, "0")}/${y}`;
|
||||
}
|
||||
|
||||
function nomeExibicao(nome: string, codinome: string | null): string {
|
||||
return codinome?.trim() ? `${nome} (${codinome.trim()})` : nome;
|
||||
}
|
||||
|
||||
export default function MeuFechamentoBancoPontos() {
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAuthAccess();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exportando, setExportando] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [dados, setDados] = useState<Awaited<ReturnType<typeof authMeService.getMeuBancoPontosExtrato>> | null>(null);
|
||||
const semVinculo = !me?.parceiroId;
|
||||
|
||||
useEffect(() => {
|
||||
if (semVinculo) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!me?.email) {
|
||||
setLoading(false);
|
||||
toast.error("Não foi possível identificar o usuário logado.");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authMeService.getMeuBancoPontosExtrato(me.email, page, PER_PAGE);
|
||||
if (!cancelled) {
|
||||
setDados(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar extrato.";
|
||||
toast.error(message);
|
||||
setDados(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [me?.email, page, semVinculo]);
|
||||
|
||||
const handleExportar = async () => {
|
||||
if (!me?.email) return;
|
||||
try {
|
||||
setExportando(true);
|
||||
const { buffer, filename } = await authMeService.exportarMeuBancoPontosExtrato(me.email);
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename ?? "meu-banco-pontos.xlsx";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Planilha exportada com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao exportar extrato.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setExportando(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (semVinculo) {
|
||||
return (
|
||||
<div className="p-3 md:p-6">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento/meu-fechamento")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="mt-4 rounded-lg border p-4 text-sm text-muted-foreground">
|
||||
Usuário não está vinculado a parceiro.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPaginas = Math.max(1, dados?.meta.totalPaginas ?? 1);
|
||||
const parceiroNome =
|
||||
dados?.parceiro ? nomeExibicao(dados.parceiro.nome, dados.parceiro.codinome) : "Carregando...";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||
<div className="mb-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento/meu-fechamento")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar ao meu fechamento
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Banco de Pontos — {parceiroNome}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Extrato detalhado do seu banco de pontos.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 p-3 md:p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
{dados?.parceiro ? (
|
||||
<span className={`text-xl font-bold ${(dados.parceiro.saldo ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
Saldo atual: {formatPontos(Number(dados.parceiro.saldo ?? 0))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" disabled={exportando || loading} onClick={() => void handleExportar()}>
|
||||
{exportando ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
Exportar XLSX
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[150px]">Data</TableHead>
|
||||
<TableHead className="min-w-[110px] whitespace-nowrap">Competência</TableHead>
|
||||
<TableHead className="min-w-[280px]">Descrição</TableHead>
|
||||
<TableHead className="min-w-[100px]">Tipo</TableHead>
|
||||
<TableHead className="min-w-[130px] text-right">Valor</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-14 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-8 w-8 animate-spin text-primary" />
|
||||
Carregando extrato...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : !dados || dados.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
|
||||
Nenhuma movimentação registrada.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
dados.data.map((linha) => {
|
||||
const isCredito = linha.tipo === "credito";
|
||||
return (
|
||||
<TableRow key={linha.id}>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">{formatData(linha.criadoEm)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[420px] text-sm font-medium leading-snug">{linha.descricao}</TableCell>
|
||||
<TableCell>
|
||||
{isCredito ? (
|
||||
<Badge className="border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50">
|
||||
Crédito
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50">
|
||||
Débito
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right font-semibold tabular-nums ${isCredito ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{isCredito ? "+" : "−"}
|
||||
{formatPontos(Number(linha.quantidade))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Página {dados?.meta.paginaAtual ?? 1} de {totalPaginas}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={loading || page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || page >= totalPaginas}
|
||||
onClick={() => setPage((p) => Math.min(totalPaginas, p + 1))}
|
||||
>
|
||||
Próxima
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from "react";
|
||||
import { Image as ImageIcon, Loader2, Upload, UserCircle2 } 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 { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import {
|
||||
authMeService,
|
||||
type MeuParceiroPerfilData,
|
||||
} from "@/services/fechamento/authMe";
|
||||
import { fechamentoUploadsService } from "@/services/fechamento/uploads";
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
type PerfilForm = {
|
||||
nome: string;
|
||||
email: string;
|
||||
whatsapp: string;
|
||||
tipoPessoa: "fisica" | "juridica";
|
||||
cpf: string;
|
||||
cnpj: string;
|
||||
logoUrl: string;
|
||||
};
|
||||
|
||||
const FORM_INICIAL: PerfilForm = {
|
||||
nome: "",
|
||||
email: "",
|
||||
whatsapp: "",
|
||||
tipoPessoa: "juridica",
|
||||
cpf: "",
|
||||
cnpj: "",
|
||||
logoUrl: "",
|
||||
};
|
||||
|
||||
function onlyDigits(value: string): string {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
function formatCpf(value: string): string {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
const p1 = digits.slice(0, 3);
|
||||
const p2 = digits.slice(3, 6);
|
||||
const p3 = digits.slice(6, 9);
|
||||
const p4 = digits.slice(9, 11);
|
||||
if (digits.length <= 3) return p1;
|
||||
if (digits.length <= 6) return `${p1}.${p2}`;
|
||||
if (digits.length <= 9) return `${p1}.${p2}.${p3}`;
|
||||
return `${p1}.${p2}.${p3}-${p4}`;
|
||||
}
|
||||
|
||||
function formatCnpj(value: string): string {
|
||||
const digits = onlyDigits(value).slice(0, 14);
|
||||
const p1 = digits.slice(0, 2);
|
||||
const p2 = digits.slice(2, 5);
|
||||
const p3 = digits.slice(5, 8);
|
||||
const p4 = digits.slice(8, 12);
|
||||
const p5 = digits.slice(12, 14);
|
||||
if (digits.length <= 2) return p1;
|
||||
if (digits.length <= 5) return `${p1}.${p2}`;
|
||||
if (digits.length <= 8) return `${p1}.${p2}.${p3}`;
|
||||
if (digits.length <= 12) return `${p1}.${p2}.${p3}/${p4}`;
|
||||
return `${p1}.${p2}.${p3}/${p4}-${p5}`;
|
||||
}
|
||||
|
||||
function formatWhatsapp(value: string): string {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
const ddd = digits.slice(0, 2);
|
||||
const part1 = digits.slice(2, 7);
|
||||
const part2 = digits.slice(7, 11);
|
||||
if (digits.length <= 2) return ddd;
|
||||
if (digits.length <= 7) return `${ddd} ${part1}`;
|
||||
return `${ddd} ${part1}-${part2}`;
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: string): string | null {
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function validateLogoFile(file: File): string | null {
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
return "Arquivo inválido. Use JPG, PNG ou WEBP.";
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "Arquivo excede 5MB. Escolha uma imagem menor.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapPerfilToForm(perfil: MeuParceiroPerfilData): PerfilForm {
|
||||
return {
|
||||
nome: perfil.nome ?? "",
|
||||
email: perfil.email ?? "",
|
||||
whatsapp: onlyDigits(perfil.whatsapp ?? ""),
|
||||
tipoPessoa: perfil.tipoPessoa,
|
||||
cpf: onlyDigits(perfil.cpf ?? ""),
|
||||
cnpj: onlyDigits(perfil.cnpj ?? ""),
|
||||
logoUrl: perfil.logoUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function MeuPerfil() {
|
||||
const { me } = useAuthAccess();
|
||||
const logoFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||
const [perfil, setPerfil] = useState<MeuParceiroPerfilData | null>(null);
|
||||
const [form, setForm] = useState<PerfilForm>(FORM_INICIAL);
|
||||
const [selectedLogoFile, setSelectedLogoFile] = useState<File | null>(null);
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const semVinculo = !me?.parceiroId;
|
||||
const tipoPessoa = form.tipoPessoa;
|
||||
const isPessoaFisica = tipoPessoa === "fisica";
|
||||
const logoDisplayUrl = logoPreviewUrl ?? normalizeOptionalText(form.logoUrl);
|
||||
|
||||
const logoStatusLabel = useMemo(() => {
|
||||
if (uploadingLogo) return "Enviando foto...";
|
||||
if (selectedLogoFile) return `Arquivo selecionado: ${selectedLogoFile.name}`;
|
||||
return "Nenhum arquivo novo selecionado.";
|
||||
}, [selectedLogoFile, uploadingLogo]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
};
|
||||
}, [logoPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (semVinculo) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!me?.email) {
|
||||
setLoading(false);
|
||||
toast.error("Não foi possível identificar o e-mail do usuário logado.");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const carregarPerfil = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authMeService.getMeuParceiroPerfil(me.email);
|
||||
if (cancelled) return;
|
||||
setPerfil(data);
|
||||
setForm(mapPerfilToForm(data));
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar perfil.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void carregarPerfil();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [me?.email, semVinculo]);
|
||||
|
||||
const handleLogoFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validationMessage = validateLogoFile(file);
|
||||
if (validationMessage) {
|
||||
toast.error(validationMessage);
|
||||
event.currentTarget.value = "";
|
||||
return;
|
||||
}
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setSelectedLogoFile(file);
|
||||
setLogoPreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const clearLogoSelection = () => {
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setSelectedLogoFile(null);
|
||||
setLogoPreviewUrl(null);
|
||||
if (logoFileInputRef.current) {
|
||||
logoFileInputRef.current.value = "";
|
||||
}
|
||||
setForm((prev) => ({ ...prev, logoUrl: "" }));
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
if (!form.nome.trim()) {
|
||||
toast.error("Nome é obrigatório.");
|
||||
return false;
|
||||
}
|
||||
if (!form.email.trim()) {
|
||||
toast.error("E-mail é obrigatório.");
|
||||
return false;
|
||||
}
|
||||
if (isPessoaFisica && onlyDigits(form.cpf).length !== 11) {
|
||||
toast.error("CPF deve conter 11 dígitos.");
|
||||
return false;
|
||||
}
|
||||
if (!isPessoaFisica && onlyDigits(form.cnpj).length !== 14) {
|
||||
toast.error("CNPJ deve conter 14 dígitos.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadLogoIfNeeded = async (): Promise<string | null> => {
|
||||
if (!selectedLogoFile) {
|
||||
return normalizeOptionalText(form.logoUrl);
|
||||
}
|
||||
setUploadingLogo(true);
|
||||
try {
|
||||
const signed = await fechamentoUploadsService.presignUploadParceiroLogo({
|
||||
fileName: selectedLogoFile.name,
|
||||
contentType: selectedLogoFile.type as "image/jpeg" | "image/png" | "image/webp",
|
||||
});
|
||||
await fechamentoUploadsService.uploadFileToSignedUrl(signed.uploadUrl, selectedLogoFile);
|
||||
return signed.publicUrl;
|
||||
} finally {
|
||||
setUploadingLogo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSalvar = async () => {
|
||||
if (!me?.email || !perfil) return;
|
||||
if (!validateForm()) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const logoUrl = await uploadLogoIfNeeded();
|
||||
const atualizado = await authMeService.patchMeuParceiroPerfil(me.email, {
|
||||
nome: form.nome.trim(),
|
||||
email: form.email.trim(),
|
||||
whatsapp: normalizeOptionalText(formatWhatsapp(form.whatsapp)),
|
||||
logoUrl,
|
||||
cpf: isPessoaFisica ? onlyDigits(form.cpf) : null,
|
||||
cnpj: !isPessoaFisica ? onlyDigits(form.cnpj) : null,
|
||||
});
|
||||
setPerfil(atualizado);
|
||||
setForm(mapPerfilToForm(atualizado));
|
||||
if (logoPreviewUrl) {
|
||||
URL.revokeObjectURL(logoPreviewUrl);
|
||||
}
|
||||
setLogoPreviewUrl(null);
|
||||
setSelectedLogoFile(null);
|
||||
toast.success("Perfil atualizado com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar perfil.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Carregando perfil...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (semVinculo) {
|
||||
return (
|
||||
<div className="p-3 md:p-6">
|
||||
<Card className="mx-auto max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Usuário não vinculado</CardTitle>
|
||||
<CardDescription>
|
||||
Este usuário não está vinculado a um parceiro. Para editar o perfil, é necessário vínculo com parceiro.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!perfil) {
|
||||
return (
|
||||
<div className="p-3 md:p-6">
|
||||
<Card className="mx-auto max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Não foi possível carregar o perfil</CardTitle>
|
||||
<CardDescription>Tente atualizar a página novamente.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<UserCircle2 className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Meu Perfil
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Atualize seus dados de contato e identificação.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
<Card className="mx-auto max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Dados do parceiro vinculado</CardTitle>
|
||||
<CardDescription>Você pode editar apenas os campos permitidos para autoatendimento.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-3 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="group relative h-24 w-24 overflow-hidden rounded-full border shadow-sm transition hover:brightness-95"
|
||||
onClick={() => logoFileInputRef.current?.click()}
|
||||
title="Clique para alterar a foto"
|
||||
>
|
||||
{logoDisplayUrl ? (
|
||||
<img src={logoDisplayUrl} alt="Foto de perfil" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="h-7 w-7" />
|
||||
</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" />
|
||||
Alterar foto
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={logoFileInputRef}
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||||
onChange={handleLogoFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-center text-xs text-muted-foreground">{logoStatusLabel}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clearLogoSelection} disabled={uploadingLogo}>
|
||||
Remover foto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="perfil-nome">Nome</Label>
|
||||
<Input
|
||||
id="perfil-nome"
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="perfil-email">E-mail</Label>
|
||||
<Input
|
||||
id="perfil-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="perfil-whatsapp">WhatsApp</Label>
|
||||
<Input
|
||||
id="perfil-whatsapp"
|
||||
value={formatWhatsapp(form.whatsapp)}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))}
|
||||
placeholder="11 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo de Pessoa</Label>
|
||||
<Select
|
||||
value={form.tipoPessoa}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
tipoPessoa: value as "fisica" | "juridica",
|
||||
cpf: value === "fisica" ? prev.cpf : "",
|
||||
cnpj: value === "juridica" ? prev.cnpj : "",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="juridica">Jurídica</SelectItem>
|
||||
<SelectItem value="fisica">Física</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isPessoaFisica ? (
|
||||
<div>
|
||||
<Label htmlFor="perfil-cpf">CPF</Label>
|
||||
<Input
|
||||
id="perfil-cpf"
|
||||
value={formatCpf(form.cpf)}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))}
|
||||
placeholder="000.000.000-00"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="perfil-cnpj">CNPJ</Label>
|
||||
<Input
|
||||
id="perfil-cnpj"
|
||||
value={formatCnpj(form.cnpj)}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))}
|
||||
placeholder="00.000.000/0000-00"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => void handleSalvar()} disabled={saving || uploadingLogo}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
"Salvar alterações"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function NotFound() {
|
||||
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
"404 Error: User attempted to access non-existent fechamento-hgtx route:",
|
||||
"404 Error: User attempted to access non-existent fechamento route:",
|
||||
location.pathname,
|
||||
);
|
||||
}, [location.pathname]);
|
||||
@@ -16,7 +16,7 @@ export default function NotFound() {
|
||||
<div className="text-center">
|
||||
<h1 className="mb-3 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
|
||||
<Link to="/fechamento-hgtx" className="text-primary underline hover:text-primary/90">
|
||||
<Link to="/fechamento" className="text-primary underline hover:text-primary/90">
|
||||
Voltar para Fechamentos
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1007,6 +1007,7 @@ export default function Parceiros() {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setIsFormOpen(false);
|
||||
resetFormState();
|
||||
@@ -1041,7 +1042,9 @@ export default function Parceiros() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={toggling}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={toggling}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedParceiro}>
|
||||
{toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -418,12 +418,24 @@ export default function Usuarios() {
|
||||
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
||||
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.papel === "admin" ? "default" : "secondary"}>
|
||||
<Badge
|
||||
className={
|
||||
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-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"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.estaAtivo ? "secondary" : "outline"}>
|
||||
<Badge
|
||||
className={
|
||||
usuario.estaAtivo
|
||||
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-red-600/30 bg-red-50 font-normal text-red-800 hover:bg-red-50 dark:bg-red-950/40 dark:text-red-200"
|
||||
}
|
||||
>
|
||||
{usuario.estaAtivo ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -588,7 +600,12 @@ export default function Usuarios() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={saving}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsCreateOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
@@ -683,7 +700,12 @@ export default function Usuarios() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={saving}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={() => setIsEditOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleEdit} disabled={saving}>
|
||||
@@ -712,7 +734,9 @@ export default function Usuarios() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={toggling}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogCancel className="hover:bg-muted/60 hover:text-foreground" disabled={toggling}>
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
|
||||
{toggling ? (
|
||||
"Processando..."
|
||||
|
||||
Reference in New Issue
Block a user