atualizacoes modulo fechamento

This commit is contained in:
Vitex Tecnologia
2026-04-26 22:54:56 -03:00
parent 7a01c6f97b
commit 6da067c641
29 changed files with 5973 additions and 3 deletions
@@ -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>
);
}