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