novas atualizacoes do sistema de fechamento

This commit is contained in:
Vitex Tecnologia
2026-04-27 16:50:27 -03:00
parent 6da067c641
commit 4c28862c5c
27 changed files with 3388 additions and 300 deletions
+65 -6
View File
@@ -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" />
+253 -15
View File
@@ -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 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 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>
+29 -5
View File
@@ -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..."