From 4c28862c5c73baa4e030d260a3baf7df991698c3 Mon Sep 17 00:00:00 2001 From: Vitex Tecnologia Date: Mon, 27 Apr 2026 16:50:27 -0300 Subject: [PATCH] novas atualizacoes do sistema de fechamento --- src/App.tsx | 15 +- src/components/auth/NoAccessScreen.tsx | 73 ++- src/components/fechamento/UnidadeGate.tsx | 2 +- src/components/ui/border-beam.tsx | 111 ++--- src/components/ui/button.tsx | 2 +- src/components/ui/magic-card.tsx | 193 ++++++++ src/modules/fechamento-hgtx/App.tsx | 71 ++- .../components/layout/AppSidebar.tsx | 212 +++++++-- .../fechamento-hgtx/pages/BancoPontos.tsx | 268 ++++++++++- .../pages/BancoPontosExtrato.tsx | 335 +++++++++++++ .../pages/CompetenciaFechamentos.tsx | 360 ++++++++++++-- .../fechamento-hgtx/pages/Configuracoes.tsx | 58 ++- .../pages/FechamentoDetalhes.tsx | 306 ++++++++++-- .../fechamento-hgtx/pages/Fechamentos.tsx | 26 +- .../fechamento-hgtx/pages/MeuFechamento.tsx | 293 ++++++++++++ .../pages/MeuFechamentoBancoPontos.tsx | 245 ++++++++++ .../fechamento-hgtx/pages/MeuPerfil.tsx | 450 ++++++++++++++++++ .../fechamento-hgtx/pages/NotFound.tsx | 4 +- .../fechamento-hgtx/pages/Parceiros.tsx | 5 +- .../fechamento-hgtx/pages/Usuarios.tsx | 34 +- src/services/fechamento/authMe.ts | 218 +++++++++ src/services/fechamento/bancoPontos.ts | 308 ++++++++++++ src/services/fechamento/competencias.ts | 46 ++ src/services/fechamento/fechamentos.ts | 28 +- src/services/fechamento/parceiroFotoUrl.ts | 23 + tsconfig.app.tsbuildinfo | 1 + tsconfig.node.tsbuildinfo | 1 + 27 files changed, 3388 insertions(+), 300 deletions(-) create mode 100644 src/components/ui/magic-card.tsx create mode 100644 src/modules/fechamento-hgtx/pages/BancoPontosExtrato.tsx create mode 100644 src/modules/fechamento-hgtx/pages/MeuFechamento.tsx create mode 100644 src/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos.tsx create mode 100644 src/modules/fechamento-hgtx/pages/MeuPerfil.tsx create mode 100644 src/services/fechamento/bancoPontos.ts create mode 100644 src/services/fechamento/parceiroFotoUrl.ts create mode 100644 tsconfig.app.tsbuildinfo create mode 100644 tsconfig.node.tsbuildinfo 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" ? (
- + +

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}
@@ -102,16 +129,21 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) { />
- +
) : 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).

) : ( 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"}

+
+
+ + + ) : ( + <> + +
+ +
+ )}
-
+
+ ) : 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 ( +
+
+
+ +
+ +
+
+

+ + 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} +
+
+
+ +
+
+ +
+ +
+ + + + 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 ? ( + + ) : ( + + )} + + + ); + }) + )} + +
+
+ +
+

+ {loading ? ( + "Carregando paginação…" + ) : ( + <> + {intervaloLabel} + {" · "} + Página {meta.paginaAtual} de{" "} + {meta.total === 0 ? 1 : Math.max(1, meta.totalPaginas)} + + )} +

+
+ + +
+
+
+
+ ); +} 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 (
-
-

- - Fechamentos da Competência -

- {!loading && ( -
-

- {fechamentos.length} fechamento(s) -

- -
- )} +
+

+ + Fechamentos da Competência +

+ {!loading && ( +
+

{fechamentos.length} fechamento(s)

+ {competenciaStatus === "concluido" ? ( + + ) : temFechamentos ? ( + <> + + + + ) : null} +
+ )} +
{!loading && fechamentos.length === 0 ? ( - + Nenhum fechamento encontrado @@ -191,10 +312,14 @@ export default function CompetenciaFechamentos() {
- + {importKind === "sheet" ? ( + + ) : ( + + )}
) : ( @@ -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() {
- - - - 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. + + + +
+
) : null} +
- - + + +
+ + + + + Reabrir competência + + Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis. + + +
+ + setMotivoReabertura(e.target.value)} + placeholder="Ex.: correção de ajustes pós-fechamento" + /> +
+ + + + +
+
+ + + + + Concluir competência + + Deseja concluir esta competência? A operação só será permitida se todos os fechamentos estiverem fechados. + + + + + 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 ? (
@@ -268,8 +273,13 @@ export default function Configuracoes() { ) : ( <>
- - +
@@ -310,19 +320,26 @@ export default function Configuracoes() { )} +
- - - Integração Asana - - Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações. - - - + + + + Integração Asana + + Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações. + + + {configAsanaError ? (
+
); 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() {
- @@ -463,24 +573,25 @@ export default function FechamentoDetalhes() { {!loading ? (
- {isFechado ? "Fechado" : "Em aberto"} + {isFechado ? "Fechado" : "Em aberto"} + {isCompetenciaConcluida ? Competência concluída : null} - {!isFechado ? ( + {!isReadonly ? ( +
+ ) : 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 ? ( + + ) : null}
+
)}
@@ -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() { - @@ -799,11 +969,27 @@ export default function FechamentoDetalhes() {
- -
@@ -827,7 +1013,12 @@ export default function FechamentoDetalhes() { />
- - @@ -948,10 +1153,15 @@ export default function FechamentoDetalhes() {
- - 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 -
+
- + @@ -174,7 +177,12 @@ export default function Fechamentos() {
- +
+
+ 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))} + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+ + ); +} + 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 ( +
+ +
+ 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 ( +
+
+
+ +
+

+ + 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} +
+ +
+ +
+ + + + 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} +

+
+ + +
+
+
+
+ ); +} + 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. + + +
+ + +

{logoStatusLabel}

+ +
+ +
+
+ + setForm((prev) => ({ ...prev, nome: e.target.value }))} + /> +
+ +
+ + setForm((prev) => ({ ...prev, email: e.target.value }))} + /> +
+ +
+ + setForm((prev) => ({ ...prev, whatsapp: onlyDigits(e.target.value).slice(0, 11) }))} + placeholder="11 99999-9999" + /> +
+ +
+ + +
+ + {isPessoaFisica ? ( +
+ + setForm((prev) => ({ ...prev, cpf: onlyDigits(e.target.value).slice(0, 11) }))} + placeholder="000.000.000-00" + /> +
+ ) : ( +
+ + setForm((prev) => ({ ...prev, cnpj: onlyDigits(e.target.value).slice(0, 14) }))} + placeholder="00.000.000/0000-00" + /> +
+ )} +
+ +
+ +
+
+
+
+
+ ); +} + 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() {