novas atualizacoes do sistema de fechamento

This commit is contained in:
Vitex Tecnologia
2026-04-27 16:50:27 -03:00
parent 6da067c641
commit 4c28862c5c
27 changed files with 3388 additions and 300 deletions
@@ -1,6 +1,14 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Download, ExternalLink, FileSpreadsheet, FolderKanban, RefreshCcw } from "lucide-react";
import {
ArrowLeft,
Download,
ExternalLink,
FileSpreadsheet,
FolderKanban,
Loader2,
RefreshCcw,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -14,8 +22,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoCompetenciasService, type FechamentoDaCompetenciaItem } from "@/services/fechamento/competencias";
import { fechamentoFechamentosService } from "@/services/fechamento/fechamentos";
@@ -35,14 +45,58 @@ function formatHoras(minutos: number | null): string {
return `${horas}h ${mins}min`;
}
function AsanaImportLoadingCard() {
return (
<div
className="flex flex-col items-center gap-6 py-10 text-center"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-20 w-20 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<Download className="relative h-8 w-8 text-primary" aria-hidden />
</div>
<div className="max-w-sm space-y-2">
<p className="text-lg font-semibold text-foreground">Sincronizando com o Asana</p>
<p className="text-sm text-muted-foreground">
Buscando tarefas concluídas no período da competência e montando os fechamentos. Pode levar um minuto
não feche esta página.
</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-2 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[85%] animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-2 w-[60%] animate-pulse rounded-full bg-muted" />
</div>
</div>
);
}
function reprocessamentoLabel(modo: "reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"): string {
if (modo === "reprocessar_tudo") return "Reimportando toda a competência a partir do Asana.";
if (modo === "reprocessar_alguns") return "Atualizando os parceiros selecionados no Asana.";
return "Buscando novos fechamentos no Asana.";
}
export default function CompetenciaFechamentos() {
const { me } = useAuthAccess();
const { id: competenciaId = "" } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [fechamentos, setFechamentos] = useState<FechamentoDaCompetenciaItem[]>([]);
const [importing, setImporting] = useState(false);
/** Onde a operação longa do Asana foi disparada (para mensagem e layout de loading). */
const [importKind, setImportKind] = useState<"sheet" | "modal" | null>(null);
const importing = importKind !== null;
const [exportingFechamentoId, setExportingFechamentoId] = useState<string | null>(null);
const [isReprocessModalOpen, setIsReprocessModalOpen] = useState(false);
const [isConcluirModalOpen, setIsConcluirModalOpen] = useState(false);
const [isReabrirModalOpen, setIsReabrirModalOpen] = useState(false);
const [concluindoCompetencia, setConcluindoCompetencia] = useState(false);
const [reabrindoCompetencia, setReabrindoCompetencia] = useState(false);
const [motivoReabertura, setMotivoReabertura] = useState("");
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
const [reprocessMode, setReprocessMode] = useState<
"reprocessar_tudo" | "reprocessar_alguns" | "buscar_novos_fechamentos"
>("reprocessar_tudo");
@@ -51,8 +105,13 @@ export default function CompetenciaFechamentos() {
const loadFechamentos = async () => {
try {
setLoading(true);
const data = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId);
const [data, competencias] = await Promise.all([
fechamentoCompetenciasService.listarFechamentosDaCompetencia(competenciaId),
fechamentoCompetenciasService.listarCompetencias({}),
]);
const competenciaAtual = competencias.find((item) => item.id === competenciaId);
setFechamentos(data);
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
} catch (error) {
const message =
error instanceof Error ? error.message : "Erro ao carregar fechamentos da competência.";
@@ -64,7 +123,7 @@ export default function CompetenciaFechamentos() {
};
const handleImportarAsana = async () => {
setImporting(true);
setImportKind("sheet");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId);
toast.success(
@@ -75,7 +134,7 @@ export default function CompetenciaFechamentos() {
const message = error instanceof Error ? error.message : "Erro ao importar tasks do Asana.";
toast.error(message);
} finally {
setImporting(false);
setImportKind(null);
}
};
@@ -85,7 +144,7 @@ export default function CompetenciaFechamentos() {
return;
}
setImporting(true);
setImportKind("modal");
try {
const resultado = await fechamentoCompetenciasService.importarDoAsana(competenciaId, {
modo: reprocessMode,
@@ -106,7 +165,46 @@ export default function CompetenciaFechamentos() {
const message = error instanceof Error ? error.message : "Erro ao executar reprocessamento do Asana.";
toast.error(message);
} finally {
setImporting(false);
setImportKind(null);
}
};
const handleConcluirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para concluir a competência.");
return;
}
try {
setConcluindoCompetencia(true);
await fechamentoCompetenciasService.concluirCompetencia(competenciaId, me.id);
toast.success("Competência concluída com sucesso.");
setIsConcluirModalOpen(false);
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir competência.";
toast.error(message);
} finally {
setConcluindoCompetencia(false);
}
};
const handleReabrirCompetencia = async () => {
if (!me?.id) {
toast.error("Não foi possível identificar o usuário para reabrir a competência.");
return;
}
try {
setReabrindoCompetencia(true);
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim() || undefined);
toast.success("Competência reaberta com sucesso.");
setIsReabrirModalOpen(false);
setMotivoReabertura("");
await loadFechamentos();
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao reabrir competência.";
toast.error(message);
} finally {
setReabrindoCompetencia(false);
}
};
@@ -150,40 +248,63 @@ export default function CompetenciaFechamentos() {
setSelectedParceiroIds(fechamentos.map((f) => f.parceiroId));
}, [fechamentos]);
const temFechamentos = fechamentos.length > 0;
return (
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border p-3 md:p-6">
<div className="mb-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento-hgtx")}>
<Button variant="ghost" size="sm" onClick={() => navigate("/fechamento")}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos da Competência
</h1>
{!loading && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">
{fechamentos.length} fechamento(s)
</p>
<Button
size="sm"
variant="secondary"
onClick={() => setIsReprocessModalOpen(true)}
disabled={importing}
>
<RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<FolderKanban className="h-5 w-5 md:h-6 md:w-6" />
Fechamentos da Competência
</h1>
{!loading && (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-muted-foreground">{fechamentos.length} fechamento(s)</p>
{competenciaStatus === "concluido" ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirModalOpen(true)}
disabled={reabrindoCompetencia}
>
{reabrindoCompetencia ? "Reabrindo..." : "Reabrir competência"}
</Button>
) : temFechamentos ? (
<>
<Button
size="sm"
variant="secondary"
onClick={() => setIsReprocessModalOpen(true)}
disabled={importing}
>
<RefreshCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setIsConcluirModalOpen(true)}
disabled={concluindoCompetencia}
>
{concluindoCompetencia ? "Concluindo..." : "Concluir competência"}
</Button>
</>
) : null}
</div>
)}
</div>
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && fechamentos.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl">
<Card className="mx-auto mt-12 max-w-2xl border-border/80 shadow-sm">
<CardHeader>
<CardTitle>Nenhum fechamento encontrado</CardTitle>
<CardDescription>
@@ -191,10 +312,14 @@ export default function CompetenciaFechamentos() {
</CardDescription>
</CardHeader>
<div className="px-6 pb-6">
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" />
{importing ? "Importando..." : "Importar tasks do Asana"}
</Button>
{importKind === "sheet" ? (
<AsanaImportLoadingCard />
) : (
<Button onClick={() => void handleImportarAsana()} disabled={importing}>
<Download className="mr-2 h-4 w-4" />
Importar tasks do Asana
</Button>
)}
</div>
</Card>
) : (
@@ -213,8 +338,18 @@ export default function CompetenciaFechamentos() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
Carregando fechamentos...
<TableCell colSpan={6} className="py-16">
<div
className="flex flex-col items-center justify-center gap-4 text-center"
role="status"
aria-live="polite"
>
<Loader2 className="h-9 w-9 animate-spin text-primary" aria-hidden />
<div>
<p className="font-medium text-foreground">Carregando fechamentos</p>
<p className="mt-1 text-sm text-muted-foreground">Aguarde um instante.</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
@@ -238,8 +373,8 @@ export default function CompetenciaFechamentos() {
<Badge
className={
row.status === "fechado"
? "bg-green-500 hover:bg-green-500/80 text-white border-green-500"
: "bg-yellow-500 hover:bg-yellow-500/80 text-black border-yellow-500"
? "border-emerald-600/30 bg-emerald-50 font-normal text-emerald-800 hover:bg-emerald-50 dark:bg-emerald-950/40 dark:text-emerald-200"
: "border-slate-500/25 bg-slate-100 font-normal text-slate-700 hover:bg-slate-100 dark:bg-slate-800/60 dark:text-slate-200"
}
>
{row.status === "fechado" ? "Fechado" : "Em aberto"}
@@ -250,6 +385,7 @@ export default function CompetenciaFechamentos() {
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() => void handleExportar(row.id)}
disabled={exportingFechamentoId === row.id || row.status !== "fechado"}
title="Exportar planilha financeira (XLSX)"
@@ -260,8 +396,9 @@ export default function CompetenciaFechamentos() {
<Button
size="sm"
variant="outline"
className="transition-colors hover:bg-muted/50 hover:text-foreground"
onClick={() =>
navigate(`/fechamento-hgtx/fechamentos/${row.id}`, {
navigate(`/fechamento/fechamentos/${row.id}`, {
state: { competenciaId, status: row.status },
})
}
@@ -280,17 +417,57 @@ export default function CompetenciaFechamentos() {
)}
</div>
<Dialog open={isReprocessModalOpen} onOpenChange={setIsReprocessModalOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Reprocessar Asana</DialogTitle>
<DialogDescription>
Escolha uma estratégia de reprocessamento para esta competência.
</DialogDescription>
</DialogHeader>
<Dialog
open={isReprocessModalOpen}
onOpenChange={(open) => {
if (!open && importing) return;
setIsReprocessModalOpen(open);
}}
>
<DialogContent
className="flex min-h-0 max-h-[85vh] w-[calc(100vw-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:w-full"
hideClose={importing}
onPointerDownOutside={(e) => {
if (importing) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (importing) e.preventDefault();
}}
>
{importKind === "modal" ? (
<div
className="absolute inset-0 z-[60] flex flex-col items-center justify-center gap-4 rounded-b-lg rounded-t-lg bg-background/90 p-6 text-center backdrop-blur-sm sm:rounded-lg"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="relative flex h-16 w-16 items-center justify-center">
<span className="absolute inset-0 rounded-full border-4 border-muted" />
<span className="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-primary" />
<RefreshCcw className="relative h-7 w-7 text-primary" aria-hidden />
</div>
<div className="max-w-md space-y-2">
<p className="text-lg font-semibold text-foreground">Processando no Asana</p>
<p className="text-sm text-muted-foreground">{reprocessamentoLabel(reprocessMode)}</p>
<p className="text-xs text-muted-foreground">Não feche esta janela até a operação terminar.</p>
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="h-1.5 animate-pulse rounded-full bg-muted" />
<div className="mx-auto h-1.5 w-[75%] animate-pulse rounded-full bg-muted" />
</div>
</div>
) : null}
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<div className="flex min-h-0 flex-1 flex-col gap-4 px-6 pb-0 pt-12 sm:pt-14">
<DialogHeader className="shrink-0 space-y-1.5 pr-8 text-left sm:pr-0">
<DialogTitle>Reprocessar Asana</DialogTitle>
<DialogDescription>
Escolha uma estratégia de reprocessamento para esta competência.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pb-4 pr-1">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => setReprocessMode("reprocessar_tudo")}
@@ -389,14 +566,95 @@ export default function CompetenciaFechamentos() {
})}
</div>
) : null}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsReprocessModalOpen(false)} disabled={importing}>
<DialogFooter className="shrink-0 gap-2 border-t bg-background p-4 sm:justify-end">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReprocessModalOpen(false)}
disabled={importing}
>
Cancelar
</Button>
<Button onClick={() => void handleExecutarReprocessamento()} disabled={importing}>
{importing ? "Processando..." : "Executar"}
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
Processando...
</>
) : (
"Executar"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReabrirModalOpen} onOpenChange={setIsReabrirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reabrir competência</DialogTitle>
<DialogDescription>
Ao reabrir, o reprocessamento do Asana e as edições dos fechamentos voltam a ficar disponíveis.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabrir-competencia">Motivo (opcional)</Label>
<Input
id="motivo-reabrir-competencia"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: correção de ajustes pós-fechamento"
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReabrirModalOpen(false)}
disabled={reabrindoCompetencia}
>
Cancelar
</Button>
<Button onClick={() => void handleReabrirCompetencia()} disabled={reabrindoCompetencia}>
{reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isConcluirModalOpen} onOpenChange={setIsConcluirModalOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Concluir competência</DialogTitle>
<DialogDescription>
Deseja concluir esta competência? A operação será permitida se todos os fechamentos estiverem fechados.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsConcluirModalOpen(false)}
disabled={concluindoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleConcluirCompetencia()}
disabled={concluindoCompetencia}
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
>
{concluindoCompetencia ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Confirmar conclusão"
)}
</Button>
</DialogFooter>
</DialogContent>