diff --git a/src/App.tsx b/src/App.tsx
index 57ebb7c..f506e32 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,7 +2,7 @@ import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
import { ThemeProvider } from "next-themes";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
@@ -11,6 +11,16 @@ import React from "react";
import IntelligenceIAApp from "./modules/intelligence-ia/App";
import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
+const FECHAMENTO_LEGACY_PREFIX = "/fechamento-hgtx";
+
+/** Redireciona URLs antigas `/fechamento-hgtx/...` para `/fechamento/...` (mesmo sufixo, query e hash). */
+function FechamentoLegacyRedirect() {
+ const location = useLocation();
+ const tail = location.pathname.slice(FECHAMENTO_LEGACY_PREFIX.length);
+ const to = `/fechamento${tail}`;
+ return ;
+}
+
const queryClient = new QueryClient();
const App = () => (
@@ -34,7 +44,8 @@ const App = () => (
/>
} />
- } />
+ } />
+ } />
} />
diff --git a/src/components/auth/NoAccessScreen.tsx b/src/components/auth/NoAccessScreen.tsx
index 93250ba..3c0c4d2 100644
--- a/src/components/auth/NoAccessScreen.tsx
+++ b/src/components/auth/NoAccessScreen.tsx
@@ -1,11 +1,13 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { Loader2, ShieldX } from "lucide-react";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
+import { BorderBeam } from "@/components/ui/border-beam";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
import { Label } from "@/components/ui/label";
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
import { authMeService } from "@/services/fechamento/authMe";
+import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
type NoAccessScreenProps = {
reason: AccessBlockReason | null;
@@ -19,9 +21,6 @@ function getMessage(reason: AccessBlockReason | null): string {
if (reason === "erro") {
return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
}
- if (reason === "bootstrap") {
- return "Primeiro acesso detectado. Cadastre a unidade e o primeiro usuário administrador para iniciar o sistema.";
- }
return "Você não tem acesso a este módulo no momento. Solicite acesso ao administrador.";
}
@@ -30,7 +29,15 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
const [adminNome, setAdminNome] = useState("");
const [adminEmail, setAdminEmail] = useState("");
const [saving, setSaving] = useState(false);
- const estabelecimentoId = (errorMessage ?? "").trim();
+
+ /** Código do estabelecimento: prioriza área de transferência (Codex), depois o valor repassado pelo AuthGate. */
+ const estabelecimentoId = useMemo(() => {
+ const fromTransfer = String(
+ GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
+ ).trim();
+ const fromGate = (errorMessage ?? "").trim();
+ return fromTransfer || fromGate;
+ }, [errorMessage]);
const handleBootstrap = async () => {
if (!estabelecimentoId) {
@@ -58,20 +65,40 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
return (
-
+
-
-
-
- Você não tem acesso a este módulo
+ {reason === "bootstrap" ? (
+ <>
+ Bem-vindo! Configuração inicial
+
+ Este é o primeiro acesso ao Fechamento HGTX
+ para este estabelecimento. Em poucos passos você cadastra a unidade e o primeiro usuário administrador
+ para começar a usar o sistema.
+
+ >
+ ) : (
+ <>
+
+
+
+ Você não tem acesso a este módulo
+ >
+ )}
- {getMessage(reason)}
+ {reason !== "bootstrap" ? {getMessage(reason)}
: null}
{reason === "bootstrap" ? (
Código do estabelecimento
-
+
+
Preenchimento automático.
+ {!estabelecimentoId ? (
+
+ Não foi possível obter o código do estabelecimento. Abra o Fechamento HGTX a partir do Codex com o
+ estabelecimento carregado no transfer.
+
+ ) : null}
Nome da unidade
@@ -102,16 +129,21 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
/>
-
+
{saving ? (
- <>
-
+
+
Inicializando...
- >
+
) : (
- "Inicializar ambiente"
+ "Iniciar ambiente"
)}
-
+
) : null}
@@ -119,6 +151,7 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
{errorMessage}
) : null}
+
);
diff --git a/src/components/fechamento/UnidadeGate.tsx b/src/components/fechamento/UnidadeGate.tsx
index 1c69e4d..3f31f73 100644
--- a/src/components/fechamento/UnidadeGate.tsx
+++ b/src/components/fechamento/UnidadeGate.tsx
@@ -134,7 +134,7 @@ export function UnidadeGate({ children }: UnidadeGateProps) {
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
- Ir para Configurações
+ Ir para Configurações
>
) : (
diff --git a/src/components/ui/border-beam.tsx b/src/components/ui/border-beam.tsx
index 0ae6bc3..a25f617 100644
--- a/src/components/ui/border-beam.tsx
+++ b/src/components/ui/border-beam.tsx
@@ -1,54 +1,26 @@
-import { motion, MotionStyle, Transition } from "motion/react"
+import type { CSSProperties } from "react";
+import { motion, type MotionStyle, type Transition } from "motion/react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
interface BorderBeamProps {
- /**
- * The size of the border beam.
- */
- size?: number
- /**
- * The duration of the border beam.
- */
- duration?: number
- /**
- * The delay of the border beam.
- */
- delay?: number
- /**
- * The color of the border beam from.
- */
- colorFrom?: string
- /**
- * The color of the border beam to.
- */
- colorTo?: string
- /**
- * The motion transition of the border beam.
- */
- transition?: Transition
- /**
- * The class name of the border beam.
- */
- className?: string
- /**
- * The style of the border beam.
- */
- style?: React.CSSProperties
- /**
- * Whether to reverse the animation direction.
- */
- reverse?: boolean
- /**
- * The initial offset position (0-100).
- */
- initialOffset?: number
- /**
- * The border width of the beam.
- */
- borderWidth?: number
+ size?: number;
+ duration?: number;
+ delay?: number;
+ colorFrom?: string;
+ colorTo?: string;
+ transition?: Transition;
+ className?: string;
+ style?: CSSProperties;
+ reverse?: boolean;
+ initialOffset?: number;
+ borderWidth?: number;
}
+/**
+ * Border Beam (Magic UI) — mesma lógica do registry oficial; estilos de máscara/borda
+ * em inline CSS para compatibilidade com Tailwind 3 (o doc do Magic UI usa utilitários v4).
+ */
export const BorderBeam = ({
className,
size = 50,
@@ -62,30 +34,33 @@ export const BorderBeam = ({
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
+ const maskWrapperStyle: CSSProperties = {
+ borderWidth: `${borderWidth}px`,
+ borderStyle: "solid",
+ borderColor: "transparent",
+ maskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
+ maskClip: "padding-box, border-box",
+ maskComposite: "intersect",
+ maskRepeat: "no-repeat",
+ WebkitMaskImage: "linear-gradient(transparent, transparent), linear-gradient(#000, #000)",
+ WebkitMaskClip: "padding-box, border-box",
+ WebkitMaskComposite: "source-in",
+ };
+
+ const beamGradient: CSSProperties = {
+ width: size,
+ offsetPath: `rect(0 auto auto 0 round ${size}px)`,
+ backgroundImage: `linear-gradient(to left, ${colorFrom}, ${colorTo}, transparent)`,
+ };
+
return (
- )
-}
+ );
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index f2e666a..502ceef 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -39,7 +39,7 @@ export interface ButtonProps
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
- return ;
+ return ;
},
);
Button.displayName = "Button";
diff --git a/src/components/ui/magic-card.tsx b/src/components/ui/magic-card.tsx
new file mode 100644
index 0000000..3980cd5
--- /dev/null
+++ b/src/components/ui/magic-card.tsx
@@ -0,0 +1,193 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type PointerEvent,
+ type ReactNode,
+} from "react";
+import { motion, useMotionTemplate, useMotionValue, useSpring } from "motion/react";
+import { useTheme } from "next-themes";
+
+import { cn } from "@/lib/utils";
+
+interface MagicCardBaseProps {
+ children?: ReactNode;
+ className?: string;
+ gradientSize?: number;
+ gradientFrom?: string;
+ gradientTo?: string;
+}
+
+interface MagicCardGradientProps extends MagicCardBaseProps {
+ mode?: "gradient";
+ glowFrom?: never;
+ glowTo?: never;
+ glowAngle?: never;
+ glowSize?: never;
+ glowBlur?: never;
+ glowOpacity?: never;
+}
+
+interface MagicCardOrbProps extends MagicCardBaseProps {
+ mode: "orb";
+ glowFrom?: string;
+ glowTo?: string;
+ glowAngle?: number;
+ glowSize?: number;
+ glowBlur?: number;
+ glowOpacity?: number;
+}
+
+export type MagicCardProps = MagicCardGradientProps | MagicCardOrbProps;
+type ResetReason = "enter" | "leave" | "global" | "init";
+
+function isOrbMode(props: MagicCardProps): props is MagicCardOrbProps {
+ return props.mode === "orb";
+}
+
+export function MagicCard(props: MagicCardProps) {
+ const {
+ children,
+ className,
+ gradientSize = 200,
+ gradientFrom = "#9E7AFF",
+ gradientTo = "#FE8BBB",
+ mode = "gradient",
+ } = props;
+
+ const glowFrom = isOrbMode(props) ? (props.glowFrom ?? "#ee4f27") : "#ee4f27";
+ const glowTo = isOrbMode(props) ? (props.glowTo ?? "#6b21ef") : "#6b21ef";
+ const glowAngle = isOrbMode(props) ? (props.glowAngle ?? 90) : 90;
+ const glowSize = isOrbMode(props) ? (props.glowSize ?? 420) : 420;
+ const glowBlur = isOrbMode(props) ? (props.glowBlur ?? 60) : 60;
+ const glowOpacity = isOrbMode(props) ? (props.glowOpacity ?? 0.9) : 0.9;
+ const { theme, systemTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => setMounted(true), []);
+
+ const isDarkTheme = useMemo(() => {
+ if (!mounted) return true;
+ const currentTheme = theme === "system" ? systemTheme : theme;
+ return currentTheme === "dark";
+ }, [theme, systemTheme, mounted]);
+
+ const mouseX = useMotionValue(-gradientSize);
+ const mouseY = useMotionValue(-gradientSize);
+
+ const orbX = useSpring(mouseX, { stiffness: 250, damping: 30, mass: 0.6 });
+ const orbY = useSpring(mouseY, { stiffness: 250, damping: 30, mass: 0.6 });
+ const orbVisible = useSpring(0, { stiffness: 300, damping: 35 });
+
+ const modeRef = useRef(mode);
+ const glowOpacityRef = useRef(glowOpacity);
+ const gradientSizeRef = useRef(gradientSize);
+
+ useEffect(() => {
+ modeRef.current = mode;
+ }, [mode]);
+
+ useEffect(() => {
+ glowOpacityRef.current = glowOpacity;
+ }, [glowOpacity]);
+
+ useEffect(() => {
+ gradientSizeRef.current = gradientSize;
+ }, [gradientSize]);
+
+ const reset = useCallback(
+ (reason: ResetReason = "leave") => {
+ const currentMode = modeRef.current;
+
+ if (currentMode === "orb") {
+ if (reason === "enter") orbVisible.set(glowOpacityRef.current);
+ else orbVisible.set(0);
+ return;
+ }
+
+ const off = -gradientSizeRef.current;
+ mouseX.set(off);
+ mouseY.set(off);
+ },
+ [mouseX, mouseY, orbVisible],
+ );
+
+ const handlePointerMove = useCallback(
+ (e: PointerEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ mouseX.set(e.clientX - rect.left);
+ mouseY.set(e.clientY - rect.top);
+ },
+ [mouseX, mouseY],
+ );
+
+ useEffect(() => {
+ reset("init");
+ }, [reset]);
+
+ useEffect(() => {
+ const handleGlobalPointerOut = (e: PointerEvent) => {
+ if (!e.relatedTarget) reset("global");
+ };
+ const handleBlur = () => reset("global");
+ const handleVisibility = () => {
+ if (document.visibilityState !== "visible") reset("global");
+ };
+
+ window.addEventListener("pointerout", handleGlobalPointerOut);
+ window.addEventListener("blur", handleBlur);
+ document.addEventListener("visibilitychange", handleVisibility);
+
+ return () => {
+ window.removeEventListener("pointerout", handleGlobalPointerOut);
+ window.removeEventListener("blur", handleBlur);
+ document.removeEventListener("visibilitychange", handleVisibility);
+ };
+ }, [reset]);
+
+ const bgMotion = useMotionTemplate`
+ linear-gradient(hsl(var(--background)) 0 0) padding-box,
+ radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
+ ${gradientFrom},
+ ${gradientTo},
+ hsl(var(--border)) 100%
+ ) border-box
+ `;
+
+ return (
+ reset("leave")}
+ onPointerEnter={() => reset("enter")}
+ style={{ background: bgMotion }}
+ >
+
+
+ {mode === "orb" && (
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/App.tsx b/src/modules/fechamento-hgtx/App.tsx
index 5f43d27..e725629 100644
--- a/src/modules/fechamento-hgtx/App.tsx
+++ b/src/modules/fechamento-hgtx/App.tsx
@@ -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 ;
+ return ;
+}
+
+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 ;
}
const FechamentoHgtxApp = () => {
@@ -29,10 +49,49 @@ const FechamentoHgtxApp = () => {
- } />
- } />
- } />
- } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+ } />
+ } />
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 = () => (
<>
-
-
-
-
- {!collapsed && (
-
-
Fechamento HGTX
-
- {isAdmin ? "Painel Admin" : "Painel Parceiro"}
-
-
+
+ {!collapsed ? (
+ <>
+
+
+
+
+
+
Fechamento HGTX
+
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
+
+
+
setCollapsed(true)}
+ >
+
+
+ >
+ ) : (
+ <>
+
setCollapsed(false)}
+ >
+
+
+
+
+
+ >
)}
-
- {allowedNavItems.map((item) => (
- 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",
- )
- }
- >
-
- {!collapsed && {item.title} }
-
- ))}
+
+ {sections.map((section) => {
+ const sectionItems = allowedNavItems.filter((item) => item.section === section);
+ if (sectionItems.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {!collapsed ? (
+
+ {section}
+
+ ) : null}
+ {sectionItems.map((item) => (
+
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",
+ )
+ }
+ >
+
+ {!collapsed && {item.title} }
+
+ ))}
+
+ );
+ })}
-
-
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"
+
+
{
+ 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"
>
-
- {!collapsed && Recolher }
-
+
+ {!collapsed &&
Voltar }
+
>
);
@@ -86,7 +195,10 @@ export function AppSidebar() {
return (
<>
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"
>
diff --git a/src/modules/fechamento-hgtx/pages/BancoPontos.tsx b/src/modules/fechamento-hgtx/pages/BancoPontos.tsx
index 820ff05..27b7ea1 100644
--- a/src/modules/fechamento-hgtx/pages/BancoPontos.tsx
+++ b/src/modules/fechamento-hgtx/pages/BancoPontos.tsx
@@ -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 (
+
+ {iniciais(nome)}
+
+ );
+ }
+
+ return (
+ setImgErro(true)}
+ />
+ );
+}
export default function BancoPontos() {
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(true);
+ const [rows, setRows] = useState([]);
+ const [meta, setMeta] = useState({ total: 0, paginaAtual: 1, totalPaginas: 1 });
+ const [buscaInput, setBuscaInput] = useState("");
+ const [buscaDebounced, setBuscaDebounced] = useState("");
+ const [estaAtivo, setEstaAtivo] = useState("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 (
-
-
-
Banco de Pontos
-
- Consulte saldos por parceiro e extrato de créditos/débitos.
-
+
+
+
+
+
+ Banco de Pontos
+
+
+ Lista de parceiros e saldo; use Detalhes para ver o extrato completo.
+
+
-
-
- Estrutura inicial pronta
-
-
- Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
-
-
+
+
+
+
Buscar parceiro
+
+
+ setBuscaInput(e.target.value)}
+ />
+
+
+
+ Status
+ setEstaAtivo(v as BancoPontosEstaAtivoFiltro)}>
+
+
+
+
+ Todos
+ Ativos
+ Inativos
+
+
+
+
+
+
+
+
+
+ Foto
+ Nome
+ Saldo
+ Detalhes
+
+
+
+ {loading ? (
+
+
+
+ Carregando parceiros...
+
+
+ ) : rows.length === 0 ? (
+
+
+
+ Nenhum parceiro encontrado com os filtros atuais.
+
+
+ ) : (
+ rows.map((row) => (
+
+
+
+
+ {getDisplayNome(row)}
+ = 0 ? "text-emerald-600" : "text-red-600"
+ }`}
+ >
+ {formatPontos(Number(row.saldo))}
+
+
+
+ navigate(`/fechamento/banco-pontos/${row.parceiroId}`, {
+ state: { nomeExibicao: getDisplayNome(row) },
+ })
+ }
+ >
+ Detalhes
+
+
+
+ ))
+ )}
+
+
+
+
+ {!loading && meta.totalPaginas > 1 ? (
+
+
+ Página {meta.paginaAtual} de {meta.totalPaginas}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+
+ Anterior
+
+ = meta.totalPaginas}
+ onClick={() => setPage((p) => Math.min(meta.totalPaginas, p + 1))}
+ >
+ Próxima
+
+
+
+
+ ) : null}
+
);
}
diff --git a/src/modules/fechamento-hgtx/pages/BancoPontosExtrato.tsx b/src/modules/fechamento-hgtx/pages/BancoPontosExtrato.tsx
new file mode 100644
index 0000000..7a7fd5b
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/BancoPontosExtrato.tsx
@@ -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
(null);
+ const [linhas, setLinhas] = useState([]);
+ 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 (
+
+
+
+
navigate("/fechamento/banco-pontos")}>
+
+ Voltar ao banco de pontos
+
+
+
+
+
+
+
+ Extrato — {tituloParceiro}
+
+
+ Histórico de créditos e débitos (mais antigos primeiro).
+
+ {loading ? (
+
Carregando saldo atual…
+ ) : parceiro != null && saldoFormatado != null ? (
+
+
+
+ Saldo atual
+
+
= 0 ? "text-emerald-600" : "text-red-600"
+ }`}
+ >
+ {saldoFormatado}
+
+
pontos
+
+ ) : null}
+
+
+
+
+
+
+ void handleExportarXlsx()}
+ >
+ {exportando ? (
+
+ ) : (
+
+ )}
+ Exportar XLSX
+
+
+
+
+
+
+
+ Data
+ Competência
+ Descrição
+ Tipo
+ Valor (Pontos)
+ Fechamento
+
+
+
+ {loading ? (
+
+
+
+ Carregando extrato...
+
+
+ ) : linhasOrdenadas.length === 0 ? (
+
+
+ Nenhuma movimentação registrada para este parceiro.
+
+
+ ) : (
+ linhasOrdenadas.map((linha) => {
+ const isCredito = linha.tipo === "credito";
+ const q = Number(linha.quantidade);
+ return (
+
+
+ {formatData(linha.criadoEm)}
+
+
+ {formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
+
+ {linha.descricao}
+
+ {isCredito ? (
+
+ Crédito
+
+ ) : (
+
+ Débito
+
+ )}
+
+
+ {isCredito ? "+" : "−"}
+ {formatPontos(q)}
+
+
+ {linha.fechamentoId ? (
+
+ navigate(`/fechamento/fechamentos/${linha.fechamentoId}`, {
+ state: { competenciaId: undefined, status: undefined },
+ })
+ }
+ >
+
+ Abrir
+
+ ) : (
+ —
+ )}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ {loading ? (
+ "Carregando paginação…"
+ ) : (
+ <>
+ {intervaloLabel}
+ {" · "}
+ Página {meta.paginaAtual} de{" "}
+ {meta.total === 0 ? 1 : Math.max(1, meta.totalPaginas)}
+ >
+ )}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+
+ Anterior
+
+ = meta.totalPaginas || meta.totalPaginas < 1}
+ onClick={() => setPage((p) => Math.min(Math.max(1, meta.totalPaginas), p + 1))}
+ >
+ Próxima
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx b/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx
index 812e617..5b4a572 100644
--- a/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx
+++ b/src/modules/fechamento-hgtx/pages/CompetenciaFechamentos.tsx
@@ -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 (
+
+
+
+
+
+
+
+
Sincronizando com o Asana
+
+ Buscando tarefas concluídas no período da competência e montando os fechamentos. Pode levar um minuto —
+ não feche esta página.
+
+
+
+
+ );
+}
+
+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([]);
- 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(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 (
-
navigate("/fechamento-hgtx")}>
+ navigate("/fechamento")}>
Voltar
-
-
- Fechamentos da Competência
-
- {!loading && (
-
-
- {fechamentos.length} fechamento(s)
-
-
setIsReprocessModalOpen(true)}
- disabled={importing}
- >
-
- Reprocessar Asana
-
-
- )}
+
+
+
+ Fechamentos da Competência
+
+ {!loading && (
+
+
{fechamentos.length} fechamento(s)
+ {competenciaStatus === "concluido" ? (
+
setIsReabrirModalOpen(true)}
+ disabled={reabrindoCompetencia}
+ >
+ {reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
+
+ ) : temFechamentos ? (
+ <>
+
setIsReprocessModalOpen(true)}
+ disabled={importing}
+ >
+
+ Reprocessar Asana
+
+
setIsConcluirModalOpen(true)}
+ disabled={concluindoCompetencia}
+ >
+ {concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
+
+ >
+ ) : null}
+
+ )}
+
{!loading && fechamentos.length === 0 ? (
-
+
Nenhum fechamento encontrado
@@ -191,10 +312,14 @@ export default function CompetenciaFechamentos() {
-
void handleImportarAsana()} disabled={importing}>
-
- {importing ? "Importando..." : "Importar tasks do Asana"}
-
+ {importKind === "sheet" ? (
+
+ ) : (
+
void handleImportarAsana()} disabled={importing}>
+
+ Importar tasks do Asana
+
+ )}
) : (
@@ -213,8 +338,18 @@ export default function CompetenciaFechamentos() {
{loading ? (
-
- Carregando fechamentos...
+
+
+
+
+
Carregando fechamentos
+
Aguarde um instante.
+
+
) : (
@@ -238,8 +373,8 @@ export default function CompetenciaFechamentos() {
{row.status === "fechado" ? "Fechado" : "Em aberto"}
@@ -250,6 +385,7 @@ export default function CompetenciaFechamentos() {
void handleExportar(row.id)}
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
title="Exportar planilha financeira (XLSX)"
@@ -260,8 +396,9 @@ export default function CompetenciaFechamentos() {
- navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
+ navigate(`/fechamento/fechamentos/${row.id}`, {
state: { competenciaId, status: row.status },
})
}
@@ -280,17 +417,57 @@ export default function CompetenciaFechamentos() {
)}
-
-
-
- Reprocessar Asana
-
- Escolha uma estratégia de reprocessamento para esta competência.
-
-
+ {
+ if (!open && importing) return;
+ setIsReprocessModalOpen(open);
+ }}
+ >
+ {
+ if (importing) e.preventDefault();
+ }}
+ onEscapeKeyDown={(e) => {
+ if (importing) e.preventDefault();
+ }}
+ >
+ {importKind === "modal" ? (
+
+
+
+
+
+
+
+
Processando no Asana
+
{reprocessamentoLabel(reprocessMode)}
+
Não feche esta janela até a operação terminar.
+
+
+
+ ) : null}
-
-
+
+
+ Reprocessar Asana
+
+ Escolha uma estratégia de reprocessamento para esta competência.
+
+
+
+
+
setReprocessMode("reprocessar_tudo")}
@@ -389,14 +566,95 @@ export default function CompetenciaFechamentos() {
})}
) : null}
+
-
- setIsReprocessModalOpen(false)} disabled={importing}>
+
+ setIsReprocessModalOpen(false)}
+ disabled={importing}
+ >
Cancelar
void handleExecutarReprocessamento()} disabled={importing}>
- {importing ? "Processando..." : "Executar"}
+ {importing ? (
+ <>
+
+ Processando...
+ >
+ ) : (
+ "Executar"
+ )}
+
+
+
+
+
+
+
+
+ Reabrir competência
+
+ Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis.
+
+
+
+ Motivo (opcional)
+ setMotivoReabertura(e.target.value)}
+ placeholder="Ex.: correção de ajustes pós-fechamento"
+ />
+
+
+ setIsReabrirModalOpen(false)}
+ disabled={reabrindoCompetencia}
+ >
+ Cancelar
+
+ void handleReabrirCompetencia()} disabled={reabrindoCompetencia}>
+ {reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
+
+
+
+
+
+
+
+
+ Concluir competência
+
+ Deseja concluir esta competência? A operação só será permitida se todos os fechamentos estiverem fechados.
+
+
+
+ setIsConcluirModalOpen(false)}
+ disabled={concluindoCompetencia}
+ >
+ Cancelar
+
+ void handleConcluirCompetencia()}
+ disabled={concluindoCompetencia}
+ className="min-w-[172px] shadow-lg hover:shadow-primary/50"
+ >
+ {concluindoCompetencia ? (
+ <>
+
+ Concluindo...
+ >
+ ) : (
+ "Confirmar conclusão"
+ )}
diff --git a/src/modules/fechamento-hgtx/pages/Configuracoes.tsx b/src/modules/fechamento-hgtx/pages/Configuracoes.tsx
index 833033e..af9783e 100644
--- a/src/modules/fechamento-hgtx/pages/Configuracoes.tsx
+++ b/src/modules/fechamento-hgtx/pages/Configuracoes.tsx
@@ -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() {
-
-
- Unidade
-
- Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
- estabelecimento é somente leitura.
-
-
-
+
+
+
+
+ Código do estabelecimento
+
+
+
{loadingUnidade ? (
);
diff --git a/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx b/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
index 17ad5c7..e858dc7 100644
--- a/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
+++ b/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
@@ -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([]);
- const [togglingTaskId, setTogglingTaskId] = useState(null);
+ const [estadoRevisaoInicial, setEstadoRevisaoInicial] = useState>({});
+ const [bulkUpdatingRevisao, setBulkUpdatingRevisao] = useState(false);
const [deletingTaskId, setDeletingTaskId] = useState(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() {
-
navigate(-1)}>
+ navigate(-1)}>
Voltar
@@ -463,24 +573,25 @@ export default function FechamentoDetalhes() {
{!loading ? (
-
{isFechado ? "Fechado" : "Em aberto"}
+
{isFechado ? "Fechado" : "Em aberto"}
+ {isCompetenciaConcluida ?
Competência concluída : null}
setIsLancamentoOpen(true)}
- disabled={isFechado}
- className="min-w-[152px]"
+ disabled={isReadonly}
+ className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
Fazer lançamento
- {!isFechado ? (
+ {!isReadonly ? (
setIsReprocessAsanaOpen(true)}
disabled={reprocessandoAsana}
- className="min-w-[152px]"
+ className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
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]"
>
@@ -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]"
>
@@ -515,6 +626,11 @@ export default function FechamentoDetalhes() {
{!loading ? (
+ {isCompetenciaConcluida ? (
+
+ Competência concluída. Reabra a competência para editar este fechamento.
+
+ ) : null}
@@ -564,7 +680,26 @@ export default function FechamentoDetalhes() {
) : (
-
+
+ {!loading ? (
+
+ void handleToggleTodasAprovadas()}
+ disabled={isReadonly || bulkUpdatingRevisao}
+ >
+ {bulkUpdatingRevisao ? (
+
+ ) : (
+
+ )}
+ {todasTarefasRevisadas ? "Desmarcar todas" : "Marcar todas"}
+
+
+ ) : null}
+
@@ -573,6 +708,10 @@ export default function FechamentoDetalhes() {
Descrição
Cliente
Tipo
+ Data início
+ Data vencimento
+ Data conclusão
+ Etiquetas
Horas
Pontuação
Ações
@@ -581,7 +720,7 @@ export default function FechamentoDetalhes() {
{loading ? (
-
+
Carregando tarefas...
@@ -593,25 +732,48 @@ export default function FechamentoDetalhes() {
void handleToggleAprovada(tarefa, Boolean(checked))}
- disabled={togglingTaskId === tarefa.id || isFechado}
+ disabled={isReadonly || bulkUpdatingRevisao}
/>
- {togglingTaskId === tarefa.id ? : null}
{tarefa.numeroTicket || "—"}
{tarefa.descricao}
{tarefa.cliente || "—"}
{tarefa.tipo}
+ {formatDateOnly(tarefa.dataInicio)}
+ {formatDateOnly(tarefa.dataVencimento)}
+ {formatDateTime(tarefa.dataConclusao)}
+
+ {tarefa.etiquetas && tarefa.etiquetas.length > 0 ? (
+
+ {tarefa.etiquetas.map((etiqueta) => (
+
+ {etiqueta.name}
+
+ ))}
+
+ ) : (
+ "—"
+ )}
+
{formatHoras(tarefa.tempoMinutos)}
{Number(tarefa.pontuacao || 0)}
+ {tarefa.linkAsana ? (
+
+
+
+ Abrir
+
+
+ ) : null}
openEditarTarefa(tarefa)}
- disabled={isFechado}
+ disabled={isReadonly}
>
Editar
@@ -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 ? (
@@ -641,6 +803,7 @@ export default function FechamentoDetalhes() {
+
)}
@@ -675,10 +838,16 @@ export default function FechamentoDetalhes() {
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))}
/>
@@ -696,6 +865,7 @@ export default function FechamentoDetalhes() {
{
setIsLancamentoOpen(false);
resetLancamentoForm();
@@ -704,7 +874,7 @@ export default function FechamentoDetalhes() {
>
Cancelar
- void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
+ void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}>
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
@@ -799,11 +969,27 @@ export default function FechamentoDetalhes() {
- setIsConcluirOpen(false)} disabled={concluindo}>
+ setIsConcluirOpen(false)}
+ disabled={concluindo}
+ >
Cancelar
- void handleConcluirFechamento()} disabled={concluindo || isFechado}>
- {concluindo ? "Concluindo..." : "Confirmar conclusão"}
+ void handleConcluirFechamento()}
+ disabled={concluindo || isReadonly}
+ className="min-w-[172px] shadow-lg hover:shadow-primary/50"
+ >
+ {concluindo ? (
+ <>
+
+ Concluindo...
+ >
+ ) : (
+ "Confirmar conclusão"
+ )}
@@ -827,7 +1013,12 @@ export default function FechamentoDetalhes() {
/>
- setIsReabrirOpen(false)} disabled={reabrindo}>
+ setIsReabrirOpen(false)}
+ disabled={reabrindo}
+ >
Cancelar
void handleReabrirFechamento()} disabled={reabrindo}>
@@ -849,6 +1040,7 @@ export default function FechamentoDetalhes() {
{
setIsDeleteDialogOpen(false);
setDeletingTarefa(null);
@@ -880,13 +1072,26 @@ export default function FechamentoDetalhes() {
setIsReprocessAsanaOpen(false)}
disabled={reprocessandoAsana}
>
Cancelar
- void handleReprocessarAsana()} disabled={reprocessandoAsana}>
- {reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
+ void handleReprocessarAsana()}
+ disabled={reprocessandoAsana}
+ variant="secondary"
+ className="shadow-md hover:shadow-secondary/40"
+ >
+ {reprocessandoAsana ? (
+ <>
+
+ Reprocessando...
+ >
+ ) : (
+ "Confirmar reprocessamento"
+ )}
@@ -948,10 +1153,15 @@ export default function FechamentoDetalhes() {
- setIsEditarOpen(false)} disabled={savingEdicao}>
+ setIsEditarOpen(false)}
+ disabled={savingEdicao}
+ >
Cancelar
- void handleSalvarEdicao()} disabled={savingEdicao}>
+ void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}>
{savingEdicao ? "Salvando..." : "Salvar"}
diff --git a/src/modules/fechamento-hgtx/pages/Fechamentos.tsx b/src/modules/fechamento-hgtx/pages/Fechamentos.tsx
index cc7c5d3..ad1ed4b 100644
--- a/src/modules/fechamento-hgtx/pages/Fechamentos.tsx
+++ b/src/modules/fechamento-hgtx/pages/Fechamentos.tsx
@@ -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() {
Nova Competência
-
+
Mês
-
+
- {meses.map ((m, i) => (
+ {meses.map((m, i) => (
{m}
@@ -160,7 +163,7 @@ export default function Fechamentos() {
Ano
-
+
@@ -174,7 +177,12 @@ export default function Fechamentos() {
- setModalAberto(false)} disabled={criando}>
+ setModalAberto(false)}
+ disabled={criando}
+ >
Cancelar
void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
@@ -238,8 +246,8 @@ export default function Fechamentos() {
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
@@ -250,7 +258,7 @@ export default function Fechamentos() {
navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
+ onClick={() => navigate(`/fechamento/competencias/${competencia.id}`)}
>
Acessar
diff --git a/src/modules/fechamento-hgtx/pages/MeuFechamento.tsx b/src/modules/fechamento-hgtx/pages/MeuFechamento.tsx
new file mode 100644
index 0000000..dd0dc61
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/MeuFechamento.tsx
@@ -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(null);
+ const [anoSelecionado, setAnoSelecionado] = useState(String(currentYear));
+ const [dados, setDados] = useState> | 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([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 (
+
+
+
+ Carregando meu fechamento...
+
+
+ );
+ }
+
+ if (semVinculo) {
+ return (
+
+
+
+ Usuário não vinculado
+
+ Este usuário não está vinculado a um parceiro. Para consultar seu fechamento, é necessário vínculo.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ Meu Fechamento
+
+ navigate("/fechamento/meu-fechamento/banco-pontos")} className="gap-2">
+
+ Banco de Pontos
+
+
+
+ Ano:
+
+
+
+
+
+ {anosDisponiveis.map((ano) => (
+
+ {ano}
+
+ ))}
+
+
+
+
+
+
+
+
+ Total de pontos
+
+
+ {formatPontos(dados?.indicadores.totalPontos ?? 0)}
+
+
+
+
+
+ Saldo dos fechamentos
+
+
+ {formatPontos(dados?.indicadores.saldoFechamentos ?? 0)}
+
+
+
+
+
+ Saldo banco de pontos
+
+
+ {formatPontos(dados?.indicadores.saldoBancoPontos ?? 0)}
+
+
+
+
+
+
+ {!dados || competenciasOrdenadas.length === 0 ? (
+
+
+ Nenhum fechamento encontrado
+ Não há fechamento para o ano selecionado.
+
+
+ ) : (
+
+
+
+
+ Competência
+ Status fechamento
+ Pontos
+ Ações
+
+
+
+ {competenciasOrdenadas.map((item) => (
+
+ {formatCompetenciaMes(item.mes, item.ano)}
+
+ {item.fechamento ? (
+
+ {item.fechamento.status === "fechado" ? "Fechado" : "Em aberto"}
+
+ ) : (
+ "—"
+ )}
+
+ {formatPontos(Number(item.fechamento?.pontuacaoTotalEntregue ?? 0))}
+
+
+ item.fechamento && void handleExportar(item.fechamento.id)}
+ >
+
+ {exportingFechamentoId === item.fechamento?.id ? "Exportando..." : "Exportar"}
+
+
+ navigate(`/fechamento/fechamentos/${item.fechamento?.id}`, {
+ state: {
+ competenciaId: item.competenciaId,
+ status: item.fechamento?.status,
+ readonlyView: true,
+ },
+ })
+ }
+ >
+
+ Detalhes
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
diff --git a/src/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos.tsx b/src/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos.tsx
new file mode 100644
index 0000000..5261226
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos.tsx
@@ -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> | 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 (
+
+
navigate("/fechamento/meu-fechamento")}>
+
+ Voltar
+
+
+ Usuário não está vinculado a parceiro.
+
+
+ );
+ }
+
+ const totalPaginas = Math.max(1, dados?.meta.totalPaginas ?? 1);
+ const parceiroNome =
+ dados?.parceiro ? nomeExibicao(dados.parceiro.nome, dados.parceiro.codinome) : "Carregando...";
+
+ return (
+
+
+
+
navigate("/fechamento/meu-fechamento")}>
+
+ Voltar ao meu fechamento
+
+
+
+
+ Banco de Pontos — {parceiroNome}
+
+
Extrato detalhado do seu banco de pontos.
+
+
+
+
+
+ {dados?.parceiro ? (
+ = 0 ? "text-emerald-600" : "text-red-600"}`}>
+ Saldo atual: {formatPontos(Number(dados.parceiro.saldo ?? 0))}
+
+ ) : null}
+
+
void handleExportar()}>
+ {exportando ? : }
+ Exportar XLSX
+
+
+
+
+
+
+
+ Data
+ Competência
+ Descrição
+ Tipo
+ Valor
+
+
+
+ {loading ? (
+
+
+
+ Carregando extrato...
+
+
+ ) : !dados || dados.data.length === 0 ? (
+
+
+ Nenhuma movimentação registrada.
+
+
+ ) : (
+ dados.data.map((linha) => {
+ const isCredito = linha.tipo === "credito";
+ return (
+
+ {formatData(linha.criadoEm)}
+
+ {formatCompetenciaMesAno(linha.competenciaMes, linha.competenciaAno)}
+
+ {linha.descricao}
+
+ {isCredito ? (
+
+ Crédito
+
+ ) : (
+
+ Débito
+
+ )}
+
+
+ {isCredito ? "+" : "−"}
+ {formatPontos(Number(linha.quantidade))}
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ Página {dados?.meta.paginaAtual ?? 1} de {totalPaginas}
+
+
+ setPage((p) => Math.max(1, p - 1))}>
+
+ Anterior
+
+ = totalPaginas}
+ onClick={() => setPage((p) => Math.min(totalPaginas, p + 1))}
+ >
+ Próxima
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/modules/fechamento-hgtx/pages/MeuPerfil.tsx b/src/modules/fechamento-hgtx/pages/MeuPerfil.tsx
new file mode 100644
index 0000000..caaebea
--- /dev/null
+++ b/src/modules/fechamento-hgtx/pages/MeuPerfil.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploadingLogo, setUploadingLogo] = useState(false);
+ const [perfil, setPerfil] = useState(null);
+ const [form, setForm] = useState(FORM_INICIAL);
+ const [selectedLogoFile, setSelectedLogoFile] = useState(null);
+ const [logoPreviewUrl, setLogoPreviewUrl] = useState(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 = (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 => {
+ 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 (
+
+
+
+ Carregando perfil...
+
+
+ );
+ }
+
+ if (semVinculo) {
+ return (
+
+
+
+ Usuário não vinculado
+
+ Este usuário não está vinculado a um parceiro. Para editar o perfil, é necessário vínculo com parceiro.
+
+
+
+
+ );
+ }
+
+ if (!perfil) {
+ return (
+
+
+
+ Não foi possível carregar o perfil
+ Tente atualizar a página novamente.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Meu Perfil
+
+
Atualize seus dados de contato e identificação.
+
+
+
+
+
+ Dados do parceiro vinculado
+ Você pode editar apenas os campos permitidos para autoatendimento.
+
+
+
+
logoFileInputRef.current?.click()}
+ title="Clique para alterar a foto"
+ >
+ {logoDisplayUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ Alterar foto
+
+
+
+
+
{logoStatusLabel}
+
+ Remover foto
+
+
+
+
+
+ Nome
+ setForm((prev) => ({ ...prev, nome: e.target.value }))}
+ />
+
+
+
+ E-mail
+ setForm((prev) => ({ ...prev, email: e.target.value }))}
+ />
+
+
+
+ WhatsApp
+ setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))}
+ placeholder="11 99999-9999"
+ />
+
+
+
+ Tipo de Pessoa
+
+ setForm((prev) => ({
+ ...prev,
+ tipoPessoa: value as "fisica" | "juridica",
+ cpf: value === "fisica" ? prev.cpf : "",
+ cnpj: value === "juridica" ? prev.cnpj : "",
+ }))
+ }
+ >
+
+
+
+
+ Jurídica
+ Física
+
+
+
+
+ {isPessoaFisica ? (
+
+ CPF
+ setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))}
+ placeholder="000.000.000-00"
+ />
+
+ ) : (
+
+ CNPJ
+ setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))}
+ placeholder="00.000.000/0000-00"
+ />
+
+ )}
+
+
+
+ void handleSalvar()} disabled={saving || uploadingLogo}>
+ {saving ? (
+ <>
+
+ Salvando...
+ >
+ ) : (
+ "Salvar alterações"
+ )}
+
+
+
+
+
+
+ );
+}
+
diff --git a/src/modules/fechamento-hgtx/pages/NotFound.tsx b/src/modules/fechamento-hgtx/pages/NotFound.tsx
index fae7bef..5733afa 100644
--- a/src/modules/fechamento-hgtx/pages/NotFound.tsx
+++ b/src/modules/fechamento-hgtx/pages/NotFound.tsx
@@ -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() {
404
Rota não encontrada neste módulo.
-
+
Voltar para Fechamentos
diff --git a/src/modules/fechamento-hgtx/pages/Parceiros.tsx b/src/modules/fechamento-hgtx/pages/Parceiros.tsx
index 570bac2..cbd9607 100644
--- a/src/modules/fechamento-hgtx/pages/Parceiros.tsx
+++ b/src/modules/fechamento-hgtx/pages/Parceiros.tsx
@@ -1007,6 +1007,7 @@ export default function Parceiros() {
{
setIsFormOpen(false);
resetFormState();
@@ -1041,7 +1042,9 @@ export default function Parceiros() {
- Cancelar
+
+ Cancelar
+
{toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
diff --git a/src/modules/fechamento-hgtx/pages/Usuarios.tsx b/src/modules/fechamento-hgtx/pages/Usuarios.tsx
index 13833b4..ab31dd2 100644
--- a/src/modules/fechamento-hgtx/pages/Usuarios.tsx
+++ b/src/modules/fechamento-hgtx/pages/Usuarios.tsx
@@ -418,12 +418,24 @@ export default function Usuarios() {
{getNomeUsuario(usuario)}
{getEmailUsuario(usuario)}
-
+
{usuario.papel === "admin" ? "Admin" : "Parceiro"}
-
+
{usuario.estaAtivo ? "Ativo" : "Inativo"}
@@ -588,7 +600,12 @@ export default function Usuarios() {
- setIsCreateOpen(false)} disabled={saving}>
+ setIsCreateOpen(false)}
+ disabled={saving}
+ >
Cancelar
@@ -683,7 +700,12 @@ export default function Usuarios() {
- setIsEditOpen(false)} disabled={saving}>
+ setIsEditOpen(false)}
+ disabled={saving}
+ >
Cancelar
@@ -712,7 +734,9 @@ export default function Usuarios() {
- Cancelar
+
+ Cancelar
+
{toggling ? (
"Processando..."
diff --git a/src/services/fechamento/authMe.ts b/src/services/fechamento/authMe.ts
index 672c83f..0207f95 100644
--- a/src/services/fechamento/authMe.ts
+++ b/src/services/fechamento/authMe.ts
@@ -1,6 +1,7 @@
import axios from "axios";
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+import { normalizeBancoPontosExtratoItem } from "./bancoPontos";
export type PapelUsuario = "admin" | "parceiro";
@@ -27,11 +28,99 @@ export type BootstrapInitInput = {
adminEmail: string;
};
+export type MeuParceiroPerfilData = {
+ id: string;
+ nome: string;
+ tipoPessoa: "fisica" | "juridica";
+ cpf: string | null;
+ cnpj: string | null;
+ email: string;
+ whatsapp: string | null;
+ logoUrl: string | null;
+};
+
+export type AtualizarMeuParceiroPerfilInput = {
+ nome?: string;
+ cpf?: string | null;
+ cnpj?: string | null;
+ email?: string;
+ whatsapp?: string | null;
+ logoUrl?: string | null;
+};
+
+export type MeuFechamentoResumoData = {
+ parceiroId: string;
+ competencias: Array<{
+ competenciaId: string;
+ mes: number;
+ ano: number;
+ statusCompetencia: "em_aberto" | "concluido";
+ fechamento: {
+ id: string;
+ status: "em_aberto" | "fechado";
+ pontuacaoTotalEntregue: number;
+ pontuacaoMeta: number;
+ pontuacaoPaga: number;
+ pontuacaoBanco: number;
+ fechadoEm: string | null;
+ criadoEm: string;
+ atualizadoEm: string;
+ } | null;
+ }>;
+ indicadores: {
+ totalPontos: number;
+ saldoFechamentos: number;
+ saldoBancoPontos: number;
+ };
+};
+
+export type MeuBancoPontosExtratoData = {
+ parceiro: {
+ parceiroId: string;
+ nome: string;
+ codinome: string | null;
+ logoUrl: string | null;
+ fotoUrl: string | null;
+ saldo: number;
+ };
+ data: Array<{
+ id: string;
+ parceiroId: string;
+ fechamentoId: string | null;
+ tipo: "credito" | "debito";
+ quantidade: number;
+ descricao: string;
+ criadoEm: string;
+ competenciaMes: number | null;
+ competenciaAno: number | null;
+ }>;
+ meta: {
+ total: number;
+ paginaAtual: number;
+ totalPaginas: number;
+ };
+};
+
type MeResponse = {
success: boolean;
data: MeData;
};
+type MeuParceiroPerfilResponse = {
+ success: boolean;
+ data: MeuParceiroPerfilData;
+};
+
+type MeuFechamentoResumoResponse = {
+ success: boolean;
+ data: MeuFechamentoResumoData;
+};
+
+type MeuBancoPontosExtratoResponse = {
+ success: boolean;
+ data: MeuBancoPontosExtratoData;
+};
+
type BootstrapStatusResponse = {
success: boolean;
data: BootstrapStatusData;
@@ -45,6 +134,11 @@ type BootstrapInitResponse = {
};
};
+export type ExportarMeuBancoPontosExtratoResponse = {
+ buffer: ArrayBuffer;
+ filename: string | null;
+};
+
type ApiErrorShape = {
status?: number;
data?: {
@@ -61,6 +155,36 @@ function isApiError(value: unknown): value is ApiErrorShape {
}
class AuthMeService {
+ private extractFilenameFromContentDisposition(value: string | undefined): string | null {
+ if (!value) return null;
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
+ if (utf8Match?.[1]) {
+ try {
+ return decodeURIComponent(utf8Match[1]);
+ } catch {
+ return utf8Match[1];
+ }
+ }
+ const regularMatch = value.match(/filename="?([^";]+)"?/i);
+ return regularMatch?.[1] ?? null;
+ }
+
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const message =
+ (error.response?.data as ApiErrorShape["data"] | undefined)?.error?.message ??
+ error.message ??
+ fallback;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
async getMe(usuarioEmail: string): Promise {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
@@ -110,6 +234,100 @@ class AuthMeService {
return response.data.data;
}
+ async getMeuParceiroPerfil(usuarioEmail: string): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ try {
+ const response = await axios.get(`${baseUrl}/me/parceiro-perfil`, {
+ headers,
+ params: { usuarioEmail },
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao carregar perfil do parceiro.");
+ }
+ }
+
+ async patchMeuParceiroPerfil(
+ usuarioEmail: string,
+ input: AtualizarMeuParceiroPerfilInput,
+ ): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ try {
+ const response = await axios.patch(`${baseUrl}/me/parceiro-perfil`, input, {
+ headers,
+ params: { usuarioEmail },
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao atualizar perfil do parceiro.");
+ }
+ }
+
+ async getMeuFechamento(usuarioEmail: string, ano?: number): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ try {
+ const response = await axios.get(`${baseUrl}/me/fechamentos`, {
+ headers,
+ params: {
+ usuarioEmail,
+ ano,
+ },
+ });
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao carregar meu fechamento.");
+ }
+ }
+
+ async getMeuBancoPontosExtrato(
+ usuarioEmail: string,
+ page: number,
+ perPage: number,
+ ): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ try {
+ const response = await axios.get(`${baseUrl}/me/banco-pontos/extrato`, {
+ headers,
+ params: {
+ usuarioEmail,
+ page,
+ perPage,
+ },
+ });
+ const payload = response.data.data;
+ return {
+ ...payload,
+ data: (payload.data ?? []).map((row) =>
+ normalizeBancoPontosExtratoItem(row as unknown as Record),
+ ),
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos.");
+ }
+ }
+
+ async exportarMeuBancoPontosExtrato(usuarioEmail: string): Promise {
+ const baseUrl = resolveCommanderBaseUrl();
+ const headers = await buildCommanderHeaders();
+ try {
+ const response = await axios.get(`${baseUrl}/me/banco-pontos/extrato/exportar`, {
+ headers,
+ responseType: "arraybuffer",
+ params: { usuarioEmail },
+ });
+ return {
+ buffer: response.data,
+ filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos.");
+ }
+ }
+
async bootstrapInitialize(input: BootstrapInitInput): Promise<{ unidadeId: string; adminId: string }> {
const baseUrl = resolveCommanderBaseUrl();
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
diff --git a/src/services/fechamento/bancoPontos.ts b/src/services/fechamento/bancoPontos.ts
new file mode 100644
index 0000000..5f65005
--- /dev/null
+++ b/src/services/fechamento/bancoPontos.ts
@@ -0,0 +1,308 @@
+import axios from "axios";
+
+import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
+
+export type BancoPontosEstaAtivoFiltro = "all" | "true" | "false";
+
+export type BancoPontosSaldoItem = {
+ parceiroId: string;
+ nome: string;
+ codinome: string | null;
+ estaAtivo: boolean;
+ logoUrl: string | null;
+ /** URL pública da foto (Commander pode enviar junto com `logoUrl`). */
+ fotoUrl?: string | null;
+ saldo: number;
+};
+
+export type BancoPontosExtratoItem = {
+ id: string;
+ parceiroId: string;
+ fechamentoId: string | null;
+ tipo: "credito" | "debito";
+ quantidade: number;
+ descricao: string;
+ criadoEm: string;
+ /** Mês da competência do fechamento (1–12), se a linha estiver vinculada a um fechamento. */
+ competenciaMes: number | null;
+ /** Ano da competência do fechamento, se houver. */
+ competenciaAno: number | null;
+};
+
+export type BancoPontosParceiroExtrato = {
+ parceiroId: string;
+ nome: string;
+ codinome: string | null;
+ logoUrl: string | null;
+ fotoUrl?: string | null;
+ saldo: number;
+};
+
+type MetaResponse = {
+ total: number;
+ paginaAtual: number;
+ totalPaginas: number;
+};
+
+export type ListarBancoPontosSaldosResponse = {
+ data: BancoPontosSaldoItem[];
+ meta: MetaResponse;
+};
+
+export type ListarBancoPontosExtratoResponse = {
+ parceiro: BancoPontosParceiroExtrato;
+ data: BancoPontosExtratoItem[];
+ meta: MetaResponse;
+};
+
+type ApiErrorShape = {
+ error?: {
+ message?: string;
+ };
+};
+
+function pickLogoUrl(row: { logoUrl?: unknown; logo_url?: unknown }): string | null {
+ const v = row.logoUrl ?? row.logo_url;
+ if (v == null) return null;
+ const s = String(v).trim();
+ return s.length > 0 ? s : null;
+}
+
+function pickSaldo(row: { saldo?: unknown; saldo_atual?: unknown }): number {
+ const v = row.saldo ?? row.saldo_atual;
+ if (v == null || v === "") return 0;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function pickFotoUrl(raw: { fotoUrl?: unknown; foto_url?: unknown }): string | null {
+ const v = raw.fotoUrl ?? raw.foto_url;
+ if (v == null) return null;
+ const s = String(v).trim();
+ return s.length > 0 ? s : null;
+}
+
+function normalizeSaldoItem(raw: Record): BancoPontosSaldoItem {
+ const logo = pickLogoUrl(raw);
+ const foto = pickFotoUrl(raw) ?? logo;
+ return {
+ parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
+ nome: String(raw.nome ?? ""),
+ codinome: raw.codinome != null ? String(raw.codinome) : null,
+ estaAtivo: Boolean(raw.estaAtivo ?? raw.esta_ativo),
+ logoUrl: foto,
+ fotoUrl: foto,
+ saldo: pickSaldo(raw),
+ };
+}
+
+function normalizeParceiroExtrato(raw: Record): BancoPontosParceiroExtrato {
+ const logo = pickLogoUrl(raw);
+ const foto = pickFotoUrl(raw) ?? logo;
+ return {
+ parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
+ nome: String(raw.nome ?? ""),
+ codinome: raw.codinome != null ? String(raw.codinome) : null,
+ logoUrl: foto,
+ fotoUrl: foto,
+ saldo: pickSaldo(raw),
+ };
+}
+
+function pickNullableInt(v: unknown): number | null {
+ if (v == null || v === "") return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.trunc(n) : null;
+}
+
+export function normalizeBancoPontosExtratoItem(raw: Record): BancoPontosExtratoItem {
+ const fechamentoRaw = raw.fechamentoId ?? raw.fechamento_id;
+ return {
+ id: String(raw.id ?? ""),
+ parceiroId: String(raw.parceiroId ?? raw.parceiro_id ?? ""),
+ fechamentoId: fechamentoRaw != null && String(fechamentoRaw).trim() !== "" ? String(fechamentoRaw) : null,
+ tipo: raw.tipo === "debito" ? "debito" : "credito",
+ quantidade: Number(raw.quantidade ?? 0),
+ descricao: String(raw.descricao ?? ""),
+ criadoEm: String(raw.criadoEm ?? raw.criado_em ?? ""),
+ competenciaMes: pickNullableInt(raw.competenciaMes ?? raw.competencia_mes),
+ competenciaAno: pickNullableInt(raw.competenciaAno ?? raw.competencia_ano),
+ };
+}
+
+export type ListarBancoPontosSaldosParams = {
+ estaAtivo?: BancoPontosEstaAtivoFiltro;
+ busca?: string;
+ page: number;
+ perPage: number;
+};
+
+export type ListarBancoPontosExtratoParams = {
+ page: number;
+ perPage: number;
+};
+
+export type ExportarBancoPontosExtratoResponse = {
+ buffer: ArrayBuffer;
+ filename: string | null;
+};
+
+class FechamentoBancoPontosService {
+ private extractFilenameFromContentDisposition(value: string | undefined): string | null {
+ if (!value) return null;
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
+ if (utf8Match?.[1]) {
+ try {
+ return decodeURIComponent(utf8Match[1]);
+ } catch {
+ return utf8Match[1];
+ }
+ }
+ const regularMatch = value.match(/filename="?([^";]+)"?/i);
+ return regularMatch?.[1] ?? null;
+ }
+
+ private messageFromResponseData(data: unknown): string | null {
+ if (data == null) return null;
+ if (typeof data === "object" && !(data instanceof ArrayBuffer) && !ArrayBuffer.isView(data)) {
+ const msg = (data as ApiErrorShape).error?.message;
+ return typeof msg === "string" && msg.trim() ? msg : null;
+ }
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
+ const buf = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ if (buf.byteLength === 0) return null;
+ try {
+ const text = new TextDecoder().decode(buf);
+ const trimmed = text.trim();
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+ const json = JSON.parse(trimmed) as ApiErrorShape;
+ if (typeof json.error?.message === "string" && json.error.message.trim()) {
+ return json.error.message;
+ }
+ }
+ } catch {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private handleAxiosError(error: unknown, fallback: string): never {
+ if (axios.isAxiosError(error)) {
+ if (!error.response) {
+ throw new Error(
+ "Não foi possível conectar na API do Commander. Verifique VITE_API_BASE_URL_COMMANDER e se o backend está acessível.",
+ );
+ }
+ const status = error.response.status;
+ const fromBody =
+ this.messageFromResponseData(error.response.data) ??
+ (status === 404
+ ? "Recurso não encontrado. Confirme se o backend está atualizado (rota de exportação)."
+ : null);
+ const message = (fromBody ?? error.message ?? fallback) as string;
+ throw new Error(message);
+ }
+ throw new Error(fallback);
+ }
+
+ async listarSaldos(params: ListarBancoPontosSaldosParams): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(`${baseUrl}/banco-pontos`, {
+ headers,
+ params: {
+ estaAtivo: params.estaAtivo ?? "all",
+ busca: params.busca?.trim() || undefined,
+ page: params.page,
+ perPage: params.perPage,
+ },
+ });
+ const body = response.data;
+ return {
+ ...body,
+ data: (body.data ?? []).map((row) => normalizeSaldoItem(row as unknown as Record)),
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao listar saldos do banco de pontos.");
+ }
+ }
+
+ async listarExtrato(
+ parceiroId: string,
+ params: ListarBancoPontosExtratoParams,
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(
+ `${baseUrl}/banco-pontos/${parceiroId}/extrato`,
+ {
+ headers,
+ params: {
+ page: params.page,
+ perPage: params.perPage,
+ },
+ },
+ );
+ const body = response.data;
+ const parceiroRaw = body.parceiro as unknown;
+ const parceiroNorm =
+ parceiroRaw != null && typeof parceiroRaw === "object"
+ ? normalizeParceiroExtrato(parceiroRaw as Record)
+ : body.parceiro;
+ return {
+ ...body,
+ parceiro: parceiroNorm,
+ data: (body.data ?? []).map((row) => normalizeBancoPontosExtratoItem(row as unknown as Record)),
+ };
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao carregar extrato do banco de pontos.");
+ }
+ }
+
+ async exportarExtrato(parceiroId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.get(
+ `${baseUrl}/banco-pontos/${parceiroId}/extrato/exportar`,
+ {
+ headers,
+ responseType: "arraybuffer",
+ },
+ );
+ const buf = response.data;
+ if (buf.byteLength < 4) {
+ throw new Error("Resposta vazia ao exportar planilha.");
+ }
+ const bytes = new Uint8Array(buf);
+ const headText = new TextDecoder().decode(bytes.subarray(0, Math.min(256, bytes.length))).trim();
+ if (headText.startsWith("{")) {
+ let json: ApiErrorShape;
+ try {
+ json = JSON.parse(new TextDecoder().decode(bytes)) as ApiErrorShape;
+ } catch {
+ throw new Error("A API retornou uma resposta inválida ao exportar.");
+ }
+ const msg = json.error?.message?.trim();
+ throw new Error(msg || "Falha ao exportar extrato (resposta JSON). Atualize o Commander.");
+ }
+ if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
+ throw new Error("A API não retornou um arquivo XLSX válido (assinatura inválida).");
+ }
+ return {
+ buffer: buf,
+ filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
+ };
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ this.handleAxiosError(error, "Erro ao exportar extrato do banco de pontos.");
+ }
+ throw error instanceof Error ? error : new Error("Erro ao exportar extrato do banco de pontos.");
+ }
+ }
+}
+
+export const fechamentoBancoPontosService = new FechamentoBancoPontosService();
diff --git a/src/services/fechamento/competencias.ts b/src/services/fechamento/competencias.ts
index 09a9c21..6d1c4e1 100644
--- a/src/services/fechamento/competencias.ts
+++ b/src/services/fechamento/competencias.ts
@@ -45,6 +45,18 @@ type CriarCompetenciaResponse = {
data: CompetenciaItem;
};
+type ConcluirCompetenciaResponse = {
+ data: CompetenciaItem & {
+ concluidoPorNome: string | null;
+ };
+};
+
+type ReabrirCompetenciaResponse = {
+ data: CompetenciaItem & {
+ reabertoPorNome: string | null;
+ };
+};
+
type ImportarCompetenciaResponse = {
data: {
competenciaId: string;
@@ -129,6 +141,40 @@ class FechamentoCompetenciasService {
}
}
+ async concluirCompetencia(competenciaId: string, concluidoPorId: string): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/competencias/${competenciaId}/concluir`,
+ { concluido_por_id: concluidoPorId },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao concluir competência.");
+ }
+ }
+
+ async reabrirCompetencia(
+ competenciaId: string,
+ reabertoPorId: string,
+ motivo?: string,
+ ): Promise {
+ try {
+ const headers = await buildCommanderHeaders();
+ const baseUrl = resolveCommanderBaseUrl();
+ const response = await axios.post(
+ `${baseUrl}/competencias/${competenciaId}/reabrir`,
+ { reaberto_por_id: reabertoPorId, motivo },
+ { headers },
+ );
+ return response.data.data;
+ } catch (error) {
+ this.handleAxiosError(error, "Erro ao reabrir competência.");
+ }
+ }
+
async importarDoAsana(
competenciaId: string,
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
diff --git a/src/services/fechamento/fechamentos.ts b/src/services/fechamento/fechamentos.ts
index 8750a36..c04243d 100644
--- a/src/services/fechamento/fechamentos.ts
+++ b/src/services/fechamento/fechamentos.ts
@@ -5,15 +5,19 @@ import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp"
export type FechamentoTarefaItem = {
id: string;
fechamentoId: string;
+ asanaTaskGid?: string | null;
tipo: string;
numeroTicket: string | null;
descricao: string;
cliente: string | null;
linkAsana: string | null;
+ etiquetas: { gid: string; name: string }[] | null;
tempoMinutos: number | null;
pontuacao: number;
pontuacaoOriginal: number;
estaRevisada: boolean;
+ dataInicio: string | null;
+ dataVencimento: string | null;
dataConclusao: string | null;
editadoEm: string | null;
editadoPorId: string | null;
@@ -96,6 +100,24 @@ type ApiErrorShape = {
};
class FechamentoFechamentosService {
+ private normalizeEtiquetas(
+ value: FechamentoTarefaItem["etiquetas"] | undefined,
+ tarefaId: string,
+ ): { gid: string; name: string }[] | null {
+ if (!Array.isArray(value) || value.length === 0) {
+ return null;
+ }
+ const out: { gid: string; name: string }[] = [];
+ for (let i = 0; i < value.length; i += 1) {
+ const item = value[i];
+ const name = typeof item?.name === "string" ? item.name.trim() : "";
+ if (!name) continue;
+ const gidRaw = typeof item?.gid === "string" ? item.gid.trim() : "";
+ out.push({ gid: gidRaw || `${tarefaId}-etiqueta-${i}`, name });
+ }
+ return out.length > 0 ? out : null;
+ }
+
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
if (!value) return null;
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
@@ -132,7 +154,11 @@ class FechamentoFechamentosService {
const response = await axios.get(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
headers,
});
- return response.data.data ?? [];
+ const tarefas = response.data.data ?? [];
+ return tarefas.map((tarefa) => ({
+ ...tarefa,
+ etiquetas: this.normalizeEtiquetas(tarefa.etiquetas, tarefa.id),
+ }));
} catch (error) {
this.handleAxiosError(error, "Erro ao listar tarefas do fechamento.");
}
diff --git a/src/services/fechamento/parceiroFotoUrl.ts b/src/services/fechamento/parceiroFotoUrl.ts
new file mode 100644
index 0000000..3770b14
--- /dev/null
+++ b/src/services/fechamento/parceiroFotoUrl.ts
@@ -0,0 +1,23 @@
+import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
+
+function commanderOrigin(): string {
+ const base = resolveCommanderBaseUrl();
+ return base.replace(/\/api\/?$/i, "");
+}
+
+/**
+ * Resolve URL absoluta da logo do parceiro.
+ * Aceita URL absoluta, protocolo relativo (//), path absoluto (/...) ou path relativo (ex.: pasta/arquivo).
+ */
+export function resolveParceiroFotoUrl(logoUrl: string | null | undefined): string | null {
+ if (logoUrl == null) return null;
+ const t = logoUrl.trim();
+ if (!t) return null;
+ if (/^https?:\/\//i.test(t)) return t;
+ if (t.startsWith("//")) {
+ return typeof window !== "undefined" && window.location.protocol === "http:" ? `http:${t}` : `https:${t}`;
+ }
+ const origin = commanderOrigin();
+ const withLeadingSlash = t.startsWith("/") ? t : `/${t}`;
+ return `${origin}${withLeadingSlash}`;
+}
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
new file mode 100644
index 0000000..36702cc
--- /dev/null
+++ b/tsconfig.app.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/globalfunctions.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/chatheader.tsx","./src/components/fullscreenloader.tsx","./src/components/layout.tsx","./src/components/themetoggle.tsx","./src/components/agent/agentsearch.tsx","./src/components/agent/agentview.tsx","./src/components/agent/opiniondialog.tsx","./src/components/areas/areasview.tsx","./src/components/audio/audioview.tsx","./src/components/audio/generationview.tsx","./src/components/audio/transcriptionview.tsx","./src/components/auth/authgate.tsx","./src/components/auth/noaccessscreen.tsx","./src/components/bots/botchat.tsx","./src/components/bots/botchatsidebar.tsx","./src/components/bots/botview.tsx","./src/components/chat/chatinput.tsx","./src/components/chat/chatmessage.tsx","./src/components/chat/chatsidebar.tsx","./src/components/chat/chatview.tsx","./src/components/fechamento/unidadegate.tsx","./src/components/images/imageview.tsx","./src/components/parecer-juridico/parecerjuridicodetailview.tsx","./src/components/parecer-juridico/parecerjuridicoformview.tsx","./src/components/parecer-juridico/parecerjuridicogeneratingscreen.tsx","./src/components/parecer-juridico/parecerjuridicoview.tsx","./src/components/prompts/promptgeneratingscreen.tsx","./src/components/prompts/promptsformview.tsx","./src/components/prompts/promptsview.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/animated-beam.tsx","./src/components/ui/animated-gradient-text.tsx","./src/components/ui/animated-grid-pattern.tsx","./src/components/ui/animated-shiny-text.tsx","./src/components/ui/aspect-ratio.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/border-beam.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/confetti.tsx","./src/components/ui/context-menu.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input-otp.tsx","./src/components/ui/input.tsx","./src/components/ui/interactive-hover-button.tsx","./src/components/ui/label.tsx","./src/components/ui/magic-card.tsx","./src/components/ui/menubar.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/neon-gradient-card.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/rainbow-button.tsx","./src/components/ui/resizable.tsx","./src/components/ui/ripple-button.tsx","./src/components/ui/ripple.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/shimmer-button.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/sparkles-text.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/terminal.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/components/ui/typing-animation.tsx","./src/components/ui/use-toast.ts","./src/components/ui/warp-background.tsx","./src/config/models.ts","./src/contexts/authaccesscontext.tsx","./src/contexts/promptscontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/lib/utils.ts","./src/modules/fechamento-hgtx/app.tsx","./src/modules/fechamento-hgtx/components/layout/appsidebar.tsx","./src/modules/fechamento-hgtx/components/layout/mainlayout.tsx","./src/modules/fechamento-hgtx/pages/bancopontos.tsx","./src/modules/fechamento-hgtx/pages/bancopontosextrato.tsx","./src/modules/fechamento-hgtx/pages/competenciafechamentos.tsx","./src/modules/fechamento-hgtx/pages/configuracoes.tsx","./src/modules/fechamento-hgtx/pages/fechamentodetalhes.tsx","./src/modules/fechamento-hgtx/pages/fechamentos.tsx","./src/modules/fechamento-hgtx/pages/meufechamento.tsx","./src/modules/fechamento-hgtx/pages/meufechamentobancopontos.tsx","./src/modules/fechamento-hgtx/pages/meuperfil.tsx","./src/modules/fechamento-hgtx/pages/notfound.tsx","./src/modules/fechamento-hgtx/pages/parceiros.tsx","./src/modules/fechamento-hgtx/pages/usuarios.tsx","./src/modules/intelligence-ia/app.tsx","./src/modules/intelligence-ia/components/navlink.tsx","./src/modules/intelligence-ia/components/dashboard/metriccard.tsx","./src/modules/intelligence-ia/components/dashboard/statusbadge.tsx","./src/modules/intelligence-ia/components/integrations/asanacard.tsx","./src/modules/intelligence-ia/components/integrations/googlecalendarcard.tsx","./src/modules/intelligence-ia/components/integrations/googlesheetscard.tsx","./src/modules/intelligence-ia/components/layout/appsidebar.tsx","./src/modules/intelligence-ia/components/layout/mainlayout.tsx","./src/modules/intelligence-ia/pages/dashboard.tsx","./src/modules/intelligence-ia/pages/financas.tsx","./src/modules/intelligence-ia/pages/index.tsx","./src/modules/intelligence-ia/pages/integrations.tsx","./src/modules/intelligence-ia/pages/meuperfil.tsx","./src/modules/intelligence-ia/pages/notfound.tsx","./src/pages/index.tsx","./src/pages/notfound.tsx","./src/pages/redirect.tsx","./src/pages/redirectservice.ts","./src/services/agent.ts","./src/services/api.ts","./src/services/areas.ts","./src/services/asana.ts","./src/services/assistenteprompts.ts","./src/services/audiogeneration.ts","./src/services/chat.ts","./src/services/imagegeneration.ts","./src/services/index.ts","./src/services/parecerapi.ts","./src/services/personalagent.ts","./src/services/promptsapi.ts","./src/services/transcription.ts","./src/services/types.ts","./src/services/fechamento/asanaworkspaceusers.ts","./src/services/fechamento/authme.ts","./src/services/fechamento/bancopontos.ts","./src/services/fechamento/commanderhttp.ts","./src/services/fechamento/competencias.ts","./src/services/fechamento/configuracoes.ts","./src/services/fechamento/fechamentobaseurl.ts","./src/services/fechamento/fechamentos.ts","./src/services/fechamento/parceirofotourl.ts","./src/services/fechamento/parceiros.ts","./src/services/fechamento/unidadecontext.ts","./src/services/fechamento/unidades.ts","./src/services/fechamento/uploads.ts","./src/services/fechamento/usuarios.ts","./src/utils/sound.ts"],"errors":true,"version":"5.8.3"}
\ No newline at end of file
diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo
new file mode 100644
index 0000000..3015526
--- /dev/null
+++ b/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./vite.config.ts"],"version":"5.8.3"}
\ No newline at end of file