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 { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { ThemeProvider } from "next-themes";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
@@ -11,6 +11,16 @@ import React from "react";
|
|||||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||||
import FechamentoHgtxApp from "./modules/fechamento-hgtx/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 queryClient = new QueryClient();
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
@@ -34,7 +44,8 @@ const App = () => (
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
<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 />} />
|
<Route path="/*" element={<Index />} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Loader2, ShieldX } from "lucide-react";
|
import { Loader2, ShieldX } from "lucide-react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { BorderBeam } from "@/components/ui/border-beam";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
|
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
|
||||||
import { authMeService } from "@/services/fechamento/authMe";
|
import { authMeService } from "@/services/fechamento/authMe";
|
||||||
|
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||||
|
|
||||||
type NoAccessScreenProps = {
|
type NoAccessScreenProps = {
|
||||||
reason: AccessBlockReason | null;
|
reason: AccessBlockReason | null;
|
||||||
@@ -19,9 +21,6 @@ function getMessage(reason: AccessBlockReason | null): string {
|
|||||||
if (reason === "erro") {
|
if (reason === "erro") {
|
||||||
return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
|
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.";
|
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 [adminNome, setAdminNome] = useState("");
|
||||||
const [adminEmail, setAdminEmail] = useState("");
|
const [adminEmail, setAdminEmail] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
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 () => {
|
const handleBootstrap = async () => {
|
||||||
if (!estabelecimentoId) {
|
if (!estabelecimentoId) {
|
||||||
@@ -58,20 +65,40 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<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">
|
<CardHeader className="text-center">
|
||||||
<div className="mb-3 flex justify-center">
|
{reason === "bootstrap" ? (
|
||||||
<ShieldX className="h-10 w-10 text-destructive" />
|
<>
|
||||||
</div>
|
<CardTitle>Bem-vindo! Configuração inicial</CardTitle>
|
||||||
<CardTitle>Você não tem acesso a este módulo</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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-center">
|
<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" ? (
|
{reason === "bootstrap" ? (
|
||||||
<div className="space-y-4 rounded-md border p-4 text-left">
|
<div className="space-y-4 rounded-md border p-4 text-left">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Código do estabelecimento</Label>
|
<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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
|
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
|
||||||
@@ -102,16 +129,21 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<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 ? (
|
{saving ? (
|
||||||
<>
|
<span className="inline-flex items-center gap-2">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||||
Inicializando...
|
Inicializando...
|
||||||
</>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"Inicializar ambiente"
|
"Iniciar ambiente"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</InteractiveHoverButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -119,6 +151,7 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
|||||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<BorderBeam duration={8} size={100} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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).
|
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
|
||||||
</p>
|
</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/fechamento-hgtx/configuracoes#unidade">Ir para Configurações</Link>
|
<Link to="/fechamento/configuracoes#unidade">Ir para Configurações</Link>
|
||||||
</Button>
|
</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 {
|
interface BorderBeamProps {
|
||||||
/**
|
size?: number;
|
||||||
* The size of the border beam.
|
duration?: number;
|
||||||
*/
|
delay?: number;
|
||||||
size?: number
|
colorFrom?: string;
|
||||||
/**
|
colorTo?: string;
|
||||||
* The duration of the border beam.
|
transition?: Transition;
|
||||||
*/
|
className?: string;
|
||||||
duration?: number
|
style?: CSSProperties;
|
||||||
/**
|
reverse?: boolean;
|
||||||
* The delay of the border beam.
|
initialOffset?: number;
|
||||||
*/
|
borderWidth?: number;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = ({
|
export const BorderBeam = ({
|
||||||
className,
|
className,
|
||||||
size = 50,
|
size = 50,
|
||||||
@@ -62,30 +34,33 @@ export const BorderBeam = ({
|
|||||||
initialOffset = 0,
|
initialOffset = 0,
|
||||||
borderWidth = 1,
|
borderWidth = 1,
|
||||||
}: BorderBeamProps) => {
|
}: 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 (
|
return (
|
||||||
<div
|
<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]"
|
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||||
style={
|
style={maskWrapperStyle}
|
||||||
{
|
|
||||||
"--border-beam-width": `${borderWidth}px`,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn("absolute aspect-square", className)}
|
||||||
"absolute aspect-square",
|
style={{ ...beamGradient, ...style } as MotionStyle}
|
||||||
"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
|
|
||||||
}
|
|
||||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||||
animate={{
|
animate={{
|
||||||
offsetDistance: reverse
|
offsetDistance: reverse
|
||||||
@@ -101,5 +76,5 @@ export const BorderBeam = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export interface ButtonProps
|
|||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
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";
|
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 { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
||||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
||||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
||||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
||||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
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 Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
||||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
||||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
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 NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
||||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
import { AuthGate } from "@/components/auth/AuthGate";
|
import { AuthGate } from "@/components/auth/AuthGate";
|
||||||
@@ -19,7 +23,23 @@ function RequireAdminRoute({ children }: { children: JSX.Element }) {
|
|||||||
return children;
|
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 = () => {
|
const FechamentoHgtxApp = () => {
|
||||||
@@ -29,10 +49,49 @@ const FechamentoHgtxApp = () => {
|
|||||||
<UnidadeGate>
|
<UnidadeGate>
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Fechamentos />} />
|
<Route
|
||||||
<Route path="competencias/:id" element={<CompetenciaFechamentos />} />
|
index
|
||||||
<Route path="fechamentos/:id" element={<FechamentoDetalhes />} />
|
element={
|
||||||
<Route path="banco-pontos" element={<BancoPontos />} />
|
<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
|
<Route
|
||||||
path="parceiros"
|
path="parceiros"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,24 +1,74 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Landmark,
|
Landmark,
|
||||||
Menu,
|
Menu,
|
||||||
|
ReceiptText,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserCircle2,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ title: "Fechamentos", path: "", icon: ClipboardList, onlyAdmin: false },
|
{
|
||||||
{ title: "Banco de Pontos", path: "banco-pontos", icon: Landmark, onlyAdmin: false },
|
section: "Fechamento",
|
||||||
{ title: "Parceiros", path: "parceiros", icon: Wallet, onlyAdmin: true },
|
title: "Gerenciar Fechamentos",
|
||||||
{ title: "Usuários", path: "usuarios", icon: Users, onlyAdmin: true },
|
path: "",
|
||||||
{ title: "Configurações", path: "configuracoes", icon: Settings, onlyAdmin: true },
|
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() {
|
export function AppSidebar() {
|
||||||
@@ -26,59 +76,118 @@ export function AppSidebar() {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const isAdmin = papel === "admin";
|
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 = () => (
|
const SidebarContent = () => (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-6">
|
<div
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
className={cn(
|
||||||
<ClipboardList className="h-5 w-5 text-primary" />
|
"border-b border-sidebar-border max-lg:pt-12",
|
||||||
</div>
|
collapsed
|
||||||
{!collapsed && (
|
? "flex flex-col items-center gap-2 px-2 py-3"
|
||||||
<div className="animate-fade-in">
|
: "flex items-center gap-2 px-3 py-4 lg:gap-3",
|
||||||
<h1 className="text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
|
{!collapsed ? (
|
||||||
</p>
|
<>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
<nav className="flex-1 space-y-3 px-3 py-4">
|
||||||
{allowedNavItems.map((item) => (
|
{sections.map((section) => {
|
||||||
<NavLink
|
const sectionItems = allowedNavItems.filter((item) => item.section === section);
|
||||||
key={item.path}
|
if (sectionItems.length === 0) {
|
||||||
to={item.path}
|
return null;
|
||||||
end={item.path === ""}
|
}
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className={({ isActive }) =>
|
return (
|
||||||
cn(
|
<div key={section} className="space-y-1">
|
||||||
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
{!collapsed ? (
|
||||||
isActive
|
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||||
? "bg-sidebar-accent font-medium text-primary"
|
{section}
|
||||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
</p>
|
||||||
)
|
) : null}
|
||||||
}
|
{sectionItems.map((item) => (
|
||||||
>
|
<NavLink
|
||||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
key={item.path}
|
||||||
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
|
to={item.path}
|
||||||
</NavLink>
|
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>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden border-t border-sidebar-border px-3 py-4 lg:block">
|
<div className="mt-auto border-t border-sidebar-border p-3">
|
||||||
<button
|
<Button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
type="button"
|
||||||
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"
|
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
|
<ArrowLeft className="h-4 w-4 flex-shrink-0" />
|
||||||
className={cn(
|
{!collapsed && <span>Voltar</span>}
|
||||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
</Button>
|
||||||
collapsed && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!collapsed && <span>Recolher</span>}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -86,7 +195,10 @@ export function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<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"
|
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" />
|
<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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||||
<div>
|
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Banco de Pontos</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||||
Consulte saldos por parceiro e extrato de créditos/débitos.
|
<Landmark className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<Card className="metric-card">
|
<div className="flex flex-1 flex-col gap-4 p-3 md:p-6">
|
||||||
<CardHeader>
|
<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">
|
||||||
<CardTitle>Estrutura inicial pronta</CardTitle>
|
<div className="min-w-[200px] flex-1 space-y-2">
|
||||||
</CardHeader>
|
<Label htmlFor="banco-busca">Buscar parceiro</Label>
|
||||||
<CardContent>
|
<div className="relative">
|
||||||
Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
</CardContent>
|
<Input
|
||||||
</Card>
|
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>
|
</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 { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -14,8 +22,10 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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 { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
|
||||||
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||||
|
|
||||||
@@ -35,14 +45,58 @@ function formatHoras(minutos: number | null): string {
|
|||||||
return `${horas}h ${mins}min`;
|
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() {
|
export default function CompetenciaFechamentos() {
|
||||||
|
const { me } = useAuthAccess();
|
||||||
const { id: competenciaId = "" } = useParams();
|
const { id: competenciaId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
|
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 [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
|
||||||
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
|
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<
|
const [reprocessMode, setReprocessMode] = useState<
|
||||||
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
|
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
|
||||||
>("reprocessar_tudo");
|
>("reprocessar_tudo");
|
||||||
@@ -51,8 +105,13 @@ export default function CompetenciaFechamentos() {
|
|||||||
const loadFechamentos = async () => {
|
const loadFechamentos = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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);
|
setFechamentos(data);
|
||||||
|
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
|
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
|
||||||
@@ -64,7 +123,7 @@ export default function CompetenciaFechamentos() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImportarAsana = async () => {
|
const handleImportarAsana = async () => {
|
||||||
setImporting(true);
|
setImportKind("sheet");
|
||||||
try {
|
try {
|
||||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
|
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -75,7 +134,7 @@ export default function CompetenciaFechamentos() {
|
|||||||
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
|
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImportKind(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +144,7 @@ export default function CompetenciaFechamentos() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setImporting(true);
|
setImportKind("modal");
|
||||||
try {
|
try {
|
||||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
|
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
|
||||||
modo: reprocessMode,
|
modo: reprocessMode,
|
||||||
@@ -106,7 +165,46 @@ export default function CompetenciaFechamentos() {
|
|||||||
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
|
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} 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));
|
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
|
||||||
}, [fechamentos]);
|
}, [fechamentos]);
|
||||||
|
|
||||||
|
const temFechamentos = fechamentos.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
<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="border-b border-border p-3 md:p-6">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<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" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||||
Fechamentos da Competência
|
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
||||||
</h1>
|
Fechamentos da Competência
|
||||||
{!loading && (
|
</h1>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
{!loading && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{fechamentos.length} fechamento(s)
|
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
|
||||||
</p>
|
{competenciaStatus === "concluido" ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
onClick={() => setIsReprocessModalOpen(true)}
|
onClick={() => setIsReabrirModalOpen(true)}
|
||||||
disabled={importing}
|
disabled={reabrindoCompetencia}
|
||||||
>
|
>
|
||||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
{reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
|
||||||
Reprocessar Asana
|
</Button>
|
||||||
</Button>
|
) : temFechamentos ? (
|
||||||
</div>
|
<>
|
||||||
)}
|
<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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||||
{!loading && fechamentos.length === 0 ? (
|
{!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>
|
<CardHeader>
|
||||||
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -191,10 +312,14 @@ export default function CompetenciaFechamentos() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6">
|
||||||
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
{importKind === "sheet" ? (
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<AsanaImportLoadingCard />
|
||||||
{importing ? "Importando..." : "Importar tasks do Asana"}
|
) : (
|
||||||
</Button>
|
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Importar tasks do Asana
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -213,8 +338,18 @@ export default function CompetenciaFechamentos() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
<TableCell colSpan={6} className="py-16">
|
||||||
Carregando fechamentos...
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
@@ -238,8 +373,8 @@ export default function CompetenciaFechamentos() {
|
|||||||
<Badge
|
<Badge
|
||||||
className={
|
className={
|
||||||
row.status === "fechado"
|
row.status === "fechado"
|
||||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-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"
|
||||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
: "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"}
|
{row.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||||
@@ -250,6 +385,7 @@ export default function CompetenciaFechamentos() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||||
onClick={() => void handleExportar(row.id)}
|
onClick={() => void handleExportar(row.id)}
|
||||||
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
|
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
|
||||||
title="Exportar planilha financeira (XLSX)"
|
title="Exportar planilha financeira (XLSX)"
|
||||||
@@ -260,8 +396,9 @@ export default function CompetenciaFechamentos() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
|
navigate(`/fechamento/fechamentos/${row.id}`, {
|
||||||
state: { competenciaId, status: row.status },
|
state: { competenciaId, status: row.status },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -280,17 +417,57 @@ export default function CompetenciaFechamentos() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
|
<Dialog
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
|
open={isReprocessModalOpen}
|
||||||
<DialogHeader>
|
onOpenChange={(open) => {
|
||||||
<DialogTitle>Reprocessar Asana</DialogTitle>
|
if (!open && importing) return;
|
||||||
<DialogDescription>
|
setIsReprocessModalOpen(open);
|
||||||
Escolha uma estratégia de reprocessamento para esta competência.
|
}}
|
||||||
</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
<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="flex min-h-0 flex-1 flex-col gap-4 px-6 pb-0 pt-12 sm:pt-14">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReprocessMode("reprocessar_tudo")}
|
onClick={() => setReprocessMode("reprocessar_tudo")}
|
||||||
@@ -389,14 +566,95 @@ export default function CompetenciaFechamentos() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="shrink-0 gap-2 border-t bg-background p-4 sm:justify-end">
|
||||||
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
|
onClick={() => setIsReprocessModalOpen(false)}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
|
<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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
|
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { MagicCard } from "@/components/ui/magic-card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -210,7 +211,7 @@ export default function Configuracoes() {
|
|||||||
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
|
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
|
||||||
toast.success("Unidade cadastrada.");
|
toast.success("Unidade cadastrada.");
|
||||||
clearCommanderUnidadeIdCache();
|
clearCommanderUnidadeIdCache();
|
||||||
navigate("/fechamento-hgtx", { replace: true });
|
navigate("/fechamento", { replace: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
|
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
|
||||||
@@ -242,15 +243,19 @@ export default function Configuracoes() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref={unidadeSectionRef} id="unidade">
|
<div ref={unidadeSectionRef} id="unidade">
|
||||||
<Card className="overflow-hidden border-border shadow-sm">
|
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
|
||||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
<MagicCard
|
||||||
<CardTitle className="text-base font-semibold">Unidade</CardTitle>
|
className="rounded-lg"
|
||||||
<CardDescription className="text-sm leading-relaxed">
|
gradientFrom="hsl(var(--primary))"
|
||||||
Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
|
gradientTo="hsl(var(--secondary))"
|
||||||
estabelecimento é somente leitura.
|
gradientSize={220}
|
||||||
</CardDescription>
|
>
|
||||||
</CardHeader>
|
<CardHeader className="space-y-0 border-b bg-muted/30 px-6 py-4">
|
||||||
<CardContent className="space-y-5 px-6 py-6">
|
<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 ? (
|
{loadingUnidade ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -268,8 +273,13 @@ export default function Configuracoes() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Código do estabelecimento (transfer)</Label>
|
<Input
|
||||||
<Input value={codigoEstabelecimento} readOnly className="h-11 font-mono text-sm bg-muted" />
|
id="codigo-estabelecimento"
|
||||||
|
aria-labelledby="titulo-codigo-estabelecimento"
|
||||||
|
value={codigoEstabelecimento}
|
||||||
|
readOnly
|
||||||
|
className="h-11 font-mono text-sm bg-muted"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nome-unidade">Nome da unidade</Label>
|
<Label htmlFor="nome-unidade">Nome da unidade</Label>
|
||||||
@@ -310,19 +320,26 @@ export default function Configuracoes() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</MagicCard>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="opacity-60" />
|
<Separator className="opacity-60" />
|
||||||
|
|
||||||
<Card className="overflow-hidden border-border shadow-sm">
|
<Card className="overflow-hidden border-border bg-card p-0 text-card-foreground shadow-sm">
|
||||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
<MagicCard
|
||||||
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
|
className="rounded-lg"
|
||||||
<CardDescription className="text-sm leading-relaxed">
|
gradientFrom="hsl(var(--primary))"
|
||||||
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
|
gradientTo="hsl(var(--secondary))"
|
||||||
</CardDescription>
|
gradientSize={220}
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent className="space-y-6 px-6 py-6">
|
<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 ? (
|
{configAsanaError ? (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -455,6 +472,7 @@ export default function Configuracoes() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
</MagicCard>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
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 { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -49,9 +49,44 @@ function parsePontuacaoInput(value: string): number {
|
|||||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
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 = {
|
type FechamentoDetalhesLocationState = {
|
||||||
competenciaId?: string;
|
competenciaId?: string;
|
||||||
status?: "em_aberto" | "fechado";
|
status?: "em_aberto" | "fechado";
|
||||||
|
readonlyView?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FechamentoDetalhes() {
|
export default function FechamentoDetalhes() {
|
||||||
@@ -64,7 +99,8 @@ export default function FechamentoDetalhes() {
|
|||||||
const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
|
const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tarefas, setTarefas] = useState<FechamentoTarefaItem[]>([]);
|
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 [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||||
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
|
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
|
||||||
const [savingLancamento, setSavingLancamento] = useState(false);
|
const [savingLancamento, setSavingLancamento] = useState(false);
|
||||||
@@ -91,7 +127,10 @@ export default function FechamentoDetalhes() {
|
|||||||
const [edicaoCliente, setEdicaoCliente] = useState("");
|
const [edicaoCliente, setEdicaoCliente] = useState("");
|
||||||
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
|
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
|
||||||
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
|
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
|
||||||
|
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
|
||||||
const isFechado = fechamentoStatus === "fechado";
|
const isFechado = fechamentoStatus === "fechado";
|
||||||
|
const isCompetenciaConcluida = competenciaStatus === "concluido";
|
||||||
|
const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida;
|
||||||
|
|
||||||
const totais = useMemo(() => {
|
const totais = useMemo(() => {
|
||||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
||||||
@@ -108,14 +147,26 @@ export default function FechamentoDetalhes() {
|
|||||||
const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
|
const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
|
||||||
const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
|
const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
|
||||||
const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
|
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 isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
|
||||||
|
const todasTarefasRevisadas = tarefas.length > 0 && tarefas.every((tarefa) => tarefa.estaRevisada);
|
||||||
|
|
||||||
const loadTarefas = async () => {
|
const loadTarefas = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -128,11 +179,16 @@ export default function FechamentoDetalhes() {
|
|||||||
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
|
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
|
||||||
if (!currentCompetenciaId) return;
|
if (!currentCompetenciaId) return;
|
||||||
try {
|
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 current = rows.find((row) => row.id === fechamentoId);
|
||||||
|
const competenciaAtual = competencias.find((item) => item.id === currentCompetenciaId);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
setFechamentoStatus(current.status);
|
setFechamentoStatus(current.status);
|
||||||
setPontuacaoMeta(current.pontuacaoMeta);
|
setPontuacaoMeta(current.pontuacaoMeta);
|
||||||
|
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
|
||||||
if (!competenciaId) {
|
if (!competenciaId) {
|
||||||
setCompetenciaId(current.competenciaId);
|
setCompetenciaId(current.competenciaId);
|
||||||
}
|
}
|
||||||
@@ -145,6 +201,10 @@ export default function FechamentoDetalhes() {
|
|||||||
toast.error("Fechamento está fechado. Reabra para editar.");
|
toast.error("Fechamento está fechado. Reabra para editar.");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toastBloqueioCompetenciaConcluida = () => {
|
||||||
|
toast.error("Competência concluída. Reabra a competência para editar.");
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fechamentoId) {
|
if (fechamentoId) {
|
||||||
void loadTarefas();
|
void loadTarefas();
|
||||||
@@ -165,12 +225,14 @@ export default function FechamentoDetalhes() {
|
|||||||
}, [isFechado]);
|
}, [isFechado]);
|
||||||
|
|
||||||
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const previous = tarefas;
|
|
||||||
setTogglingTaskId(tarefa.id);
|
|
||||||
setTarefas((prev) =>
|
setTarefas((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
item.id === tarefa.id
|
item.id === tarefa.id
|
||||||
@@ -181,21 +243,48 @@ export default function FechamentoDetalhes() {
|
|||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
};
|
||||||
if (!me?.id) {
|
|
||||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
const handleToggleTodasAprovadas = async () => {
|
||||||
}
|
if (isCompetenciaConcluida) {
|
||||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
toastBloqueioCompetenciaConcluida();
|
||||||
estaRevisada: approved,
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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 = () => {
|
const resetLancamentoForm = () => {
|
||||||
@@ -205,12 +294,16 @@ export default function FechamentoDetalhes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSalvarLancamento = async () => {
|
const handleSalvarLancamento = async () => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const descricao = lancamentoDescricao.trim();
|
const descricao = lancamentoDescricao.trim();
|
||||||
const pontuacao = Number(lancamentoPontuacao);
|
const pontuacao = Math.trunc(Number(lancamentoPontuacao));
|
||||||
if (!descricao) {
|
if (!descricao) {
|
||||||
toast.error("Informe a descrição do lançamento.");
|
toast.error("Informe a descrição do lançamento.");
|
||||||
return;
|
return;
|
||||||
@@ -240,6 +333,10 @@ export default function FechamentoDetalhes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAbrirConcluir = () => {
|
const handleAbrirConcluir = () => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
return;
|
||||||
@@ -250,6 +347,10 @@ export default function FechamentoDetalhes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleConcluirFechamento = async () => {
|
const handleConcluirFechamento = async () => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
return;
|
||||||
@@ -269,13 +370,14 @@ export default function FechamentoDetalhes() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setConcluindo(true);
|
setConcluindo(true);
|
||||||
|
await persistirRevisoesPendentes();
|
||||||
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
||||||
pontuacaoPaga: pontuacaoPagaRaw,
|
pontuacaoPaga: pontuacaoPagaRaw,
|
||||||
motivoAjuste: motivoAjuste.trim() || undefined,
|
motivoAjuste: motivoAjuste.trim() || undefined,
|
||||||
});
|
});
|
||||||
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
||||||
setFechamentoStatus("fechado");
|
setFechamentoStatus("fechado");
|
||||||
navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
|
navigate(`/fechamento/competencias/${data.competenciaId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
|
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -321,6 +423,10 @@ export default function FechamentoDetalhes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
|
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
return;
|
||||||
@@ -349,6 +455,10 @@ export default function FechamentoDetalhes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
|
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
|
||||||
|
if (isCompetenciaConcluida) {
|
||||||
|
toastBloqueioCompetenciaConcluida();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isFechado) {
|
if (isFechado) {
|
||||||
toastBloqueioFechado();
|
toastBloqueioFechado();
|
||||||
return;
|
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="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="border-b border-border bg-muted/20 p-3 md:p-6">
|
||||||
<div className="mb-2">
|
<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" />
|
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -463,24 +573,25 @@ export default function FechamentoDetalhes() {
|
|||||||
|
|
||||||
{!loading ? (
|
{!loading ? (
|
||||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsLancamentoOpen(true)}
|
onClick={() => setIsLancamentoOpen(true)}
|
||||||
disabled={isFechado}
|
disabled={isReadonly}
|
||||||
className="min-w-[152px]"
|
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Fazer lançamento
|
Fazer lançamento
|
||||||
</Button>
|
</Button>
|
||||||
{!isFechado ? (
|
{!isReadonly ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsReprocessAsanaOpen(true)}
|
onClick={() => setIsReprocessAsanaOpen(true)}
|
||||||
disabled={reprocessandoAsana}
|
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" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
Reprocessar Asana
|
Reprocessar Asana
|
||||||
@@ -491,7 +602,7 @@ export default function FechamentoDetalhes() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsReabrirOpen(true)}
|
onClick={() => setIsReabrirOpen(true)}
|
||||||
disabled={reabrindo}
|
disabled={reabrindo || isCompetenciaConcluida}
|
||||||
className="min-w-[152px]"
|
className="min-w-[152px]"
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
@@ -502,7 +613,7 @@ export default function FechamentoDetalhes() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleAbrirConcluir}
|
onClick={handleAbrirConcluir}
|
||||||
disabled={tarefas.length === 0 || concluindo}
|
disabled={tarefas.length === 0 || concluindo || isCompetenciaConcluida}
|
||||||
className="min-w-[152px]"
|
className="min-w-[152px]"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
@@ -515,6 +626,11 @@ export default function FechamentoDetalhes() {
|
|||||||
|
|
||||||
{!loading ? (
|
{!loading ? (
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<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">
|
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||||
<CardHeader className="space-y-2 p-4">
|
<CardHeader className="space-y-2 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -564,7 +680,26 @@ export default function FechamentoDetalhes() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -573,6 +708,10 @@ export default function FechamentoDetalhes() {
|
|||||||
<TableHead className="min-w-[320px]">Descrição</TableHead>
|
<TableHead className="min-w-[320px]">Descrição</TableHead>
|
||||||
<TableHead className="min-w-[180px]">Cliente</TableHead>
|
<TableHead className="min-w-[180px]">Cliente</TableHead>
|
||||||
<TableHead className="min-w-[110px]">Tipo</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]">Horas</TableHead>
|
||||||
<TableHead className="min-w-[120px]">Pontuação</TableHead>
|
<TableHead className="min-w-[120px]">Pontuação</TableHead>
|
||||||
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
|
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
|
||||||
@@ -581,7 +720,7 @@ export default function FechamentoDetalhes() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<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...
|
Carregando tarefas...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -593,25 +732,48 @@ export default function FechamentoDetalhes() {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={tarefa.estaRevisada}
|
checked={tarefa.estaRevisada}
|
||||||
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
|
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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
||||||
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
||||||
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
||||||
<TableCell>{tarefa.tipo}</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>{formatHoras(tarefa.tempoMinutos)}</TableCell>
|
||||||
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => openEditarTarefa(tarefa)}
|
onClick={() => openEditarTarefa(tarefa)}
|
||||||
disabled={isFechado}
|
disabled={isReadonly}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
<span className="ml-1">Editar</span>
|
<span className="ml-1">Editar</span>
|
||||||
@@ -623,7 +785,7 @@ export default function FechamentoDetalhes() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
onClick={() => void handleExcluirLancamento(tarefa)}
|
onClick={() => void handleExcluirLancamento(tarefa)}
|
||||||
disabled={isFechado || deletingTaskId === tarefa.id}
|
disabled={isReadonly || deletingTaskId === tarefa.id}
|
||||||
>
|
>
|
||||||
{deletingTaskId === tarefa.id ? (
|
{deletingTaskId === tarefa.id ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -641,6 +803,7 @@ export default function FechamentoDetalhes() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -675,10 +838,16 @@ export default function FechamentoDetalhes() {
|
|||||||
<Input
|
<Input
|
||||||
id="lancamento-pontuacao"
|
id="lancamento-pontuacao"
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
step={1}
|
step={1}
|
||||||
value={lancamentoPontuacao}
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -696,6 +865,7 @@ export default function FechamentoDetalhes() {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsLancamentoOpen(false);
|
setIsLancamentoOpen(false);
|
||||||
resetLancamentoForm();
|
resetLancamentoForm();
|
||||||
@@ -704,7 +874,7 @@ export default function FechamentoDetalhes() {
|
|||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
|
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}>
|
||||||
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -799,11 +969,27 @@ export default function FechamentoDetalhes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
|
<Button
|
||||||
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -827,7 +1013,12 @@ export default function FechamentoDetalhes() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
||||||
@@ -849,6 +1040,7 @@ export default function FechamentoDetalhes() {
|
|||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setDeletingTarefa(null);
|
setDeletingTarefa(null);
|
||||||
@@ -880,13 +1072,26 @@ export default function FechamentoDetalhes() {
|
|||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
onClick={() => setIsReprocessAsanaOpen(false)}
|
onClick={() => setIsReprocessAsanaOpen(false)}
|
||||||
disabled={reprocessandoAsana}
|
disabled={reprocessandoAsana}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
|
<Button
|
||||||
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
|
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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -948,10 +1153,15 @@ export default function FechamentoDetalhes() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
|
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}>
|
||||||
{savingEdicao ? "Salvando..." : "Salvar"}
|
{savingEdicao ? "Salvando..." : "Salvar"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export default function Fechamentos() {
|
|||||||
|
|
||||||
const competenciasFiltradas = useMemo(() => {
|
const competenciasFiltradas = useMemo(() => {
|
||||||
if (!anoSelecionado) return [];
|
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]);
|
}, [allCompetencias, anoSelecionado]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,15 +144,15 @@ export default function Fechamentos() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Nova Competência</DialogTitle>
|
<DialogTitle>Nova Competência</DialogTitle>
|
||||||
</DialogHeader>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">Mês</label>
|
<label className="text-sm font-medium">Mês</label>
|
||||||
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecione o mês" />
|
<SelectValue placeholder="Selecione o mês" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{meses.map ((m, i) => (
|
{meses.map((m, i) => (
|
||||||
<SelectItem key={i + 1} value={String(i + 1)}>
|
<SelectItem key={i + 1} value={String(i + 1)}>
|
||||||
{m}
|
{m}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -160,7 +163,7 @@ export default function Fechamentos() {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">Ano</label>
|
<label className="text-sm font-medium">Ano</label>
|
||||||
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Selecione o ano" />
|
<SelectValue placeholder="Selecione o ano" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -174,7 +177,12 @@ export default function Fechamentos() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
||||||
@@ -238,8 +246,8 @@ export default function Fechamentos() {
|
|||||||
<Badge
|
<Badge
|
||||||
className={
|
className={
|
||||||
competencia.status === "concluido"
|
competencia.status === "concluido"
|
||||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-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"
|
||||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
: "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"}
|
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
|
||||||
@@ -250,7 +258,7 @@ export default function Fechamentos() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
|
onClick={() => navigate(`/fechamento/competencias/${competencia.id}`)}
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="mr-2 h-4 w-4" />
|
||||||
Acessar
|
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(() => {
|
useEffect(() => {
|
||||||
console.error(
|
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,
|
||||||
);
|
);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -16,7 +16,7 @@ export default function NotFound() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-3 text-4xl font-bold">404</h1>
|
<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>
|
<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
|
Voltar para Fechamentos
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@ export default function Parceiros() {
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="hover:bg-muted/60 hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
resetFormState();
|
resetFormState();
|
||||||
@@ -1041,7 +1042,9 @@ export default function Parceiros() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<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}>
|
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedParceiro}>
|
||||||
{toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
|
{toggling ? "Processando..." : selectedParceiro?.estaAtivo ? "Inativar" : "Reativar"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -418,12 +418,24 @@ export default function Usuarios() {
|
|||||||
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
||||||
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
||||||
<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"}
|
{usuario.papel === "admin" ? "Admin" : "Parceiro"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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"}
|
{usuario.estaAtivo ? "Ativo" : "Inativo"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -588,7 +600,12 @@ export default function Usuarios() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={saving}>
|
<Button onClick={handleCreate} disabled={saving}>
|
||||||
@@ -683,7 +700,12 @@ export default function Usuarios() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleEdit} disabled={saving}>
|
<Button onClick={handleEdit} disabled={saving}>
|
||||||
@@ -712,7 +734,9 @@ export default function Usuarios() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<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}>
|
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
|
||||||
{toggling ? (
|
{toggling ? (
|
||||||
"Processando..."
|
"Processando..."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||||
|
import { normalizeBancoPontosExtratoItem } from "./bancoPontos";
|
||||||
|
|
||||||
export type PapelUsuario = "admin" | "parceiro";
|
export type PapelUsuario = "admin" | "parceiro";
|
||||||
|
|
||||||
@@ -27,11 +28,99 @@ export type BootstrapInitInput = {
|
|||||||
adminEmail: string;
|
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 = {
|
type MeResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: MeData;
|
data: MeData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MeuParceiroPerfilResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuParceiroPerfilData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeuFechamentoResumoResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuFechamentoResumoData;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeuBancoPontosExtratoResponse = {
|
||||||
|
success: boolean;
|
||||||
|
data: MeuBancoPontosExtratoData;
|
||||||
|
};
|
||||||
|
|
||||||
type BootstrapStatusResponse = {
|
type BootstrapStatusResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: BootstrapStatusData;
|
data: BootstrapStatusData;
|
||||||
@@ -45,6 +134,11 @@ type BootstrapInitResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExportarMeuBancoPontosExtratoResponse = {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
filename: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ApiErrorShape = {
|
type ApiErrorShape = {
|
||||||
status?: number;
|
status?: number;
|
||||||
data?: {
|
data?: {
|
||||||
@@ -61,6 +155,36 @@ function isApiError(value: unknown): value is ApiErrorShape {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AuthMeService {
|
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> {
|
async getMe(usuarioEmail: string): Promise<MeData | null> {
|
||||||
const baseUrl = resolveCommanderBaseUrl();
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||||
@@ -110,6 +234,100 @@ class AuthMeService {
|
|||||||
return response.data.data;
|
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 }> {
|
async bootstrapInitialize(input: BootstrapInitInput): Promise<{ unidadeId: string; adminId: string }> {
|
||||||
const baseUrl = resolveCommanderBaseUrl();
|
const baseUrl = resolveCommanderBaseUrl();
|
||||||
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
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;
|
data: CompetenciaItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConcluirCompetenciaResponse = {
|
||||||
|
data: CompetenciaItem & {
|
||||||
|
concluidoPorNome: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReabrirCompetenciaResponse = {
|
||||||
|
data: CompetenciaItem & {
|
||||||
|
reabertoPorNome: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ImportarCompetenciaResponse = {
|
type ImportarCompetenciaResponse = {
|
||||||
data: {
|
data: {
|
||||||
competenciaId: string;
|
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(
|
async importarDoAsana(
|
||||||
competenciaId: string,
|
competenciaId: string,
|
||||||
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
|
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp"
|
|||||||
export type FechamentoTarefaItem = {
|
export type FechamentoTarefaItem = {
|
||||||
id: string;
|
id: string;
|
||||||
fechamentoId: string;
|
fechamentoId: string;
|
||||||
|
asanaTaskGid?: string | null;
|
||||||
tipo: string;
|
tipo: string;
|
||||||
numeroTicket: string | null;
|
numeroTicket: string | null;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
cliente: string | null;
|
cliente: string | null;
|
||||||
linkAsana: string | null;
|
linkAsana: string | null;
|
||||||
|
etiquetas: { gid: string; name: string }[] | null;
|
||||||
tempoMinutos: number | null;
|
tempoMinutos: number | null;
|
||||||
pontuacao: number;
|
pontuacao: number;
|
||||||
pontuacaoOriginal: number;
|
pontuacaoOriginal: number;
|
||||||
estaRevisada: boolean;
|
estaRevisada: boolean;
|
||||||
|
dataInicio: string | null;
|
||||||
|
dataVencimento: string | null;
|
||||||
dataConclusao: string | null;
|
dataConclusao: string | null;
|
||||||
editadoEm: string | null;
|
editadoEm: string | null;
|
||||||
editadoPorId: string | null;
|
editadoPorId: string | null;
|
||||||
@@ -96,6 +100,24 @@ type ApiErrorShape = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class FechamentoFechamentosService {
|
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 {
|
private extractFilenameFromContentDisposition(value: string | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
@@ -132,7 +154,11 @@ class FechamentoFechamentosService {
|
|||||||
const response = await axios.get<ListarTarefasResponse>(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
|
const response = await axios.get<ListarTarefasResponse>(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
return response.data.data ?? [];
|
const tarefas = response.data.data ?? [];
|
||||||
|
return tarefas.map((tarefa) => ({
|
||||||
|
...tarefa,
|
||||||
|
etiquetas: this.normalizeEtiquetas(tarefa.etiquetas, tarefa.id),
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleAxiosError(error, "Erro ao listar tarefas do fechamento.");
|
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