atualizacoes modulo fechamento
This commit is contained in:
+3
-3
@@ -2,14 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Redirect from "./pages/Redirect";
|
||||
import { GlobalFunctions } from "./GlobalFunctions";
|
||||
import React from "react";
|
||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||
import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -33,8 +33,8 @@ const App = () => (
|
||||
element={<Redirect />}
|
||||
/>
|
||||
|
||||
{/* Módulo Intelligence IA como subpath*/}
|
||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
||||
<Route path="/fechamento-hgtx/*" element={<FechamentoHgtxApp />} />
|
||||
|
||||
<Route path="/*" element={<Index />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { authMeService } from "@/services/fechamento/authMe";
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const CORE_URL_FALLBACK = "https://core.hgtx.com.br";
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const {
|
||||
loading,
|
||||
hasAccess,
|
||||
reason,
|
||||
errorMessage,
|
||||
setAuthState,
|
||||
} = useAuthAccess();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const bootstrapAccess = async () => {
|
||||
const isLogado = GlobalFunctions.isUsuarioLogado();
|
||||
if (!isLogado) {
|
||||
setAuthState({ loading: false, isAuthenticated: false, hasAccess: false });
|
||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE || CORE_URL_FALLBACK);
|
||||
return;
|
||||
}
|
||||
|
||||
let userEmail = GlobalFunctions.getUsuarioLogado().email;
|
||||
if (!userEmail) {
|
||||
const transferEmail = GlobalFunctions.getTransferProperty(TransferAreaProperties.UsuarioEmail);
|
||||
if (typeof transferEmail === "string" && transferEmail.trim()) {
|
||||
userEmail = transferEmail.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!userEmail) {
|
||||
try {
|
||||
await GlobalFunctions.getToken();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
userEmail = GlobalFunctions.getUsuarioLogado().email;
|
||||
} catch (error) {
|
||||
console.error("Erro ao obter token para validação de acesso:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userEmail) {
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: false,
|
||||
hasAccess: false,
|
||||
reason: "erro",
|
||||
errorMessage: "Não foi possível identificar o e-mail do usuário logado.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const me = await authMeService.getMe(userEmail);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
const estabelecimentoCodigo = String(
|
||||
GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo) ?? "",
|
||||
).trim();
|
||||
if (estabelecimentoCodigo) {
|
||||
try {
|
||||
const bootstrap = await authMeService.getBootstrapStatus(estabelecimentoCodigo);
|
||||
if (bootstrap.requiresBootstrap) {
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
hasAccess: false,
|
||||
reason: "bootstrap",
|
||||
papel: null,
|
||||
me: null,
|
||||
errorMessage: estabelecimentoCodigo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Mantém fluxo padrão de não cadastrado caso não consiga consultar bootstrap.
|
||||
}
|
||||
}
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
hasAccess: false,
|
||||
reason: "nao_cadastrado",
|
||||
papel: null,
|
||||
me: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!me.estaAtivo) {
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
hasAccess: false,
|
||||
reason: "inativo",
|
||||
papel: me.papel,
|
||||
me,
|
||||
errorMessage: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
hasAccess: true,
|
||||
papel: me.papel,
|
||||
me,
|
||||
reason: null,
|
||||
errorMessage: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setAuthState({
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
hasAccess: false,
|
||||
reason: "erro",
|
||||
errorMessage: error instanceof Error ? error.message : "Erro ao validar acesso.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
bootstrapAccess();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [setAuthState]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Validando acesso...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return <NoAccessScreen reason={reason} errorMessage={errorMessage} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, ShieldX } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { AccessBlockReason } from "@/contexts/AuthAccessContext";
|
||||
import { authMeService } from "@/services/fechamento/authMe";
|
||||
|
||||
type NoAccessScreenProps = {
|
||||
reason: AccessBlockReason | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
function getMessage(reason: AccessBlockReason | null): string {
|
||||
if (reason === "inativo") {
|
||||
return "Seu usuário está inativo neste módulo. Solicite a reativação ao administrador.";
|
||||
}
|
||||
if (reason === "erro") {
|
||||
return "Não foi possível validar seu acesso agora. Tente novamente em instantes.";
|
||||
}
|
||||
if (reason === "bootstrap") {
|
||||
return "Primeiro acesso detectado. Cadastre a unidade e o primeiro usuário administrador para iniciar o sistema.";
|
||||
}
|
||||
return "Você não tem acesso a este módulo no momento. Solicite acesso ao administrador.";
|
||||
}
|
||||
|
||||
export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
||||
const [unidadeNome, setUnidadeNome] = useState("");
|
||||
const [adminNome, setAdminNome] = useState("");
|
||||
const [adminEmail, setAdminEmail] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const estabelecimentoId = (errorMessage ?? "").trim();
|
||||
|
||||
const handleBootstrap = async () => {
|
||||
if (!estabelecimentoId) {
|
||||
return;
|
||||
}
|
||||
if (!unidadeNome.trim() || !adminNome.trim() || !adminEmail.trim()) {
|
||||
alert("Preencha nome da unidade, nome do admin e e-mail.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
await authMeService.bootstrapInitialize({
|
||||
estabelecimentoId,
|
||||
unidadeNome: unidadeNome.trim(),
|
||||
adminNome: adminNome.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "Erro ao inicializar ambiente.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mb-3 flex justify-center">
|
||||
<ShieldX className="h-10 w-10 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>Você não tem acesso a este módulo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-center">
|
||||
<p className="text-muted-foreground">{getMessage(reason)}</p>
|
||||
{reason === "bootstrap" ? (
|
||||
<div className="space-y-4 rounded-md border p-4 text-left">
|
||||
<div className="space-y-1">
|
||||
<Label>Código do estabelecimento</Label>
|
||||
<Input value={estabelecimentoId} readOnly className="font-mono bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bootstrap-unidade">Nome da unidade</Label>
|
||||
<Input
|
||||
id="bootstrap-unidade"
|
||||
value={unidadeNome}
|
||||
onChange={(e) => setUnidadeNome(e.target.value)}
|
||||
placeholder="Ex.: Unidade Matriz"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bootstrap-admin-nome">Nome do primeiro admin</Label>
|
||||
<Input
|
||||
id="bootstrap-admin-nome"
|
||||
value={adminNome}
|
||||
onChange={(e) => setAdminNome(e.target.value)}
|
||||
placeholder="Ex.: João Silva"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bootstrap-admin-email">E-mail do primeiro admin</Label>
|
||||
<Input
|
||||
id="bootstrap-admin-email"
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="admin@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={handleBootstrap} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Inicializando...
|
||||
</>
|
||||
) : (
|
||||
"Inicializar ambiente"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{reason !== "bootstrap" && errorMessage ? (
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Building2, Loader2, MapPinOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import {
|
||||
getEstabelecimentoCodigoFromTransfer,
|
||||
lookupUnidadeByEstabelecimento,
|
||||
} from "@/services/fechamento/unidadeContext";
|
||||
|
||||
type GatePhase =
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "sem_codigo_transfer"
|
||||
| "unidade_nao_cadastrada"
|
||||
| "usuario_outra_unidade";
|
||||
|
||||
type UnidadeGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function isConfiguracoesPath(pathname: string): boolean {
|
||||
return pathname.includes("configuracoes");
|
||||
}
|
||||
|
||||
export function UnidadeGate({ children }: UnidadeGateProps) {
|
||||
const { me, papel } = useAuthAccess();
|
||||
const location = useLocation();
|
||||
const [phase, setPhase] = useState<GatePhase>("loading");
|
||||
const [codigoTransfer, setCodigoTransfer] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const run = async () => {
|
||||
const codigo = getEstabelecimentoCodigoFromTransfer();
|
||||
if (!mounted) return;
|
||||
setCodigoTransfer(codigo);
|
||||
|
||||
if (!codigo) {
|
||||
setPhase("sem_codigo_transfer");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await lookupUnidadeByEstabelecimento(codigo);
|
||||
if (!mounted) return;
|
||||
|
||||
if (!row) {
|
||||
setPhase("unidade_nao_cadastrada");
|
||||
return;
|
||||
}
|
||||
|
||||
if (papel !== "admin" && me?.unidadeId && me.unidadeId !== row.id) {
|
||||
setPhase("usuario_outra_unidade");
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("ready");
|
||||
} catch {
|
||||
if (!mounted) return;
|
||||
setPhase("unidade_nao_cadastrada");
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [me?.unidadeId, location.pathname, papel]);
|
||||
|
||||
const isAdmin = papel === "admin";
|
||||
const onConfiguracoes = isConfiguracoesPath(location.pathname);
|
||||
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Verificando unidade do estabelecimento...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "ready") {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (phase === "unidade_nao_cadastrada" && isAdmin && onConfiguracoes) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (phase === "sem_codigo_transfer") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mb-2 flex justify-center">
|
||||
<MapPinOff className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle>Estabelecimento não identificado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center text-muted-foreground">
|
||||
<p>
|
||||
O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
|
||||
Fechamento HGTX a partir do Codex com o estabelecimento carregado no transfer.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "unidade_nao_cadastrada") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mb-2 flex justify-center">
|
||||
<Building2 className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Unidade ainda não cadastrada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Não existe unidade no Commander para o estabelecimento{" "}
|
||||
<span className="font-mono text-foreground">{codigoTransfer || "—"}</span>.
|
||||
</p>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cadastre o nome da unidade em Configurações (integração com o código atual do transfer).
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link to="/fechamento-hgtx/configuracoes#unidade">Ir para Configurações</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Peça a um administrador para cadastrar a unidade deste estabelecimento no Commander. Informe o
|
||||
código: <span className="font-mono text-foreground">{codigoTransfer}</span>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "usuario_outra_unidade") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Estabelecimento diferente do seu cadastro</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-center text-muted-foreground">
|
||||
<p>
|
||||
Você está vinculado a outra unidade no Commander do que o estabelecimento aberto neste contexto
|
||||
(código <span className="font-mono text-foreground">{codigoTransfer}</span>).
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Abra o módulo com o estabelecimento correspondente ao seu usuário ou solicite ajuste ao
|
||||
administrador.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import type { MeData, PapelUsuario } from "@/services/fechamento/authMe";
|
||||
|
||||
export type AccessBlockReason = "nao_cadastrado" | "inativo" | "erro" | "bootstrap";
|
||||
|
||||
type AuthAccessState = {
|
||||
loading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
hasAccess: boolean;
|
||||
papel: PapelUsuario | null;
|
||||
me: MeData | null;
|
||||
reason: AccessBlockReason | null;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
|
||||
type AuthAccessContextValue = AuthAccessState & {
|
||||
setAuthState: (state: Partial<AuthAccessState>) => void;
|
||||
resetAuthState: () => void;
|
||||
};
|
||||
|
||||
const initialState: AuthAccessState = {
|
||||
loading: true,
|
||||
isAuthenticated: false,
|
||||
hasAccess: false,
|
||||
papel: null,
|
||||
me: null,
|
||||
reason: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const AuthAccessContext = createContext<AuthAccessContextValue | undefined>(undefined);
|
||||
|
||||
export function AuthAccessProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthAccessState>(initialState);
|
||||
const setAuthState = useCallback((partial: Partial<AuthAccessState>) => {
|
||||
setState((prev) => ({ ...prev, ...partial }));
|
||||
}, []);
|
||||
const resetAuthState = useCallback(() => {
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthAccessContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
setAuthState,
|
||||
resetAuthState,
|
||||
}),
|
||||
[state, setAuthState, resetAuthState],
|
||||
);
|
||||
|
||||
return <AuthAccessContext.Provider value={value}>{children}</AuthAccessContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuthAccess() {
|
||||
const ctx = useContext(AuthAccessContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuthAccess deve ser usado dentro de AuthAccessProvider.");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
||||
import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
||||
import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { AuthGate } from "@/components/auth/AuthGate";
|
||||
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
|
||||
|
||||
function RequireAdminRoute({ children }: { children: JSX.Element }) {
|
||||
const { papel } = useAuthAccess();
|
||||
|
||||
if (papel === "admin") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return <Navigate to="/fechamento-hgtx" replace />;
|
||||
}
|
||||
|
||||
const FechamentoHgtxApp = () => {
|
||||
return (
|
||||
<AuthAccessProvider>
|
||||
<AuthGate>
|
||||
<UnidadeGate>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route index element={<Fechamentos />} />
|
||||
<Route path="competencias/:id" element={<CompetenciaFechamentos />} />
|
||||
<Route path="fechamentos/:id" element={<FechamentoDetalhes />} />
|
||||
<Route path="banco-pontos" element={<BancoPontos />} />
|
||||
<Route
|
||||
path="parceiros"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Parceiros />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usuarios"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Usuarios />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuracoes"
|
||||
element={
|
||||
<RequireAdminRoute>
|
||||
<Configuracoes />
|
||||
</RequireAdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</UnidadeGate>
|
||||
</AuthGate>
|
||||
</AuthAccessProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FechamentoHgtxApp;
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ClipboardList,
|
||||
Landmark,
|
||||
Menu,
|
||||
Settings,
|
||||
Users,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
|
||||
const navItems = [
|
||||
{ title: "Fechamentos", path: "", icon: ClipboardList, onlyAdmin: false },
|
||||
{ title: "Banco de Pontos", path: "banco-pontos", icon: Landmark, onlyAdmin: false },
|
||||
{ title: "Parceiros", path: "parceiros", icon: Wallet, onlyAdmin: true },
|
||||
{ title: "Usuários", path: "usuarios", icon: Users, onlyAdmin: true },
|
||||
{ title: "Configurações", path: "configuracoes", icon: Settings, onlyAdmin: true },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
const { papel } = useAuthAccess();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const isAdmin = papel === "admin";
|
||||
const allowedNavItems = navItems.filter((item) => isAdmin || !item.onlyAdmin);
|
||||
|
||||
const SidebarContent = () => (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b border-sidebar-border px-4 py-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="text-lg font-semibold text-sidebar-foreground">Fechamento HGTX</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdmin ? "Painel Admin" : "Painel Parceiro"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{allowedNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === ""}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"nav-item flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-sidebar-accent font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in">{item.title}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden border-t border-sidebar-border px-3 py-4 lg:block">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="nav-item flex w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent hover:text-foreground lg:justify-start"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||
collapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Recolher</span>}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="fixed left-4 top-4 z-50 rounded-lg border border-border bg-card p-2 shadow-sm lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-50 flex h-full w-64 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-300 lg:hidden",
|
||||
mobileOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-sidebar-accent"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-300 lg:flex",
|
||||
collapsed ? "w-[72px]" : "w-64",
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } from "react";
|
||||
import { AppSidebar } from "@/modules/fechamento-hgtx/components/layout/AppSidebar";
|
||||
|
||||
type MainLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-background">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="p-4 pt-16 lg:p-8 lg:pt-8">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function BancoPontos() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Banco de Pontos</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Consulte saldos por parceiro e extrato de créditos/débitos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="metric-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Estrutura inicial pronta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Esta tela receberá filtros, tabela de saldos e navegação para extrato detalhado.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Download, ExternalLink, FileSpreadsheet, FolderKanban, RefreshCcw } 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
|
||||
|
||||
function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
|
||||
if (row.parceiroCodinome?.trim()) {
|
||||
return `${row.parceiroNome} (${row.parceiroCodinome.trim()})`;
|
||||
}
|
||||
return row.parceiroNome;
|
||||
}
|
||||
|
||||
function formatHoras(minutos: number | null): string {
|
||||
if (!minutos || minutos <= 0) return "0h";
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
export default function CompetenciaFechamentos() {
|
||||
const { id: competenciaId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
|
||||
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
|
||||
const [reprocessMode, setReprocessMode] = useState<
|
||||
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
|
||||
>("reprocessar_tudo");
|
||||
const [selectedParceiroIds, setSelectedParceiroIds] = useState<string[]>([]);
|
||||
|
||||
const loadFechamentos = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId);
|
||||
setFechamentos(data);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
|
||||
toast.error(message);
|
||||
setFechamentos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportarAsana = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
|
||||
toast.success(
|
||||
`Importação concluída: ${resultado.tarefasImportadas} tasks, ${resultado.fechamentosCriados} fechamentos criados.`,
|
||||
);
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutarReprocessamento = async () => {
|
||||
if (reprocessMode === "reprocessar_alguns" && selectedParceiroIds.length === 0) {
|
||||
toast.error("Selecione ao menos um fechamento/parceiro para reprocessar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
|
||||
modo: reprocessMode,
|
||||
parceiroIds: reprocessMode === "reprocessar_alguns" ? selectedParceiroIds : undefined,
|
||||
});
|
||||
if (reprocessMode === "buscar_novos_fechamentos") {
|
||||
toast.success(
|
||||
`Busca concluída: ${resultado.fechamentosCriados} novos fechamento(s) criado(s), sem alterar os atuais.`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Reprocessamento concluído: ${resultado.tarefasImportadas} tasks processadas e ${resultado.fechamentosCriados} fechamento(s) criado(s).`,
|
||||
);
|
||||
}
|
||||
setIsReprocessModalOpen(false);
|
||||
await loadFechamentos();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (competenciaId) {
|
||||
void loadFechamentos();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [competenciaId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
|
||||
}, [fechamentos]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento-hgtx")}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos da Competência
|
||||
</h1>
|
||||
{!loading && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fechamentos.length} fechamento(s)
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setIsReprocessModalOpen(true)}
|
||||
disabled={importing}
|
||||
>
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && fechamentos.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhum fechamento encontrado</CardTitle>
|
||||
<CardDescription>
|
||||
Importe as tasks do Asana para criar os fechamentos automaticamente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-6">
|
||||
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{importing ? "Importando..." : "Importar tasks do Asana"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px]">Logo</TableHead>
|
||||
<TableHead className="min-w-[280px]">Nome</TableHead>
|
||||
<TableHead className="min-w-[140px]">Pontuação Total</TableHead>
|
||||
<TableHead className="min-w-[120px]">Horas Total</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
Carregando fechamentos...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
fechamentos.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
{row.parceiroLogoUrl ? (
|
||||
<img
|
||||
src={row.parceiroLogoUrl}
|
||||
alt={`Logo ${row.parceiroNome}`}
|
||||
className="h-8 w-8 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
|
||||
<TableCell>{row.pontuacaoTotalEntregue}</TableCell>
|
||||
<TableCell>{formatHoras(row.horasTotal * 60)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
row.status === "fechado"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
}
|
||||
>
|
||||
{row.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleExportar(row.id)}
|
||||
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
|
||||
title="Exportar planilha financeira (XLSX)"
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
{exportingFechamentoId === row.id ? "Exportando..." : "Exportar"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
|
||||
state: { competenciaId, status: row.status },
|
||||
})
|
||||
}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Acessar
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reprocessar Asana</DialogTitle>
|
||||
<DialogDescription>
|
||||
Escolha uma estratégia de reprocessamento para esta competência.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("reprocessar_tudo")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "reprocessar_tudo"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Reprocessar tudo</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Remove fechamentos/tarefas atuais da competência e importa tudo novamente do zero.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("reprocessar_alguns")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "reprocessar_alguns"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Reprocessar apenas alguns</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Reprocessa somente os parceiros selecionados abaixo.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReprocessMode("buscar_novos_fechamentos")}
|
||||
className={`rounded-lg border p-3 text-left transition ${
|
||||
reprocessMode === "buscar_novos_fechamentos"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/60"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold">Buscar novos fechamentos</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Busca no Asana e cria apenas os fechamentos que ainda não existem.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reprocessMode === "reprocessar_alguns" ? (
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Selecione os fechamentos/parceiros</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedParceiroIds(Array.from(new Set(fechamentos.map((f) => f.parceiroId))))}
|
||||
>
|
||||
Marcar todos
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedParceiroIds([])}
|
||||
>
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fechamentos.map((f) => {
|
||||
const checked = selectedParceiroIds.includes(f.parceiroId);
|
||||
return (
|
||||
<label
|
||||
key={f.id}
|
||||
htmlFor={`sel-${f.id}`}
|
||||
className={`flex cursor-pointer items-center space-x-3 rounded-md border p-2 transition ${
|
||||
checked ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`sel-${f.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => {
|
||||
const on = Boolean(value);
|
||||
setSelectedParceiroIds((prev) =>
|
||||
on ? Array.from(new Set([...prev, f.parceiroId])) : prev.filter((id) => id !== f.parceiroId),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`sel-${f.id}`} className="cursor-pointer text-sm">
|
||||
{getDisplayNome(f)}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{f.status === "fechado" ? "Fechado" : "Em aberto"}
|
||||
</span>
|
||||
</Label>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
|
||||
{importing ? "Processando..." : "Executar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Eye, EyeOff, Loader2, RefreshCw, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { asanaService, type AsanaWorkspace } from "@/services/asana";
|
||||
import {
|
||||
fechamentoConfiguracoesService,
|
||||
type ConfiguracaoPublica,
|
||||
} from "@/services/fechamento/configuracoes";
|
||||
import { fechamentoUnidadesService } from "@/services/fechamento/unidades";
|
||||
import {
|
||||
clearCommanderUnidadeIdCache,
|
||||
getEstabelecimentoCodigoFromTransfer,
|
||||
lookupUnidadeByEstabelecimento,
|
||||
type UnidadeLookupRow,
|
||||
} from "@/services/fechamento/unidadeContext";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
|
||||
export default function Configuracoes() {
|
||||
const navigate = useNavigate();
|
||||
const { papel } = useAuthAccess();
|
||||
const isAdmin = papel === "admin";
|
||||
const unidadeSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [configAsanaError, setConfigAsanaError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingWorkspaces, setLoadingWorkspaces] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const [configAtual, setConfigAtual] = useState<ConfiguracaoPublica | null>(null);
|
||||
const [asanaToken, setAsanaToken] = useState("");
|
||||
const [workspaces, setWorkspaces] = useState<AsanaWorkspace[]>([]);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
|
||||
const [selectedWorkspaceNome, setSelectedWorkspaceNome] = useState("");
|
||||
|
||||
const [codigoEstabelecimento, setCodigoEstabelecimento] = useState("");
|
||||
const [unidadeExistente, setUnidadeExistente] = useState<UnidadeLookupRow | null>(null);
|
||||
const [nomeUnidade, setNomeUnidade] = useState("");
|
||||
const [loadingUnidade, setLoadingUnidade] = useState(true);
|
||||
const [savingUnidade, setSavingUnidade] = useState(false);
|
||||
|
||||
const tokenJaConfigurado = Boolean(configAtual?.asanaTokenConfigured);
|
||||
const tokenDigitado = asanaToken.trim();
|
||||
const podeBuscarWorkspaces = tokenDigitado.length >= 5 && !loadingWorkspaces;
|
||||
|
||||
const workspaceOptions = useMemo(() => {
|
||||
if (!selectedWorkspaceId || !selectedWorkspaceNome) {
|
||||
return workspaces;
|
||||
}
|
||||
if (workspaces.some((item) => item.id === selectedWorkspaceId)) {
|
||||
return workspaces;
|
||||
}
|
||||
return [{ id: selectedWorkspaceId, name: selectedWorkspaceNome }, ...workspaces];
|
||||
}, [workspaces, selectedWorkspaceId, selectedWorkspaceNome]);
|
||||
|
||||
const loadUnidade = async () => {
|
||||
try {
|
||||
setLoadingUnidade(true);
|
||||
const codigo = getEstabelecimentoCodigoFromTransfer();
|
||||
setCodigoEstabelecimento(codigo);
|
||||
if (!codigo) {
|
||||
setUnidadeExistente(null);
|
||||
setNomeUnidade("");
|
||||
return;
|
||||
}
|
||||
const row = await lookupUnidadeByEstabelecimento(codigo);
|
||||
setUnidadeExistente(row);
|
||||
setNomeUnidade(row?.nome ?? "");
|
||||
} catch {
|
||||
setUnidadeExistente(null);
|
||||
setNomeUnidade("");
|
||||
} finally {
|
||||
setLoadingUnidade(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfiguracoesAsana = async () => {
|
||||
try {
|
||||
setConfigAsanaError(null);
|
||||
const data = await fechamentoConfiguracoesService.getConfiguracoes();
|
||||
setConfigAtual(data);
|
||||
setAsanaToken(data?.asanaToken ?? "");
|
||||
setSelectedWorkspaceId(data?.asanaWorkspaceId ?? "");
|
||||
setSelectedWorkspaceNome(data?.asanaWorkspaceNome ?? "");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar configurações do Asana.";
|
||||
setConfigAsanaError(message);
|
||||
setConfigAtual(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await Promise.all([loadUnidade(), loadConfiguracoesAsana()]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || loading) return;
|
||||
if (window.location.hash === "#unidade" && unidadeSectionRef.current) {
|
||||
unidadeSectionRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const handleBuscarWorkspaces = async () => {
|
||||
if (!tokenDigitado) {
|
||||
toast.error("Informe o token do Asana para buscar workspaces.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingWorkspaces(true);
|
||||
const ws = await asanaService.getWorkspaces(tokenDigitado);
|
||||
setWorkspaces(ws);
|
||||
if (ws.length === 0) {
|
||||
toast.warning("Nenhum workspace foi encontrado para este token.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkspaceId && ws.some((item) => item.id === selectedWorkspaceId)) {
|
||||
const selected = ws.find((item) => item.id === selectedWorkspaceId);
|
||||
setSelectedWorkspaceNome(selected?.name ?? selectedWorkspaceNome);
|
||||
} else {
|
||||
setSelectedWorkspaceId("");
|
||||
setSelectedWorkspaceNome("");
|
||||
}
|
||||
|
||||
toast.success(`${ws.length} workspace(s) carregado(s).`);
|
||||
} catch (error) {
|
||||
const message = error && typeof error === "object" && "message" in error
|
||||
? String((error as { message: unknown }).message)
|
||||
: "Erro ao buscar workspaces do Asana.";
|
||||
toast.error(message);
|
||||
setWorkspaces([]);
|
||||
} finally {
|
||||
setLoadingWorkspaces(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsana = async () => {
|
||||
if (!selectedWorkspaceId) {
|
||||
toast.error("Selecione um workspace antes de salvar.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const workspaceSelecionado = workspaceOptions.find((item) => item.id === selectedWorkspaceId);
|
||||
const updated = await fechamentoConfiguracoesService.salvarConfiguracoes({
|
||||
asanaToken: tokenDigitado ? tokenDigitado : undefined,
|
||||
asanaWorkspaceId: selectedWorkspaceId,
|
||||
asanaWorkspaceNome: workspaceSelecionado?.name ?? selectedWorkspaceNome,
|
||||
});
|
||||
|
||||
setConfigAtual(updated);
|
||||
setAsanaToken(updated.asanaToken ?? "");
|
||||
setSelectedWorkspaceId(updated.asanaWorkspaceId ?? "");
|
||||
setSelectedWorkspaceNome(updated.asanaWorkspaceNome ?? "");
|
||||
setConfigAsanaError(null);
|
||||
toast.success("Configurações salvas com sucesso.");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar configurações.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSalvarUnidade = async () => {
|
||||
const nome = nomeUnidade.trim();
|
||||
if (!nome) {
|
||||
toast.error("Informe o nome da unidade.");
|
||||
return;
|
||||
}
|
||||
const codigo = codigoEstabelecimento.trim();
|
||||
if (!codigo) {
|
||||
toast.error("Código do estabelecimento não disponível no transfer.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingUnidade(true);
|
||||
if (unidadeExistente) {
|
||||
await fechamentoUnidadesService.updateUnidade(unidadeExistente.id, { nome });
|
||||
toast.success("Unidade atualizada.");
|
||||
clearCommanderUnidadeIdCache();
|
||||
await loadUnidade();
|
||||
} else {
|
||||
await fechamentoUnidadesService.createUnidade({ nome, estabelecimentoId: codigo });
|
||||
toast.success("Unidade cadastrada.");
|
||||
clearCommanderUnidadeIdCache();
|
||||
navigate("/fechamento-hgtx", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao salvar unidade.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingUnidade(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[300px] items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Carregando configurações...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-5xl space-y-8 pb-8">
|
||||
<header className="border-b border-border pb-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground md:text-2xl">Configurações do sistema</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
|
||||
Cadastro da unidade (estabelecimento) e integração com o Asana. Alterações aplicam-se ao contexto
|
||||
atual do Commander.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div ref={unidadeSectionRef} id="unidade">
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Unidade</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Nome exibido no Commander e vínculo com o código enviado pelo Codex (TransferArea). O código do
|
||||
estabelecimento é somente leitura.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 px-6 py-6">
|
||||
{loadingUnidade ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Carregando dados da unidade...
|
||||
</div>
|
||||
) : !isAdmin ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Apenas administradores podem cadastrar ou editar a unidade aqui.
|
||||
</p>
|
||||
) : !codigoEstabelecimento ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Abra o módulo pelo Codex com o estabelecimento no transfer para exibir o código e cadastrar a
|
||||
unidade.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Código do estabelecimento (transfer)</Label>
|
||||
<Input value={codigoEstabelecimento} readOnly className="h-11 font-mono text-sm bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nome-unidade">Nome da unidade</Label>
|
||||
<Input
|
||||
id="nome-unidade"
|
||||
value={nomeUnidade}
|
||||
onChange={(e) => setNomeUnidade(e.target.value)}
|
||||
placeholder="Ex.: Unidade Matriz"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{unidadeExistente ? (
|
||||
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
|
||||
Unidade cadastrada
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="max-w-full shrink-0 whitespace-normal sm:whitespace-nowrap">
|
||||
Pendente: informe o nome e salve para criar a unidade
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-border pt-4">
|
||||
<Button type="button" onClick={handleSalvarUnidade} disabled={savingUnidade}>
|
||||
{savingUnidade ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{unidadeExistente ? "Salvar nome da unidade" : "Criar unidade"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-60" />
|
||||
|
||||
<Card className="overflow-hidden border-border shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b bg-muted/30 px-6 py-4">
|
||||
<CardTitle className="text-base font-semibold">Integração Asana</CardTitle>
|
||||
<CardDescription className="text-sm leading-relaxed">
|
||||
Token pessoal ou de serviço, listagem de workspaces e workspace padrão usado nas importações.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-6 py-6">
|
||||
{configAsanaError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<p className="font-medium">Não foi possível carregar as configurações do Asana</p>
|
||||
<p className="mt-1 leading-relaxed opacity-90">
|
||||
{configAsanaError} Cadastre a unidade acima, se necessário, e atualize a página.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Label htmlFor="asana-token" className="text-sm font-medium">
|
||||
Token de acesso
|
||||
</Label>
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
{tokenJaConfigurado ? (
|
||||
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
|
||||
Token configurado no servidor
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 whitespace-nowrap">
|
||||
Token ainda não salvo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-stretch">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
id="asana-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={asanaToken}
|
||||
onChange={(e) => setAsanaToken(e.target.value)}
|
||||
placeholder={
|
||||
tokenJaConfigurado
|
||||
? "Substitua o token ou mantenha em branco para não alterar ao salvar"
|
||||
: "Cole o token do Asana (Personal Access Token)"
|
||||
}
|
||||
className="h-11 pr-11 font-mono text-sm"
|
||||
disabled={Boolean(configAsanaError)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((prev) => !prev)}
|
||||
title={showToken ? "Ocultar token" : "Mostrar token"}
|
||||
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-11 shrink-0 whitespace-nowrap px-5 lg:self-stretch"
|
||||
onClick={handleBuscarWorkspaces}
|
||||
disabled={!podeBuscarWorkspaces || Boolean(configAsanaError)}
|
||||
>
|
||||
{loadingWorkspaces ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Buscando…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Listar workspaces
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="asana-workspace" className="text-sm font-medium">
|
||||
Workspace padrão
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedWorkspaceId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWorkspaceId(value);
|
||||
const selected = workspaceOptions.find((item) => item.id === value);
|
||||
setSelectedWorkspaceNome(selected?.name ?? "");
|
||||
}}
|
||||
disabled={Boolean(configAsanaError)}
|
||||
>
|
||||
<SelectTrigger id="asana-workspace" className="h-11 w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
workspaceOptions.length > 0
|
||||
? "Selecione um workspace"
|
||||
: "Informe o token e clique em Listar workspaces"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{workspaceOptions.map((workspace) => (
|
||||
<SelectItem key={workspace.id} value={workspace.id}>
|
||||
{workspace.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
Selecionado: <span className="font-medium text-foreground">{selectedWorkspaceNome || "—"}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="min-w-[200px]"
|
||||
onClick={handleSaveAsana}
|
||||
disabled={saving || !selectedWorkspaceId || Boolean(configAsanaError)}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvando…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Salvar integração Asana
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, CheckCircle2, Clock3, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
|
||||
import { 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { fechamentoCompetenciasService } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos";
|
||||
|
||||
function formatHoras(minutos: number | null): string {
|
||||
if (!minutos || minutos <= 0) return "0h";
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatHorasResumo(minutos: number): string {
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function parsePontuacaoInput(value: string): number {
|
||||
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
type FechamentoDetalhesLocationState = {
|
||||
competenciaId?: string;
|
||||
status?: "em_aberto" | "fechado";
|
||||
};
|
||||
|
||||
export default function FechamentoDetalhes() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { me } = useAuthAccess();
|
||||
const { id: fechamentoId = "" } = useParams();
|
||||
const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null;
|
||||
const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? "");
|
||||
const [fechamentoStatus, setFechamentoStatus] = useState<"em_aberto" | "fechado">(initialState?.status ?? "em_aberto");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tarefas, setTarefas] = useState<FechamentoTarefaItem[]>([]);
|
||||
const [togglingTaskId, setTogglingTaskId] = useState<string | null>(null);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
|
||||
const [savingLancamento, setSavingLancamento] = useState(false);
|
||||
const [isConcluirOpen, setIsConcluirOpen] = useState(false);
|
||||
const [concluindo, setConcluindo] = useState(false);
|
||||
const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState("");
|
||||
const [motivoAjuste, setMotivoAjuste] = useState("");
|
||||
const [isReabrirOpen, setIsReabrirOpen] = useState(false);
|
||||
const [reabrindo, setReabrindo] = useState(false);
|
||||
const [motivoReabertura, setMotivoReabertura] = useState("");
|
||||
const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus");
|
||||
const [lancamentoDescricao, setLancamentoDescricao] = useState("");
|
||||
const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
|
||||
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(null);
|
||||
const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false);
|
||||
const [reprocessandoAsana, setReprocessandoAsana] = useState(false);
|
||||
const [isEditarOpen, setIsEditarOpen] = useState(false);
|
||||
const [savingEdicao, setSavingEdicao] = useState(false);
|
||||
const [editingTarefa, setEditingTarefa] = useState<FechamentoTarefaItem | null>(null);
|
||||
const [edicaoNumeroTicket, setEdicaoNumeroTicket] = useState("");
|
||||
const [edicaoDescricao, setEdicaoDescricao] = useState("");
|
||||
const [edicaoCliente, setEdicaoCliente] = useState("");
|
||||
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
|
||||
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
|
||||
const isFechado = fechamentoStatus === "fechado";
|
||||
|
||||
const totais = useMemo(() => {
|
||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
||||
const pontos = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
|
||||
const minutos = aprovadas.reduce((acc, t) => acc + Number(t.tempoMinutos || 0), 0);
|
||||
return {
|
||||
pontos,
|
||||
horas: formatHorasResumo(minutos),
|
||||
aprovadas: aprovadas.length,
|
||||
};
|
||||
}, [tarefas]);
|
||||
const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
|
||||
const bancoCalculado = totais.pontos - pontuacaoPagaNumero;
|
||||
const diferencaParaMeta = totais.pontos - Number(pontuacaoMeta ?? 0);
|
||||
const diferencaPagamentoMeta = pontuacaoPagaNumero - Number(pontuacaoMeta ?? 0);
|
||||
const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
|
||||
const pontuacaoTotalLabel = String(totais.pontos);
|
||||
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
|
||||
|
||||
const loadTarefas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
|
||||
setTarefas(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
||||
toast.error(message);
|
||||
setTarefas([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
|
||||
if (!currentCompetenciaId) return;
|
||||
try {
|
||||
const rows = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
|
||||
const current = rows.find((row) => row.id === fechamentoId);
|
||||
if (!current) return;
|
||||
setFechamentoStatus(current.status);
|
||||
setPontuacaoMeta(current.pontuacaoMeta);
|
||||
if (!competenciaId) {
|
||||
setCompetenciaId(current.competenciaId);
|
||||
}
|
||||
} catch {
|
||||
// mantém status atual em caso de erro para evitar bloquear navegação
|
||||
}
|
||||
};
|
||||
|
||||
const toastBloqueioFechado = () => {
|
||||
toast.error("Fechamento está fechado. Reabra para editar.");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fechamentoId) {
|
||||
void loadTarefas();
|
||||
}
|
||||
}, [fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (competenciaId) {
|
||||
void loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
}, [competenciaId, fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFechado) {
|
||||
setIsConcluirOpen(false);
|
||||
setIsLancamentoOpen(false);
|
||||
}
|
||||
}, [isFechado]);
|
||||
|
||||
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const previous = tarefas;
|
||||
setTogglingTaskId(tarefa.id);
|
||||
setTarefas((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === tarefa.id
|
||||
? {
|
||||
...item,
|
||||
estaRevisada: approved,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (!me?.id) {
|
||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
}
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
||||
estaRevisada: approved,
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setTarefas(previous);
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar aprovação da tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setTogglingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLancamentoForm = () => {
|
||||
setLancamentoTipo("bonus");
|
||||
setLancamentoDescricao("");
|
||||
setLancamentoPontuacao("0");
|
||||
};
|
||||
|
||||
const handleSalvarLancamento = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const descricao = lancamentoDescricao.trim();
|
||||
const pontuacao = Number(lancamentoPontuacao);
|
||||
if (!descricao) {
|
||||
toast.error("Informe a descrição do lançamento.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
|
||||
toast.error("Informe uma pontuação válida maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLancamento(true);
|
||||
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
|
||||
tipo: lancamentoTipo,
|
||||
descricao,
|
||||
pontuacao,
|
||||
});
|
||||
toast.success("Lançamento incluído com sucesso.");
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao incluir lançamento.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingLancamento(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbrirConcluir = () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
setPontuacaoPagaInput(pontuacaoTotalLabel);
|
||||
setMotivoAjuste("");
|
||||
setIsConcluirOpen(true);
|
||||
};
|
||||
|
||||
const handleConcluirFechamento = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const pontuacaoPagaRaw = parsePontuacaoInput(pontuacaoPagaInput);
|
||||
if (!Number.isFinite(pontuacaoPagaRaw)) {
|
||||
toast.error("Informe uma pontuação paga válida.");
|
||||
return;
|
||||
}
|
||||
if (pontuacaoPagaRaw <= 0) {
|
||||
toast.error("A pontuação paga deve ser maior que zero.");
|
||||
return;
|
||||
}
|
||||
if (requerMotivoAjuste && !motivoAjuste.trim()) {
|
||||
toast.error("Informe o motivo do ajuste quando houver diferença de saldo.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setConcluindo(true);
|
||||
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
||||
pontuacaoPaga: pontuacaoPagaRaw,
|
||||
motivoAjuste: motivoAjuste.trim() || undefined,
|
||||
});
|
||||
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
||||
setFechamentoStatus("fechado");
|
||||
navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
|
||||
toast.error(message);
|
||||
if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
await loadTarefas();
|
||||
} finally {
|
||||
setConcluindo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReabrirFechamento = async () => {
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para reabertura.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReabrindo(true);
|
||||
const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, {
|
||||
reabertoPorId: me.id,
|
||||
motivo: motivoReabertura,
|
||||
});
|
||||
toast.success("Fechamento reaberto com sucesso.");
|
||||
setFechamentoStatus(data.status);
|
||||
setIsReabrirOpen(false);
|
||||
setMotivoReabertura("");
|
||||
await loadTarefas();
|
||||
if (data.competenciaId) {
|
||||
setCompetenciaId(data.competenciaId);
|
||||
} else if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao reabrir fechamento.";
|
||||
toast.error(message);
|
||||
if (competenciaId) {
|
||||
await loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
} finally {
|
||||
setReabrindo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const isManual = tarefa.tipo === "bonus" || tarefa.tipo === "desconto";
|
||||
if (!isManual) return;
|
||||
setDeletingTarefa(tarefa);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteLancamento = async () => {
|
||||
if (!deletingTarefa) return;
|
||||
try {
|
||||
setDeletingTaskId(deletingTarefa.id);
|
||||
await fechamentoFechamentosService.excluirLancamento(fechamentoId, deletingTarefa.id);
|
||||
toast.success("Lançamento excluído com sucesso.");
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingTarefa(null);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao excluir lançamento.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setDeletingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
setEditingTarefa(tarefa);
|
||||
setEdicaoNumeroTicket(tarefa.numeroTicket ?? "");
|
||||
setEdicaoDescricao(tarefa.descricao ?? "");
|
||||
setEdicaoCliente(tarefa.cliente ?? "");
|
||||
setEdicaoTempoMinutos(tarefa.tempoMinutos != null ? String(tarefa.tempoMinutos) : "");
|
||||
setEdicaoPontuacao(String(Number(tarefa.pontuacao ?? 0)));
|
||||
setIsEditarOpen(true);
|
||||
};
|
||||
|
||||
const handleSalvarEdicao = async () => {
|
||||
if (!editingTarefa) return;
|
||||
if (!me?.id) {
|
||||
toast.error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
return;
|
||||
}
|
||||
const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto";
|
||||
const descricao = edicaoDescricao.trim();
|
||||
const pontuacao = parsePontuacaoInput(edicaoPontuacao);
|
||||
if (!descricao) {
|
||||
toast.error("Descrição é obrigatória.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
|
||||
toast.error("Pontuação deve ser maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
let tempoMinutos: number | null | undefined = undefined;
|
||||
if (!isManual) {
|
||||
const tempoRaw = edicaoTempoMinutos.trim();
|
||||
if (tempoRaw.length > 0) {
|
||||
const tempoParsed = Number(tempoRaw);
|
||||
if (!Number.isFinite(tempoParsed) || tempoParsed < 0 || !Number.isInteger(tempoParsed)) {
|
||||
toast.error("Horas/minutos deve ser um número inteiro maior ou igual a zero.");
|
||||
return;
|
||||
}
|
||||
tempoMinutos = tempoParsed;
|
||||
} else {
|
||||
tempoMinutos = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingEdicao(true);
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, editingTarefa.id, {
|
||||
descricao,
|
||||
pontuacao,
|
||||
...(isManual
|
||||
? {}
|
||||
: {
|
||||
numeroTicket: edicaoNumeroTicket.trim() ? edicaoNumeroTicket.trim() : null,
|
||||
cliente: edicaoCliente.trim() ? edicaoCliente.trim() : null,
|
||||
tempoMinutos,
|
||||
}),
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
toast.success("Tarefa atualizada com sucesso.");
|
||||
setIsEditarOpen(false);
|
||||
setEditingTarefa(null);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao editar tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSavingEdicao(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReprocessarAsana = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setReprocessandoAsana(true);
|
||||
const data = await fechamentoFechamentosService.reprocessarAsana(fechamentoId);
|
||||
toast.success(
|
||||
`Reprocessamento concluído: ${data.tarefasImportadas} tarefa(s) atualizada(s) para este parceiro.`,
|
||||
);
|
||||
setIsReprocessAsanaOpen(false);
|
||||
await loadTarefas();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao reprocessar Asana.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setReprocessandoAsana(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-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<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" />
|
||||
Detalhes do Fechamento
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Revisão operacional de tarefas, pontos e horas.</p>
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Badge variant={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsLancamentoOpen(true)}
|
||||
disabled={isFechado}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Fazer lançamento
|
||||
</Button>
|
||||
{!isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(true)}
|
||||
disabled={reprocessandoAsana}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
) : null}
|
||||
{isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReabrirOpen(true)}
|
||||
disabled={reabrindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reabrir fechamento
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAbrirConcluir}
|
||||
disabled={tarefas.length === 0 || concluindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Concluir fechamento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Total de tarefas</CardDescription>
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{tarefas.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Tarefas aprovadas</CardDescription>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Pontuação aprovada</CardDescription>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||
<CardHeader className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardDescription className="text-[11px] uppercase tracking-wide">Horas aprovadas</CardDescription>
|
||||
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{totais.horas}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && tarefas.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhuma tarefa encontrada</CardTitle>
|
||||
<CardDescription>Este fechamento não possui tarefas cadastradas.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px]">Aprovada</TableHead>
|
||||
<TableHead className="min-w-[120px]">Ticket</TableHead>
|
||||
<TableHead className="min-w-[320px]">Descrição</TableHead>
|
||||
<TableHead className="min-w-[180px]">Cliente</TableHead>
|
||||
<TableHead className="min-w-[110px]">Tipo</TableHead>
|
||||
<TableHead className="min-w-[120px]">Horas</TableHead>
|
||||
<TableHead className="min-w-[120px]">Pontuação</TableHead>
|
||||
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
|
||||
Carregando tarefas...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tarefas.map((tarefa) => (
|
||||
<TableRow key={tarefa.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={tarefa.estaRevisada}
|
||||
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
|
||||
disabled={togglingTaskId === tarefa.id || isFechado}
|
||||
/>
|
||||
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
||||
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
||||
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
||||
<TableCell>{tarefa.tipo}</TableCell>
|
||||
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
|
||||
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditarTarefa(tarefa)}
|
||||
disabled={isFechado}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="ml-1">Editar</span>
|
||||
</Button>
|
||||
{tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => void handleExcluirLancamento(tarefa)}
|
||||
disabled={isFechado || deletingTaskId === tarefa.id}
|
||||
>
|
||||
{deletingTaskId === tarefa.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1">Excluir</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fazer lançamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adicione uma bonificação ou desconto em pontuação para este fechamento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-tipo">Tipo de lançamento</Label>
|
||||
<Select
|
||||
value={lancamentoTipo}
|
||||
onValueChange={(value) => setLancamentoTipo(value as "bonus" | "desconto")}
|
||||
>
|
||||
<SelectTrigger id="lancamento-tipo">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bonus">Bonificação</SelectItem>
|
||||
<SelectItem value="desconto">Desconto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
|
||||
<Input
|
||||
id="lancamento-pontuacao"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={lancamentoPontuacao}
|
||||
onChange={(e) => setLancamentoPontuacao(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-descricao">Descrição</Label>
|
||||
<Input
|
||||
id="lancamento-descricao"
|
||||
value={lancamentoDescricao}
|
||||
onChange={(e) => setLancamentoDescricao(e.target.value)}
|
||||
placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
}}
|
||||
disabled={savingLancamento}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
|
||||
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Concluir fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os totais e confirme a pontuação paga para concluir este fechamento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Aprovada</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(totais.pontos)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Meta</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(Number(pontuacaoMeta ?? 0))}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Diferença</p>
|
||||
<p className={`text-2xl font-bold ${diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(diferencaParaMeta)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Banco de Pontos</p>
|
||||
<p className={`text-2xl font-bold ${bancoCalculado >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(bancoCalculado)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Pagar total aprovado
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
|
||||
>
|
||||
Pagar meta
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pontuacao-paga">Pontuação paga</Label>
|
||||
{isValorEditado ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition hover:text-foreground"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Usar valor total
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
id="pontuacao-paga"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={pontuacaoPagaInput}
|
||||
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
|
||||
placeholder="Ex.: 10,5"
|
||||
/>
|
||||
</div>
|
||||
{requerMotivoAjuste ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-ajuste">Motivo do ajuste *</Label>
|
||||
<Input
|
||||
id="motivo-ajuste"
|
||||
value={motivoAjuste}
|
||||
onChange={(e) => setMotivoAjuste(e.target.value)}
|
||||
placeholder="Ex.: pagamento parcial acordado com o parceiro"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
|
||||
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isReabrirOpen} onOpenChange={setIsReabrirOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reabrir fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-reabertura">Motivo (opcional)</Label>
|
||||
<Input
|
||||
id="motivo-reabertura"
|
||||
value={motivoReabertura}
|
||||
onChange={(e) => setMotivoReabertura(e.target.value)}
|
||||
placeholder="Ex.: ajuste após revisão financeira"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
||||
{reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir lançamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Deseja realmente excluir o lançamento manual{" "}
|
||||
<strong>{deletingTarefa?.descricao}</strong>? Esta ação não pode ser desfeita.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingTarefa(null);
|
||||
}}
|
||||
disabled={deletingTaskId !== null}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDeleteLancamento()}
|
||||
disabled={deletingTaskId !== null}
|
||||
>
|
||||
{deletingTaskId !== null ? "Excluindo..." : "Confirmar exclusão"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isReprocessAsanaOpen} onOpenChange={setIsReprocessAsanaOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reprocessar Asana deste fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Esta ação atualiza somente as tarefas do Asana para este parceiro no período da competência. Lançamentos
|
||||
manuais (bônus/desconto) serão preservados.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(false)}
|
||||
disabled={reprocessandoAsana}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
|
||||
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isEditarOpen} onOpenChange={setIsEditarOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar tarefa</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingTarefa?.tipo === "bonus" || editingTarefa?.tipo === "desconto"
|
||||
? "Para bônus/desconto, você pode editar apenas descrição e pontuação."
|
||||
: "Edite os campos da tarefa e salve as alterações."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{editingTarefa?.tipo === "tarefa" ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-ticket">Ticket</Label>
|
||||
<Input id="edit-ticket" value={edicaoNumeroTicket} onChange={(e) => setEdicaoNumeroTicket(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-descricao">Descrição</Label>
|
||||
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-cliente">Cliente</Label>
|
||||
<Input id="edit-cliente" value={edicaoCliente} onChange={(e) => setEdicaoCliente(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-tempo">Horas (minutos)</Label>
|
||||
<Input
|
||||
id="edit-tempo"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={edicaoTempoMinutos}
|
||||
onChange={(e) => setEdicaoTempoMinutos(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{editingTarefa?.tipo !== "tarefa" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-descricao">Descrição</Label>
|
||||
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-pontuacao">Pontuação</Label>
|
||||
<Input
|
||||
id="edit-pontuacao"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={edicaoPontuacao}
|
||||
onChange={(e) => setEdicaoPontuacao(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
|
||||
{savingEdicao ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ClipboardList, ExternalLink, Plus } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export default function Fechamentos() {
|
||||
const navigate = useNavigate();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const [allCompetencias, setAllCompetencias] = useState<CompetenciaItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [anoSelecionado, setAnoSelecionado] = useState<string>("");
|
||||
const [modalAberto, setModalAberto] = useState(false);
|
||||
const [mesSelecionado, setMesSelecionado] = useState<string>("");
|
||||
const [anoNovo, setAnoNovo] = useState<string>("");
|
||||
const [criando, setCriando] = useState(false);
|
||||
|
||||
const anosDisponiveis = useMemo(() => {
|
||||
const anosUnicos = [...new Set(allCompetencias.map((c) => c.ano))];
|
||||
if (!anosUnicos.includes(currentYear)) {
|
||||
anosUnicos.push(currentYear);
|
||||
}
|
||||
return anosUnicos.sort((a, b) => b - a).map(String);
|
||||
}, [allCompetencias, currentYear]);
|
||||
|
||||
const competenciasFiltradas = useMemo(() => {
|
||||
if (!anoSelecionado) return [];
|
||||
return allCompetencias.filter((c) => c.ano === Number(anoSelecionado));
|
||||
}, [allCompetencias, anoSelecionado]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadCompetencias = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoCompetenciasService.listarCompetencias({});
|
||||
if (!cancelled) {
|
||||
setAllCompetencias(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao carregar competências.";
|
||||
toast.error(message);
|
||||
setAllCompetencias([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadCompetencias();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (anosDisponiveis.length > 0 && !anoSelecionado) {
|
||||
const defaultAno = anosDisponiveis.includes(String(currentYear))
|
||||
? String(currentYear)
|
||||
: anosDisponiveis[0];
|
||||
setAnoSelecionado(defaultAno);
|
||||
}
|
||||
}, [anosDisponiveis, anoSelecionado, currentYear]);
|
||||
|
||||
const handleCriarCompetencia = async () => {
|
||||
const mes = Number(mesSelecionado);
|
||||
const ano = Number(anoNovo);
|
||||
if (!mes || !ano) {
|
||||
toast.error("Selecione mês e ano.");
|
||||
return;
|
||||
}
|
||||
setCriando(true);
|
||||
try {
|
||||
const nova = await fechamentoCompetenciasService.criarCompetencia({ mes, ano });
|
||||
setAllCompetencias((prev) => [...prev, nova]);
|
||||
setModalAberto(false);
|
||||
setMesSelecionado("");
|
||||
setAnoNovo("");
|
||||
toast.success("Competência criada com sucesso.");
|
||||
setAnoSelecionado(String(ano));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao criar competência.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setCriando(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<ClipboardList className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Fechamentos
|
||||
</h1>
|
||||
<Dialog open={modalAberto} onOpenChange={setModalAberto}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nova Competência
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Competência</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Mês</label>
|
||||
<Select value={mesSelecionado} onValueChange={setMesSelecionado}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o mês" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{meses.map ((m, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">Ano</label>
|
||||
<Select value={anoNovo} onValueChange={setAnoNovo}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o ano" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{anosDisponiveis.map((ano) => (
|
||||
<SelectItem key={ano} value={ano}>
|
||||
{ano}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalAberto(false)} disabled={criando}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleCriarCompetencia()} disabled={criando || !mesSelecionado || !anoNovo}>
|
||||
{criando ? "Criando..." : "Criar Competência"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="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>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loading && competenciasFiltradas.length === 0 ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhuma competência encontrada</CardTitle>
|
||||
<CardDescription>Não há competências cadastradas para o ano selecionado.</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[220px]">Mês</TableHead>
|
||||
<TableHead className="min-w-[180px]">Quantidade de Fechamentos</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Acessar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||
Carregando competências...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
competenciasFiltradas.map((competencia) => (
|
||||
<TableRow key={competencia.id}>
|
||||
<TableCell className="font-medium">
|
||||
{formatCompetenciaMes(competencia.mes, competencia.ano)}
|
||||
</TableCell>
|
||||
<TableCell>{competencia.quantidadeFechamentos}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={
|
||||
competencia.status === "concluido"
|
||||
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
|
||||
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
|
||||
}
|
||||
>
|
||||
{competencia.status === "concluido" ? "Concluída" : "Em aberto"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/fechamento-hgtx/competencias/${competencia.id}`)}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Acessar
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
export default function NotFound() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(
|
||||
"404 Error: User attempted to access non-existent fechamento-hgtx route:",
|
||||
location.pathname,
|
||||
);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-3 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-muted-foreground">Rota não encontrada neste módulo.</p>
|
||||
<Link to="/fechamento-hgtx" className="text-primary underline hover:text-primary/90">
|
||||
Voltar para Fechamentos
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,736 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Edit, Plus, Power, Users, UserCheck, UserX } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
fechamentoUsuariosService,
|
||||
type UsuarioItem,
|
||||
type UsuarioPapel,
|
||||
type UsuarioStatusFiltro,
|
||||
} from "@/services/fechamento/usuarios";
|
||||
import {
|
||||
fechamentoParceirosService,
|
||||
type ParceiroItem,
|
||||
} from "@/services/fechamento/parceiros";
|
||||
|
||||
type UsuarioForm = {
|
||||
nome: string;
|
||||
email: string;
|
||||
papel: UsuarioPapel;
|
||||
parceiroId: string;
|
||||
};
|
||||
|
||||
const formInicial: UsuarioForm = {
|
||||
nome: "",
|
||||
email: "",
|
||||
papel: "admin",
|
||||
parceiroId: "",
|
||||
};
|
||||
const parceiroSemVinculoValue = "__none__";
|
||||
|
||||
function getNomeUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||
if (!usuario) {
|
||||
return "—";
|
||||
}
|
||||
const nome = usuario.nome?.trim();
|
||||
return nome && nome.length > 0 ? nome : "—";
|
||||
}
|
||||
|
||||
function getEmailUsuario(usuario: UsuarioItem | null | undefined): string {
|
||||
if (!usuario) {
|
||||
return "—";
|
||||
}
|
||||
const email = usuario.email?.trim();
|
||||
return email && email.length > 0 ? email : "—";
|
||||
}
|
||||
|
||||
export default function Usuarios() {
|
||||
const [usuarios, setUsuarios] = useState<UsuarioItem[]>([]);
|
||||
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
||||
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingParceiros, setLoadingParceiros] = useState(true);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isToggleOpen, setIsToggleOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const [selectedUsuario, setSelectedUsuario] = useState<UsuarioItem | null>(null);
|
||||
const [form, setForm] = useState<UsuarioForm>(formInicial);
|
||||
|
||||
const [busca, setBusca] = useState("");
|
||||
const [filtroPapel, setFiltroPapel] = useState<"all" | UsuarioPapel>("all");
|
||||
const [filtroStatus, setFiltroStatus] = useState<UsuarioStatusFiltro>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
|
||||
const totalPages = Math.max(1, totalPaginas);
|
||||
const hasActiveFilters =
|
||||
busca.trim().length > 0 || filtroPapel !== "all" || filtroStatus !== "all";
|
||||
const parceiroObrigatorio = form.papel === "parceiro";
|
||||
const parceiroSelecionado = useMemo(
|
||||
() => parceiros.find((p) => p.id === form.parceiroId) ?? null,
|
||||
[parceiros, form.parceiroId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingParceiros(true);
|
||||
fechamentoParceirosService
|
||||
.listarParceirosAtivos()
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setParceiros(data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao listar parceiros.";
|
||||
toast.error(message);
|
||||
setParceiros([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingParceiros(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingList(true);
|
||||
fechamentoUsuariosService
|
||||
.listarUsuarios({
|
||||
busca: busca.trim() || undefined,
|
||||
papel: filtroPapel === "all" ? undefined : filtroPapel,
|
||||
estaAtivo: filtroStatus,
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setUsuarios(res.data ?? []);
|
||||
setTotalRegistros(res.meta?.total ?? 0);
|
||||
setTotalPaginas(res.meta?.totalPaginas ?? 1);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao listar usuários.";
|
||||
toast.error(message);
|
||||
setUsuarios([]);
|
||||
setTotalRegistros(0);
|
||||
setTotalPaginas(1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingList(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [busca, filtroPapel, filtroStatus, currentPage, itemsPerPage, reloadNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [busca, filtroPapel, filtroStatus]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setBusca("");
|
||||
setFiltroPapel("all");
|
||||
setFiltroStatus("all");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setForm(formInicial);
|
||||
setIsCreateOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (usuario: UsuarioItem) => {
|
||||
setSelectedUsuario(usuario);
|
||||
setForm({
|
||||
nome: usuario.nome ?? "",
|
||||
email: usuario.email ?? "",
|
||||
papel: usuario.papel,
|
||||
parceiroId: usuario.parceiroId ?? "",
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const openToggleDialog = (usuario: UsuarioItem) => {
|
||||
setSelectedUsuario(usuario);
|
||||
setIsToggleOpen(true);
|
||||
};
|
||||
|
||||
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 (parceiroObrigatorio && !form.parceiroId) {
|
||||
toast.error("Selecione um parceiro para o perfil parceiro.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
await fechamentoUsuariosService.criarUsuario({
|
||||
nome: form.nome.trim(),
|
||||
email: form.email.trim(),
|
||||
papel: form.papel,
|
||||
parceiroId: form.parceiroId || null,
|
||||
estaAtivo: true,
|
||||
});
|
||||
toast.success("Usuário criado com sucesso.");
|
||||
setIsCreateOpen(false);
|
||||
setCurrentPage(1);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao criar usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!selectedUsuario || !validateForm()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setSaving(true);
|
||||
await fechamentoUsuariosService.editarUsuario(selectedUsuario.id, {
|
||||
nome: form.nome.trim(),
|
||||
email: form.email.trim(),
|
||||
papel: form.papel,
|
||||
parceiroId: form.parceiroId || null,
|
||||
});
|
||||
toast.success("Usuário atualizado com sucesso.");
|
||||
setIsEditOpen(false);
|
||||
setSelectedUsuario(null);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao editar usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!selectedUsuario) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setToggling(true);
|
||||
const updated = await fechamentoUsuariosService.toggleAtivoUsuario(selectedUsuario.id);
|
||||
toast.success(updated.estaAtivo ? "Usuário reativado com sucesso." : "Usuário inativado com sucesso.");
|
||||
setIsToggleOpen(false);
|
||||
setSelectedUsuario(null);
|
||||
setReloadNonce((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar status do usuário.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const onChangePapel = (value: UsuarioPapel) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
papel: value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
|
||||
<div className="border-b border-border p-3 md:p-6">
|
||||
<div className="mb-4 flex flex-col items-start justify-between gap-3 md:flex-row md:items-center">
|
||||
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
|
||||
<Users className="h-5 w-5 md:h-6 md:w-6" />
|
||||
Usuários
|
||||
</h1>
|
||||
<Button onClick={openCreateDialog} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-2 md:flex-row md:items-center md:gap-4">
|
||||
<Input
|
||||
placeholder="Buscar por nome ou e-mail..."
|
||||
value={busca}
|
||||
onChange={(e) => setBusca(e.target.value)}
|
||||
className="w-full md:max-w-sm"
|
||||
/>
|
||||
<Select
|
||||
value={filtroPapel}
|
||||
onValueChange={(value) => setFiltroPapel(value as "all" | UsuarioPapel)}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-[170px]">
|
||||
<SelectValue placeholder="Perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filtroStatus} onValueChange={(value) => setFiltroStatus(value as UsuarioStatusFiltro)}>
|
||||
<SelectTrigger className="w-full md:w-[170px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos</SelectItem>
|
||||
<SelectItem value="true">Ativos</SelectItem>
|
||||
<SelectItem value="false">Inativos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm text-muted-foreground">Itens:</span>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
|
||||
<Card className="mx-auto mt-12 max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Nenhum usuário cadastrado
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Crie o primeiro usuário para iniciar o gerenciamento de acessos.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo usuário
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[220px]">Nome</TableHead>
|
||||
<TableHead className="min-w-[220px]">E-mail</TableHead>
|
||||
<TableHead className="min-w-[120px]">Perfil</TableHead>
|
||||
<TableHead className="min-w-[120px]">Status</TableHead>
|
||||
<TableHead className="text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingList ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
Carregando...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : totalRegistros === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
Nenhum usuário encontrado para os filtros atuais.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
usuarios.map((usuario) => (
|
||||
<TableRow key={usuario.id}>
|
||||
<TableCell className="font-medium">{getNomeUsuario(usuario)}</TableCell>
|
||||
<TableCell>{getEmailUsuario(usuario)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.papel === "admin" ? "default" : "secondary"}>
|
||||
{usuario.papel === "admin" ? "Admin" : "Parceiro"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={usuario.estaAtivo ? "secondary" : "outline"}>
|
||||
{usuario.estaAtivo ? "Ativo" : "Inativo"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(usuario)}
|
||||
title="Editar usuário"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openToggleDialog(usuario)}
|
||||
title={usuario.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!loadingList && totalRegistros > 0 && (
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-center text-sm text-muted-foreground sm:text-left">
|
||||
Mostrando {totalRegistros} {totalRegistros === 1 ? "usuário" : "usuários"}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="h-9 w-10 p-0"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Próxima
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo usuário</DialogTitle>
|
||||
<DialogDescription>Preencha os dados para criar um novo usuário.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="create-nome">Nome</Label>
|
||||
<Input
|
||||
id="create-nome"
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="create-email">E-mail</Label>
|
||||
<Input
|
||||
id="create-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Perfil</Label>
|
||||
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.parceiroId || parceiroSemVinculoValue}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||
}))
|
||||
}
|
||||
disabled={loadingParceiros}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
parceiroObrigatorio
|
||||
? "Selecione um parceiro"
|
||||
: "Opcional para admin, obrigatório para parceiro"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||
Sem parceiro
|
||||
</SelectItem>
|
||||
{parceiros.map((parceiro) => (
|
||||
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||
{parceiro.codinome?.trim()
|
||||
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||
: parceiro.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? "Criando..." : "Criar usuário"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar usuário</DialogTitle>
|
||||
<DialogDescription>Atualize os dados do usuário selecionado.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-nome">Nome</Label>
|
||||
<Input
|
||||
id="edit-nome"
|
||||
value={form.nome}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-email">E-mail</Label>
|
||||
<Input
|
||||
id="edit-email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="usuario@empresa.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Perfil</Label>
|
||||
<Select value={form.papel} onValueChange={(value) => onChangePapel(value as UsuarioPapel)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="parceiro">Parceiro</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Parceiro {parceiroObrigatorio ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.parceiroId || parceiroSemVinculoValue}
|
||||
onValueChange={(value) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
parceiroId: value === parceiroSemVinculoValue ? "" : value,
|
||||
}))
|
||||
}
|
||||
disabled={loadingParceiros}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
parceiroObrigatorio
|
||||
? "Selecione um parceiro"
|
||||
: "Opcional para admin, obrigatório para parceiro"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={parceiroSemVinculoValue} disabled={parceiroObrigatorio}>
|
||||
Sem parceiro
|
||||
</SelectItem>
|
||||
{parceiros.map((parceiro) => (
|
||||
<SelectItem key={parceiro.id} value={parceiro.id}>
|
||||
{parceiro.codinome?.trim()
|
||||
? `${parceiro.nome} (${parceiro.codinome.trim()})`
|
||||
: parceiro.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{parceiroSelecionado && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Vinculado: {parceiroSelecionado.codinome?.trim() || parceiroSelecionado.nome}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Cada parceiro pode estar vinculado a apenas um usuário.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={saving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleEdit} disabled={saving}>
|
||||
{saving ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={isToggleOpen} onOpenChange={setIsToggleOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{selectedUsuario?.estaAtivo ? "Inativar usuário" : "Reativar usuário"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{selectedUsuario?.estaAtivo ? (
|
||||
<>
|
||||
Deseja inativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Deseja reativar o usuário <strong>{getNomeUsuario(selectedUsuario)}</strong>?
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={toggling}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleToggleStatus} disabled={toggling || !selectedUsuario}>
|
||||
{toggling ? (
|
||||
"Processando..."
|
||||
) : selectedUsuario?.estaAtivo ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<UserX className="h-4 w-4" />
|
||||
Inativar
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Reativar
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type AsanaWorkspaceUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AsanaWorkspaceUsersResponse = {
|
||||
data: Array<{
|
||||
gid: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
class FechamentoAsanaWorkspaceUsersService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async listarUsuariosDoWorkspace(workspaceId: string): Promise<AsanaWorkspaceUser[]> {
|
||||
if (!workspaceId.trim()) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<AsanaWorkspaceUsersResponse>(
|
||||
`${baseUrl}/asana/workspaces/${encodeURIComponent(workspaceId)}/users`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return (response.data.data ?? []).map((item) => ({
|
||||
id: item.gid,
|
||||
name: item.name?.trim() || item.email?.trim() || item.gid,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar usuários do workspace do Asana.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoAsanaWorkspaceUsersService = new FechamentoAsanaWorkspaceUsersService();
|
||||
@@ -0,0 +1,123 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type PapelUsuario = "admin" | "parceiro";
|
||||
|
||||
export type MeData = {
|
||||
id: string;
|
||||
nome: string;
|
||||
email: string;
|
||||
papel: PapelUsuario;
|
||||
parceiroId: string | null;
|
||||
estaAtivo: boolean;
|
||||
unidadeId: string;
|
||||
};
|
||||
|
||||
export type BootstrapStatusData = {
|
||||
requiresBootstrap: boolean;
|
||||
estabelecimentoId: string;
|
||||
unidade: { id: string; nome: string; estabelecimentoId: string } | null;
|
||||
};
|
||||
|
||||
export type BootstrapInitInput = {
|
||||
estabelecimentoId: string;
|
||||
unidadeNome: string;
|
||||
adminNome: string;
|
||||
adminEmail: string;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
success: boolean;
|
||||
data: MeData;
|
||||
};
|
||||
|
||||
type BootstrapStatusResponse = {
|
||||
success: boolean;
|
||||
data: BootstrapStatusData;
|
||||
};
|
||||
|
||||
type BootstrapInitResponse = {
|
||||
success: boolean;
|
||||
data: {
|
||||
unidadeId: string;
|
||||
adminId: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ApiErrorShape = {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function isApiError(value: unknown): value is ApiErrorShape {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
class AuthMeService {
|
||||
async getMe(usuarioEmail: string): Promise<MeData | null> {
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||
|
||||
try {
|
||||
const response = await axios.get<MeResponse>(`${baseUrl}/me`, {
|
||||
params: { usuarioEmail },
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const code = (error.response?.data as ApiErrorShape["data"] | undefined)?.error?.code;
|
||||
if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
|
||||
return null;
|
||||
}
|
||||
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;
|
||||
throw new Error(message || "Erro ao consultar acesso do usuário.");
|
||||
}
|
||||
if (isApiError(error)) {
|
||||
const status = error.status;
|
||||
const code = error.data?.error?.code;
|
||||
if (status === 404 || code === "USUARIO_NAO_CADASTRADO") {
|
||||
return null;
|
||||
}
|
||||
const message = error.data?.error?.message ?? error.message;
|
||||
throw new Error(message || "Erro ao consultar acesso do usuário.");
|
||||
}
|
||||
throw new Error("Erro ao consultar acesso do usuário.");
|
||||
}
|
||||
}
|
||||
|
||||
async getBootstrapStatus(estabelecimentoId: string): Promise<BootstrapStatusData> {
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||
const response = await axios.get<BootstrapStatusResponse>(`${baseUrl}/bootstrap-status`, {
|
||||
headers,
|
||||
params: { estabelecimentoId },
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async bootstrapInitialize(input: BootstrapInitInput): Promise<{ unidadeId: string; adminId: string }> {
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildCommanderHeaders({ omitUnidadeId: true });
|
||||
const response = await axios.post<BootstrapInitResponse>(`${baseUrl}/bootstrap`, input, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const authMeService = new AuthMeService();
|
||||
@@ -0,0 +1,27 @@
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
import { resolveCommanderUnidadeId } from "./unidadeContext";
|
||||
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||
|
||||
export { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||
|
||||
export type BuildCommanderHeadersOptions = {
|
||||
/** Rotas allowlisted no Commander (ex.: `/me`, lookup de `/unidades`) não devem enviar `X-Unidade-Id`. */
|
||||
omitUnidadeId?: boolean;
|
||||
};
|
||||
|
||||
export async function buildCommanderHeaders(
|
||||
opts?: BuildCommanderHeadersOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = await GlobalFunctions.getToken();
|
||||
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { apikey: apiKey } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
if (!opts?.omitUnidadeId) {
|
||||
headers["X-Unidade-Id"] = await resolveCommanderUnidadeId();
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type CompetenciaStatus = "em_aberto" | "concluido";
|
||||
|
||||
export type CompetenciaItem = {
|
||||
id: string;
|
||||
mes: number;
|
||||
ano: number;
|
||||
status: CompetenciaStatus;
|
||||
quantidadeFechamentos: number;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
export type FechamentoDaCompetenciaItem = {
|
||||
id: string;
|
||||
competenciaId: string;
|
||||
parceiroId: string;
|
||||
parceiroNome: string;
|
||||
parceiroCodinome: string | null;
|
||||
parceiroLogoUrl: string | null;
|
||||
horasTotal: number;
|
||||
status: "em_aberto" | "fechado";
|
||||
pontuacaoTotalEntregue: number;
|
||||
pontuacaoMeta: number;
|
||||
pontuacaoPaga: number;
|
||||
pontuacaoBanco: number;
|
||||
exportadoFinanceiro: boolean;
|
||||
fechadoEm: string | null;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
type ListarCompetenciasResponse = {
|
||||
data: CompetenciaItem[];
|
||||
};
|
||||
|
||||
type ListarFechamentosDaCompetenciaResponse = {
|
||||
data: FechamentoDaCompetenciaItem[];
|
||||
};
|
||||
|
||||
type CriarCompetenciaResponse = {
|
||||
data: CompetenciaItem;
|
||||
};
|
||||
|
||||
type ImportarCompetenciaResponse = {
|
||||
data: {
|
||||
competenciaId: string;
|
||||
tarefasRecebidas: number;
|
||||
tarefasImportadas: number;
|
||||
fechamentosCriados: number;
|
||||
tarefasIgnoradasSemParceiro: number;
|
||||
chamadasSubtarefasAsana: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImportacaoAsanaModo =
|
||||
| "reprocessar_tudo"
|
||||
| "reprocessar_alguns"
|
||||
| "buscar_novos_fechamentos";
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
class FechamentoCompetenciasService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async listarCompetencias(params: { ano?: number; status?: CompetenciaStatus }): Promise<CompetenciaItem[]> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ListarCompetenciasResponse>(`${baseUrl}/competencias`, {
|
||||
headers,
|
||||
params: {
|
||||
ano: params.ano,
|
||||
status: params.status,
|
||||
},
|
||||
});
|
||||
return response.data.data ?? [];
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar competências.");
|
||||
}
|
||||
}
|
||||
|
||||
async listarFechamentosDaCompetencia(competenciaId: string): Promise<FechamentoDaCompetenciaItem[]> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ListarFechamentosDaCompetenciaResponse>(
|
||||
`${baseUrl}/competencias/${competenciaId}/fechamentos`,
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data ?? [];
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar fechamentos da competência.");
|
||||
}
|
||||
}
|
||||
|
||||
async criarCompetencia(input: { mes: number; ano: number }): Promise<CompetenciaItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<CriarCompetenciaResponse>(
|
||||
`${baseUrl}/competencias`,
|
||||
{ mes: input.mes, ano: input.ano },
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao criar competência.");
|
||||
}
|
||||
}
|
||||
|
||||
async importarDoAsana(
|
||||
competenciaId: string,
|
||||
opcoes?: { modo?: ImportacaoAsanaModo; parceiroIds?: string[] },
|
||||
): Promise<{ tarefasRecebidas: number; tarefasImportadas: number; fechamentosCriados: number; tarefasIgnoradasSemParceiro: number }> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<ImportarCompetenciaResponse>(
|
||||
`${baseUrl}/competencias/${competenciaId}/importar`,
|
||||
{
|
||||
modo: opcoes?.modo ?? "reprocessar_tudo",
|
||||
parceiroIds: opcoes?.parceiroIds,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return {
|
||||
tarefasRecebidas: response.data.data.tarefasRecebidas,
|
||||
tarefasImportadas: response.data.data.tarefasImportadas,
|
||||
fechamentosCriados: response.data.data.fechamentosCriados,
|
||||
tarefasIgnoradasSemParceiro: response.data.data.tarefasIgnoradasSemParceiro,
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao importar tasks do Asana.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoCompetenciasService = new FechamentoCompetenciasService();
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type ConfiguracaoPublica = {
|
||||
id: string;
|
||||
asanaWorkspaceId: string | null;
|
||||
asanaWorkspaceNome: string | null;
|
||||
asanaToken: string | null;
|
||||
asanaTokenConfigured: boolean;
|
||||
atualizadoEm: string;
|
||||
atualizadoPorId: string | null;
|
||||
};
|
||||
|
||||
type GetConfiguracoesResponse = {
|
||||
data: ConfiguracaoPublica | null;
|
||||
};
|
||||
|
||||
type SalvarConfiguracoesBody = {
|
||||
asanaToken?: string;
|
||||
asanaWorkspaceId?: string | null;
|
||||
asanaWorkspaceNome?: string | null;
|
||||
};
|
||||
|
||||
type SalvarConfiguracoesResponse = {
|
||||
data: ConfiguracaoPublica;
|
||||
};
|
||||
|
||||
class FechamentoConfiguracoesService {
|
||||
async getConfiguracoes(): Promise<ConfiguracaoPublica | null> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<GetConfiguracoesResponse>(`${baseUrl}/configuracoes`, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error: unknown) {
|
||||
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 { error?: { message?: string } } | undefined)?.error?.message ??
|
||||
error.message) ||
|
||||
"Erro ao carregar configurações.";
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error("Erro ao carregar configurações.");
|
||||
}
|
||||
}
|
||||
|
||||
async salvarConfiguracoes(body: SalvarConfiguracoesBody): Promise<ConfiguracaoPublica> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<SalvarConfiguracoesResponse>(
|
||||
`${baseUrl}/configuracoes`,
|
||||
body,
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error: unknown) {
|
||||
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 { error?: { message?: string } } | undefined)?.error?.message ??
|
||||
error.message) ||
|
||||
"Erro ao salvar configurações.";
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error("Erro ao salvar configurações.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoConfiguracoesService = new FechamentoConfiguracoesService();
|
||||
@@ -0,0 +1,9 @@
|
||||
export function resolveCommanderBaseUrl(): string {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL_COMMANDER?.trim();
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
"A variável VITE_API_BASE_URL_COMMANDER não está configurada. Defina, por exemplo, http://localhost:3333/api/",
|
||||
);
|
||||
}
|
||||
return raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type FechamentoTarefaItem = {
|
||||
id: string;
|
||||
fechamentoId: string;
|
||||
tipo: string;
|
||||
numeroTicket: string | null;
|
||||
descricao: string;
|
||||
cliente: string | null;
|
||||
linkAsana: string | null;
|
||||
tempoMinutos: number | null;
|
||||
pontuacao: number;
|
||||
pontuacaoOriginal: number;
|
||||
estaRevisada: boolean;
|
||||
dataConclusao: string | null;
|
||||
editadoEm: string | null;
|
||||
editadoPorId: string | null;
|
||||
criadoEm: string;
|
||||
};
|
||||
|
||||
type ListarTarefasResponse = {
|
||||
data: FechamentoTarefaItem[];
|
||||
};
|
||||
|
||||
type PatchTarefaResponse = {
|
||||
data: FechamentoTarefaItem;
|
||||
};
|
||||
|
||||
type CriarLancamentoResponse = {
|
||||
data: FechamentoTarefaItem;
|
||||
};
|
||||
|
||||
export type FechamentoFechadoResponse = {
|
||||
id: string;
|
||||
competenciaId: string;
|
||||
parceiroId: string;
|
||||
status: "fechado";
|
||||
versao: number;
|
||||
pontuacaoTotalEntregue: number;
|
||||
pontuacaoMeta: number;
|
||||
pontuacaoPaga: number;
|
||||
pontuacaoBanco: number;
|
||||
exportadoFinanceiro: boolean;
|
||||
fechadoPorId: string | null;
|
||||
fechadoEm: string | null;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
export type FechamentoReabertoResponse = {
|
||||
id: string;
|
||||
competenciaId: string;
|
||||
parceiroId: string;
|
||||
status: "em_aberto";
|
||||
versao: number;
|
||||
pontuacaoTotalEntregue: number;
|
||||
pontuacaoMeta: number;
|
||||
pontuacaoPaga: number;
|
||||
pontuacaoBanco: number;
|
||||
exportadoFinanceiro: boolean;
|
||||
fechadoPorId: string | null;
|
||||
fechadoEm: string | null;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
type ConcluirFechamentoResponse = {
|
||||
data: FechamentoFechadoResponse;
|
||||
};
|
||||
|
||||
type ReabrirFechamentoResponse = {
|
||||
data: FechamentoReabertoResponse;
|
||||
};
|
||||
|
||||
type ReprocessarAsanaResponse = {
|
||||
data: {
|
||||
fechamentoId: string;
|
||||
tarefasRecebidas: number;
|
||||
tarefasImportadas: number;
|
||||
tarefasIgnoradasSemParceiro: number;
|
||||
chamadasSubtarefasAsana: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ExportarPlanilhaResponse = {
|
||||
buffer: ArrayBuffer;
|
||||
filename: string | null;
|
||||
};
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
class FechamentoFechamentosService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async listarTarefas(fechamentoId: string): Promise<FechamentoTarefaItem[]> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ListarTarefasResponse>(`${baseUrl}/fechamentos/${fechamentoId}/tarefas`, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data ?? [];
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar tarefas do fechamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async patchTarefa(
|
||||
fechamentoId: string,
|
||||
tarefaId: string,
|
||||
input: {
|
||||
estaRevisada?: boolean;
|
||||
pontuacao?: number;
|
||||
numeroTicket?: string | null;
|
||||
descricao?: string;
|
||||
cliente?: string | null;
|
||||
tempoMinutos?: number | null;
|
||||
editadoPorId: string;
|
||||
},
|
||||
): Promise<FechamentoTarefaItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.patch<PatchTarefaResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
|
||||
{
|
||||
...(input.estaRevisada !== undefined ? { esta_revisada: input.estaRevisada } : {}),
|
||||
...(input.pontuacao !== undefined ? { pontuacao: input.pontuacao } : {}),
|
||||
...(input.numeroTicket !== undefined ? { numero_ticket: input.numeroTicket } : {}),
|
||||
...(input.descricao !== undefined ? { descricao: input.descricao } : {}),
|
||||
...(input.cliente !== undefined ? { cliente: input.cliente } : {}),
|
||||
...(input.tempoMinutos !== undefined ? { tempo_minutos: input.tempoMinutos } : {}),
|
||||
editado_por_id: input.editadoPorId,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao atualizar aprovação da tarefa.");
|
||||
}
|
||||
}
|
||||
|
||||
async criarLancamento(
|
||||
fechamentoId: string,
|
||||
input: { tipo: "bonus" | "desconto"; descricao: string; pontuacao: number },
|
||||
): Promise<FechamentoTarefaItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<CriarLancamentoResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/tarefas`,
|
||||
{
|
||||
tipo: input.tipo,
|
||||
descricao: input.descricao,
|
||||
pontuacao: input.pontuacao,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao criar lançamento no fechamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async excluirLancamento(fechamentoId: string, tarefaId: string): Promise<FechamentoTarefaItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.delete<PatchTarefaResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/tarefas/${tarefaId}`,
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao excluir lançamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async concluirFechamento(
|
||||
fechamentoId: string,
|
||||
input: { pontuacaoPaga: number; fechadoPorId?: string; motivoAjuste?: string },
|
||||
): Promise<FechamentoFechadoResponse> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<ConcluirFechamentoResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/concluir`,
|
||||
{
|
||||
pontuacao_paga: input.pontuacaoPaga,
|
||||
...(input.fechadoPorId ? { fechado_por_id: input.fechadoPorId } : {}),
|
||||
...(input.motivoAjuste?.trim() ? { motivo_ajuste: input.motivoAjuste.trim() } : {}),
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao concluir fechamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async exportarPlanilha(fechamentoId: string): Promise<ExportarPlanilhaResponse> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ArrayBuffer>(`${baseUrl}/fechamentos/${fechamentoId}/exportar`, {
|
||||
headers,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
return {
|
||||
buffer: response.data,
|
||||
filename: this.extractFilenameFromContentDisposition(response.headers["content-disposition"]),
|
||||
};
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao exportar planilha do fechamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async reabrirFechamento(
|
||||
fechamentoId: string,
|
||||
input: { reabertoPorId: string; motivo?: string },
|
||||
): Promise<FechamentoReabertoResponse> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<ReabrirFechamentoResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/reabrir`,
|
||||
{
|
||||
reaberto_por_id: input.reabertoPorId,
|
||||
...(input.motivo?.trim() ? { motivo: input.motivo.trim() } : {}),
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao reabrir fechamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async reprocessarAsana(fechamentoId: string): Promise<ReprocessarAsanaResponse["data"]> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<ReprocessarAsanaResponse>(
|
||||
`${baseUrl}/fechamentos/${fechamentoId}/reprocessar-asana`,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao reprocessar tarefas do Asana.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoFechamentosService = new FechamentoFechamentosService();
|
||||
@@ -0,0 +1,157 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type ParceiroTipoPessoa = "fisica" | "juridica";
|
||||
export type ParceiroStatusFiltro = "all" | "true" | "false";
|
||||
|
||||
export type ParceiroItem = {
|
||||
id: string;
|
||||
nome: string;
|
||||
codinome: string | null;
|
||||
tipoPessoa: ParceiroTipoPessoa;
|
||||
cpf: string | null;
|
||||
cnpj: string | null;
|
||||
email: string;
|
||||
whatsapp: string | null;
|
||||
asanaId: string | null;
|
||||
logoUrl: string | null;
|
||||
observacoes: string | null;
|
||||
pontuacaoMeta: number;
|
||||
estaAtivo: boolean;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
type MetaResponse = {
|
||||
total: number;
|
||||
paginaAtual: number;
|
||||
totalPaginas: number;
|
||||
};
|
||||
|
||||
export type ListarParceirosResponse = {
|
||||
data: ParceiroItem[];
|
||||
meta: MetaResponse;
|
||||
};
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListarParceirosParams = {
|
||||
busca?: string;
|
||||
estaAtivo?: ParceiroStatusFiltro;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export type SalvarParceiroPayload = {
|
||||
nome: string;
|
||||
codinome?: string | null;
|
||||
tipoPessoa: ParceiroTipoPessoa;
|
||||
cpf?: string | null;
|
||||
cnpj?: string | null;
|
||||
email: string;
|
||||
whatsapp?: string | null;
|
||||
asanaId?: string | null;
|
||||
logoUrl?: string | null;
|
||||
observacoes?: string | null;
|
||||
pontuacaoMeta: number;
|
||||
estaAtivo?: boolean;
|
||||
};
|
||||
|
||||
type SalvarParceiroResponse = {
|
||||
data: ParceiroItem;
|
||||
};
|
||||
|
||||
class FechamentoParceirosService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async listarParceiros(params: ListarParceirosParams): Promise<ListarParceirosResponse> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ListarParceirosResponse>(`${baseUrl}/parceiros`, {
|
||||
headers,
|
||||
params: {
|
||||
busca: params.busca || undefined,
|
||||
estaAtivo: params.estaAtivo ?? "all",
|
||||
page: params.page,
|
||||
perPage: params.perPage,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar parceiros.");
|
||||
}
|
||||
}
|
||||
|
||||
async listarParceirosAtivos(): Promise<ParceiroItem[]> {
|
||||
const response = await this.listarParceiros({
|
||||
estaAtivo: "true",
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
});
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
async criarParceiro(payload: SalvarParceiroPayload): Promise<ParceiroItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<SalvarParceiroResponse>(`${baseUrl}/parceiros`, payload, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao criar parceiro.");
|
||||
}
|
||||
}
|
||||
|
||||
async editarParceiro(id: string, payload: Partial<SalvarParceiroPayload>): Promise<ParceiroItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.patch<SalvarParceiroResponse>(`${baseUrl}/parceiros/${id}`, payload, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao editar parceiro.");
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAtivoParceiro(id: string): Promise<ParceiroItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.patch<SalvarParceiroResponse>(
|
||||
`${baseUrl}/parceiros/${id}/toggle-ativo`,
|
||||
{},
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao atualizar status do parceiro.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoParceirosService = new FechamentoParceirosService();
|
||||
@@ -0,0 +1,106 @@
|
||||
import axios from "axios";
|
||||
import { GlobalFunctions, TransferAreaProperties } from "@/GlobalFunctions";
|
||||
|
||||
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||
|
||||
export type UnidadeLookupRow = {
|
||||
id: string;
|
||||
nome: string;
|
||||
estabelecimentoId: string;
|
||||
};
|
||||
|
||||
const cacheByCodigo = new Map<string, UnidadeLookupRow>();
|
||||
|
||||
type UnidadesListResponse = {
|
||||
data: Array<{
|
||||
id: string;
|
||||
nome: string;
|
||||
estabelecimentoId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function buildLookupHeaders(): Promise<Record<string, string>> {
|
||||
const token = await GlobalFunctions.getToken();
|
||||
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { apikey: apiKey } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearCommanderUnidadeIdCache(): void {
|
||||
cacheByCodigo.clear();
|
||||
}
|
||||
|
||||
/** Código de estabelecimento vindo do Transfer (host), ou string vazia. */
|
||||
export function getEstabelecimentoCodigoFromTransfer(): string {
|
||||
const raw = GlobalFunctions.getTransferProperty(TransferAreaProperties.EstabelecimentoCodigo);
|
||||
const codigo =
|
||||
typeof raw === "string" ? raw.trim() : raw != null && String(raw).trim() !== "" ? String(raw).trim() : "";
|
||||
return codigo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consulta `GET /unidades?estabelecimentoId=…` sem lançar; retorna `null` se não houver linha.
|
||||
* Resultado é cacheado por código.
|
||||
*/
|
||||
export async function lookupUnidadeByEstabelecimento(codigo: string): Promise<UnidadeLookupRow | null> {
|
||||
const key = codigo.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = cacheByCodigo.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildLookupHeaders();
|
||||
const response = await axios.get<UnidadesListResponse>(`${baseUrl}/unidades`, {
|
||||
headers,
|
||||
params: {
|
||||
estabelecimentoId: key,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const row =
|
||||
response.data.data?.find((u) => u.estabelecimentoId === key) ?? response.data.data?.[0];
|
||||
if (!row?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapped: UnidadeLookupRow = {
|
||||
id: row.id,
|
||||
nome: row.nome,
|
||||
estabelecimentoId: row.estabelecimentoId,
|
||||
};
|
||||
cacheByCodigo.set(key, mapped);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve o UUID da unidade no Commander a partir do código de estabelecimento
|
||||
* disponibilizado pelo host (mesmo padrão de `audioGeneration.ts` / Codex).
|
||||
* Lança se o código estiver ausente ou não existir unidade cadastrada.
|
||||
*/
|
||||
export async function resolveCommanderUnidadeId(): Promise<string> {
|
||||
const codigo = getEstabelecimentoCodigoFromTransfer();
|
||||
if (!codigo) {
|
||||
throw new Error(
|
||||
"EstabelecimentoCodigo não disponível (TransferArea). Não foi possível resolver a unidade para o Commander.",
|
||||
);
|
||||
}
|
||||
|
||||
const row = await lookupUnidadeByEstabelecimento(codigo);
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
`Nenhuma unidade encontrada no Commander para o estabelecimento "${codigo}". Cadastre a unidade ou verifique o código.`,
|
||||
);
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import axios from "axios";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
import { buildCommanderHeaders } from "./commanderHttp";
|
||||
import { resolveCommanderBaseUrl } from "./fechamentoBaseUrl";
|
||||
import { clearCommanderUnidadeIdCache, type UnidadeLookupRow } from "./unidadeContext";
|
||||
|
||||
async function buildLookupHeaders(): Promise<Record<string, string>> {
|
||||
const token = await GlobalFunctions.getToken();
|
||||
const apiKey = import.meta.env.VITE_API_KEY || "";
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { apikey: apiKey } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getUsuarioEmailParaCommander(): string {
|
||||
const email = GlobalFunctions.getUsuarioLogado().email?.trim();
|
||||
if (!email) {
|
||||
throw new Error("E-mail do usuário logado não disponível para cadastro de unidade.");
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
export type CriarUnidadeInput = {
|
||||
nome: string;
|
||||
estabelecimentoId: string;
|
||||
};
|
||||
|
||||
export type AtualizarUnidadeInput = {
|
||||
nome?: string;
|
||||
estabelecimentoId?: string;
|
||||
};
|
||||
|
||||
type UnidadeResponse = { data: UnidadeLookupRow & { criadoEm: string } };
|
||||
|
||||
class FechamentoUnidadesService {
|
||||
/**
|
||||
* `POST /unidades` sem `X-Unidade-Id`. Exige `usuarioEmail` na query (validação admin no Commander).
|
||||
*/
|
||||
async createUnidade(input: CriarUnidadeInput): Promise<UnidadeLookupRow & { criadoEm: string }> {
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildLookupHeaders();
|
||||
const usuarioEmail = getUsuarioEmailParaCommander();
|
||||
const response = await axios.post<UnidadeResponse>(
|
||||
`${baseUrl}/unidades`,
|
||||
{
|
||||
nome: input.nome.trim(),
|
||||
estabelecimentoId: input.estabelecimentoId.trim(),
|
||||
},
|
||||
{
|
||||
headers,
|
||||
params: { usuarioEmail },
|
||||
},
|
||||
);
|
||||
clearCommanderUnidadeIdCache();
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async updateUnidade(
|
||||
unidadeId: string,
|
||||
input: AtualizarUnidadeInput,
|
||||
): Promise<UnidadeLookupRow & { criadoEm: string }> {
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const headers = await buildCommanderHeaders();
|
||||
const body: Record<string, string> = {};
|
||||
if (input.nome !== undefined) body.nome = input.nome.trim();
|
||||
if (input.estabelecimentoId !== undefined) body.estabelecimentoId = input.estabelecimentoId.trim();
|
||||
|
||||
const response = await axios.patch<UnidadeResponse>(`${baseUrl}/unidades/${unidadeId}`, body, {
|
||||
headers,
|
||||
});
|
||||
clearCommanderUnidadeIdCache();
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoUnidadesService = new FechamentoUnidadesService();
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PresignParceiroLogoResponse = {
|
||||
data: {
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
objectKey: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PresignParceiroLogoPayload = {
|
||||
fileName: string;
|
||||
contentType: "image/jpeg" | "image/png" | "image/webp";
|
||||
};
|
||||
|
||||
class FechamentoUploadsService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async presignUploadParceiroLogo(payload: PresignParceiroLogoPayload) {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<PresignParceiroLogoResponse>(
|
||||
`${baseUrl}/uploads/parceiros/logo/presign`,
|
||||
payload,
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao preparar upload da logo.");
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFileToSignedUrl(uploadUrl: string, file: File): Promise<void> {
|
||||
try {
|
||||
await axios.put(uploadUrl, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(error.message || "Falha ao enviar arquivo para armazenamento.");
|
||||
}
|
||||
throw new Error("Falha ao enviar arquivo para armazenamento.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoUploadsService = new FechamentoUploadsService();
|
||||
@@ -0,0 +1,132 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { buildCommanderHeaders, resolveCommanderBaseUrl } from "./commanderHttp";
|
||||
|
||||
export type UsuarioPapel = "admin" | "parceiro";
|
||||
export type UsuarioStatusFiltro = "all" | "true" | "false";
|
||||
|
||||
export type UsuarioItem = {
|
||||
id: string;
|
||||
parceiroId: string | null;
|
||||
nome: string | null;
|
||||
email: string | null;
|
||||
papel: UsuarioPapel;
|
||||
estaAtivo: boolean;
|
||||
criadoEm: string;
|
||||
atualizadoEm: string;
|
||||
};
|
||||
|
||||
type MetaResponse = {
|
||||
total: number;
|
||||
paginaAtual: number;
|
||||
totalPaginas: number;
|
||||
};
|
||||
|
||||
type ListarUsuariosResponse = {
|
||||
data: UsuarioItem[];
|
||||
meta: MetaResponse;
|
||||
};
|
||||
|
||||
type ApiErrorShape = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListarUsuariosParams = {
|
||||
busca?: string;
|
||||
papel?: UsuarioPapel;
|
||||
estaAtivo?: UsuarioStatusFiltro;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export type SalvarUsuarioPayload = {
|
||||
nome?: string | null;
|
||||
email?: string | null;
|
||||
papel: UsuarioPapel;
|
||||
parceiroId?: string | null;
|
||||
estaAtivo?: boolean;
|
||||
};
|
||||
|
||||
type SalvarUsuarioResponse = {
|
||||
data: UsuarioItem;
|
||||
};
|
||||
|
||||
class FechamentoUsuariosService {
|
||||
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 | undefined)?.error?.message ??
|
||||
error.message ??
|
||||
fallback) as string;
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(fallback);
|
||||
}
|
||||
|
||||
async listarUsuarios(params: ListarUsuariosParams): Promise<ListarUsuariosResponse> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.get<ListarUsuariosResponse>(`${baseUrl}/usuarios`, {
|
||||
headers,
|
||||
params: {
|
||||
busca: params.busca || undefined,
|
||||
papel: params.papel || undefined,
|
||||
estaAtivo: params.estaAtivo ?? "all",
|
||||
page: params.page,
|
||||
perPage: params.perPage,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao listar usuários.");
|
||||
}
|
||||
}
|
||||
|
||||
async criarUsuario(payload: SalvarUsuarioPayload): Promise<UsuarioItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.post<SalvarUsuarioResponse>(`${baseUrl}/usuarios`, payload, { headers });
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao criar usuário.");
|
||||
}
|
||||
}
|
||||
|
||||
async editarUsuario(id: string, payload: Partial<SalvarUsuarioPayload>): Promise<UsuarioItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.patch<SalvarUsuarioResponse>(`${baseUrl}/usuarios/${id}`, payload, {
|
||||
headers,
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao editar usuário.");
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAtivoUsuario(id: string): Promise<UsuarioItem> {
|
||||
try {
|
||||
const headers = await buildCommanderHeaders();
|
||||
const baseUrl = resolveCommanderBaseUrl();
|
||||
const response = await axios.patch<SalvarUsuarioResponse>(
|
||||
`${baseUrl}/usuarios/${id}/toggle-ativo`,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
this.handleAxiosError(error, "Erro ao atualizar status do usuário.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fechamentoUsuariosService = new FechamentoUsuariosService();
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_API_BASE_URL_COMMANDER: string
|
||||
readonly VITE_API_KEY: string
|
||||
readonly VITE_USER_EMAIL: string
|
||||
readonly VITE_ESTABELECIMENTO_ID: string
|
||||
|
||||
Reference in New Issue
Block a user