atualizacoes modulo fechamento
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user