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,6 @@
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 { ArrowLeft, CheckCircle2, Clock3, ExternalLink, 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";
@@ -49,9 +49,44 @@ function parsePontuacaoInput(value: string): number {
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
/** Pontuação do modal de lançamento: só inteiros ≥ 0 (o tipo bonus/desconto define o sinal no backend). */
function sanitizePontuacaoLancamentoDigitando(raw: string): string {
const t = raw.trim();
if (t === "") return "";
const n = parsePontuacaoInput(t);
if (!Number.isFinite(n) || n < 0) return "0";
return String(Math.trunc(Math.min(n, Number.MAX_SAFE_INTEGER)));
}
function toPontuacaoInput(value: number): string {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
return String(rounded);
}
function formatDateOnly(value: string | null): string {
if (!value) return "—";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "—";
return parsed.toLocaleDateString("pt-BR");
}
function formatDateTime(value: string | null): string {
if (!value) return "—";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "—";
return parsed.toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
type FechamentoDetalhesLocationState = {
competenciaId?: string;
status?: "em_aberto" | "fechado";
readonlyView?: boolean;
};
export default function FechamentoDetalhes() {
@@ -64,7 +99,8 @@ export default function FechamentoDetalhes() {
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 [estadoRevisaoInicial, setEstadoRevisaoInicial] = useState<Record<string, boolean>>({});
const [bulkUpdatingRevisao, setBulkUpdatingRevisao] = useState(false);
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
const [isLancamentoOpen, setIsLancamentoOpen] = useState(false);
const [savingLancamento, setSavingLancamento] = useState(false);
@@ -91,7 +127,10 @@ export default function FechamentoDetalhes() {
const [edicaoCliente, setEdicaoCliente] = useState("");
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
const [competenciaStatus, setCompetenciaStatus] = useState<"em_aberto" | "concluido">("em_aberto");
const isFechado = fechamentoStatus === "fechado";
const isCompetenciaConcluida = competenciaStatus === "concluido";
const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida;
const totais = useMemo(() => {
const aprovadas = tarefas.filter((t) => t.estaRevisada);
@@ -108,14 +147,26 @@ export default function FechamentoDetalhes() {
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 pontuacaoTotalLabel = toPontuacaoInput(totais.pontos);
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
const todasTarefasRevisadas = tarefas.length > 0 && tarefas.every((tarefa) => tarefa.estaRevisada);
const loadTarefas = async () => {
try {
setLoading(true);
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
setTarefas(data);
const tarefasOrdenadas = [...data].sort((a, b) => {
const timeA = a.dataConclusao ? new Date(a.dataConclusao).getTime() : Number.POSITIVE_INFINITY;
const timeB = b.dataConclusao ? new Date(b.dataConclusao).getTime() : Number.POSITIVE_INFINITY;
return timeA - timeB;
});
const estadoInicialBanco = Object.fromEntries(tarefasOrdenadas.map((tarefa) => [tarefa.id, Boolean(tarefa.estaRevisada)]));
const tarefasDefaultRevisadas = tarefasOrdenadas.map((tarefa) => ({
...tarefa,
estaRevisada: true,
}));
setTarefas(tarefasDefaultRevisadas);
setEstadoRevisaoInicial(estadoInicialBanco);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
toast.error(message);
@@ -128,11 +179,16 @@ export default function FechamentoDetalhes() {
const loadFechamentoStatus = async (currentCompetenciaId: string) => {
if (!currentCompetenciaId) return;
try {
const rows = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
const [rows, competencias] = await Promise.all([
fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId),
fechamentoCompetenciasService.listarCompetencias({}),
]);
const current = rows.find((row) => row.id === fechamentoId);
const competenciaAtual = competencias.find((item) => item.id === currentCompetenciaId);
if (!current) return;
setFechamentoStatus(current.status);
setPontuacaoMeta(current.pontuacaoMeta);
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
if (!competenciaId) {
setCompetenciaId(current.competenciaId);
}
@@ -145,6 +201,10 @@ export default function FechamentoDetalhes() {
toast.error("Fechamento está fechado. Reabra para editar.");
};
const toastBloqueioCompetenciaConcluida = () => {
toast.error("Competência concluída. Reabra a competência para editar.");
};
useEffect(() => {
if (fechamentoId) {
void loadTarefas();
@@ -165,12 +225,14 @@ export default function FechamentoDetalhes() {
}, [isFechado]);
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
}
const previous = tarefas;
setTogglingTaskId(tarefa.id);
setTarefas((prev) =>
prev.map((item) =>
item.id === tarefa.id
@@ -181,21 +243,48 @@ export default function FechamentoDetalhes() {
: 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 handleToggleTodasAprovadas = async () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
}
const novoValor = !todasTarefasRevisadas;
const tarefasParaAtualizar = tarefas.filter((tarefa) => tarefa.estaRevisada !== novoValor);
if (tarefasParaAtualizar.length === 0) return;
setBulkUpdatingRevisao(true);
setTarefas((prev) => prev.map((tarefa) => ({ ...tarefa, estaRevisada: novoValor })));
setBulkUpdatingRevisao(false);
};
const persistirRevisoesPendentes = async () => {
if (!me?.id) {
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
}
const tarefasAlteradas = tarefas.filter(
(tarefa) => estadoRevisaoInicial[tarefa.id] !== undefined && estadoRevisaoInicial[tarefa.id] !== tarefa.estaRevisada,
);
if (tarefasAlteradas.length === 0) return;
await Promise.all(
tarefasAlteradas.map((tarefa) =>
fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
estaRevisada: tarefa.estaRevisada,
editadoPorId: me.id,
}),
),
);
setEstadoRevisaoInicial((prev) => {
const next = { ...prev };
for (const tarefa of tarefasAlteradas) {
next[tarefa.id] = tarefa.estaRevisada;
}
return next;
});
};
const resetLancamentoForm = () => {
@@ -205,12 +294,16 @@ export default function FechamentoDetalhes() {
};
const handleSalvarLancamento = async () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
}
const descricao = lancamentoDescricao.trim();
const pontuacao = Number(lancamentoPontuacao);
const pontuacao = Math.trunc(Number(lancamentoPontuacao));
if (!descricao) {
toast.error("Informe a descrição do lançamento.");
return;
@@ -240,6 +333,10 @@ export default function FechamentoDetalhes() {
};
const handleAbrirConcluir = () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
@@ -250,6 +347,10 @@ export default function FechamentoDetalhes() {
};
const handleConcluirFechamento = async () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
@@ -269,13 +370,14 @@ export default function FechamentoDetalhes() {
}
try {
setConcluindo(true);
await persistirRevisoesPendentes();
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}`);
navigate(`/fechamento/competencias/${data.competenciaId}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
toast.error(message);
@@ -321,6 +423,10 @@ export default function FechamentoDetalhes() {
};
const handleExcluirLancamento = (tarefa: FechamentoTarefaItem) => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
@@ -349,6 +455,10 @@ export default function FechamentoDetalhes() {
};
const openEditarTarefa = (tarefa: FechamentoTarefaItem) => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
return;
}
if (isFechado) {
toastBloqueioFechado();
return;
@@ -446,7 +556,7 @@ export default function FechamentoDetalhes() {
<div className="flex h-full flex-col bg-background pb-16 md:pb-0">
<div className="border-b border-border bg-muted/20 p-3 md:p-6">
<div className="mb-2">
<Button variant="ghost" size="sm" onClick={() => navigate(-1)}>
<Button variant="ghost" size="sm" className="hover:bg-muted/60 hover:text-foreground" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
@@ -463,24 +573,25 @@ export default function FechamentoDetalhes() {
{!loading ? (
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<Badge variant={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
<Badge variant={isReadonly ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
{isCompetenciaConcluida ? <Badge variant="secondary">Competência concluída</Badge> : null}
<Button
size="sm"
variant="outline"
onClick={() => setIsLancamentoOpen(true)}
disabled={isFechado}
className="min-w-[152px]"
disabled={isReadonly}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<Plus className="mr-2 h-4 w-4" />
Fazer lançamento
</Button>
{!isFechado ? (
{!isReadonly ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReprocessAsanaOpen(true)}
disabled={reprocessandoAsana}
className="min-w-[152px]"
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
@@ -491,7 +602,7 @@ export default function FechamentoDetalhes() {
size="sm"
variant="outline"
onClick={() => setIsReabrirOpen(true)}
disabled={reabrindo}
disabled={reabrindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
@@ -502,7 +613,7 @@ export default function FechamentoDetalhes() {
size="sm"
variant="secondary"
onClick={handleAbrirConcluir}
disabled={tarefas.length === 0 || concluindo}
disabled={tarefas.length === 0 || concluindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
@@ -515,6 +626,11 @@ export default function FechamentoDetalhes() {
{!loading ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{isCompetenciaConcluida ? (
<div className="sm:col-span-2 xl:col-span-4 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Competência concluída. Reabra a competência para editar este fechamento.
</div>
) : null}
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
<CardHeader className="space-y-2 p-4">
<div className="flex items-start justify-between">
@@ -564,7 +680,26 @@ export default function FechamentoDetalhes() {
</CardHeader>
</Card>
) : (
<div className="overflow-x-auto rounded-lg border">
<div className="space-y-3">
{!loading ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleToggleTodasAprovadas()}
disabled={isReadonly || bulkUpdatingRevisao}
>
{bulkUpdatingRevisao ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{todasTarefasRevisadas ? "Desmarcar todas" : "Marcar todas"}
</Button>
</div>
) : null}
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
@@ -573,6 +708,10 @@ export default function FechamentoDetalhes() {
<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-[130px]">Data início</TableHead>
<TableHead className="min-w-[140px]">Data vencimento</TableHead>
<TableHead className="min-w-[170px]">Data conclusão</TableHead>
<TableHead className="min-w-[240px]">Etiquetas</TableHead>
<TableHead className="min-w-[120px]">Horas</TableHead>
<TableHead className="min-w-[120px]">Pontuação</TableHead>
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
@@ -581,7 +720,7 @@ export default function FechamentoDetalhes() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
<TableCell colSpan={12} className="py-8 text-center text-muted-foreground">
Carregando tarefas...
</TableCell>
</TableRow>
@@ -593,25 +732,48 @@ export default function FechamentoDetalhes() {
<Checkbox
checked={tarefa.estaRevisada}
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
disabled={togglingTaskId === tarefa.id || isFechado}
disabled={isReadonly || bulkUpdatingRevisao}
/>
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</div>
</TableCell>
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
<TableCell>{tarefa.cliente || "—"}</TableCell>
<TableCell>{tarefa.tipo}</TableCell>
<TableCell>{formatDateOnly(tarefa.dataInicio)}</TableCell>
<TableCell>{formatDateOnly(tarefa.dataVencimento)}</TableCell>
<TableCell>{formatDateTime(tarefa.dataConclusao)}</TableCell>
<TableCell>
{tarefa.etiquetas && tarefa.etiquetas.length > 0 ? (
<div className="flex flex-wrap gap-1">
{tarefa.etiquetas.map((etiqueta) => (
<Badge key={`${tarefa.id}-${etiqueta.gid}`} variant="outline" className="font-normal">
{etiqueta.name}
</Badge>
))}
</div>
) : (
"—"
)}
</TableCell>
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{tarefa.linkAsana ? (
<Button type="button" size="sm" variant="ghost" asChild>
<a href={tarefa.linkAsana} target="_blank" rel="noopener noreferrer" title="Abrir tarefa no Asana">
<ExternalLink className="h-4 w-4" />
<span className="ml-1">Abrir</span>
</a>
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => openEditarTarefa(tarefa)}
disabled={isFechado}
disabled={isReadonly}
>
<Pencil className="h-4 w-4" />
<span className="ml-1">Editar</span>
@@ -623,7 +785,7 @@ export default function FechamentoDetalhes() {
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => void handleExcluirLancamento(tarefa)}
disabled={isFechado || deletingTaskId === tarefa.id}
disabled={isReadonly || deletingTaskId === tarefa.id}
>
{deletingTaskId === tarefa.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -641,6 +803,7 @@ export default function FechamentoDetalhes() {
</TableBody>
</Table>
</div>
</div>
)}
</div>
@@ -675,10 +838,16 @@ export default function FechamentoDetalhes() {
<Input
id="lancamento-pontuacao"
type="number"
min={1}
inputMode="numeric"
min={0}
step={1}
value={lancamentoPontuacao}
onChange={(e) => setLancamentoPontuacao(e.target.value)}
onKeyDown={(e) => {
if (e.key === "-" || e.key === "+" || e.key === "e" || e.key === "E" || e.key === "," || e.key === ".") {
e.preventDefault();
}
}}
onChange={(e) => setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))}
/>
</div>
@@ -696,6 +865,7 @@ export default function FechamentoDetalhes() {
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setIsLancamentoOpen(false);
resetLancamentoForm();
@@ -704,7 +874,7 @@ export default function FechamentoDetalhes() {
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}>
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
</Button>
</DialogFooter>
@@ -799,11 +969,27 @@ export default function FechamentoDetalhes() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsConcluirOpen(false)}
disabled={concluindo}
>
Cancelar
</Button>
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
<Button
onClick={() => void handleConcluirFechamento()}
disabled={concluindo || isReadonly}
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
>
{concluindo ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Confirmar conclusão"
)}
</Button>
</DialogFooter>
</DialogContent>
@@ -827,7 +1013,12 @@ export default function FechamentoDetalhes() {
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReabrirOpen(false)}
disabled={reabrindo}
>
Cancelar
</Button>
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
@@ -849,6 +1040,7 @@ export default function FechamentoDetalhes() {
<DialogFooter className="gap-2">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setIsDeleteDialogOpen(false);
setDeletingTarefa(null);
@@ -880,13 +1072,26 @@ export default function FechamentoDetalhes() {
<DialogFooter className="gap-2">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReprocessAsanaOpen(false)}
disabled={reprocessandoAsana}
>
Cancelar
</Button>
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
{reprocessandoAsana ? "Reprocessando..." : "Confirmar reprocessamento"}
<Button
onClick={() => void handleReprocessarAsana()}
disabled={reprocessandoAsana}
variant="secondary"
className="shadow-md hover:shadow-secondary/40"
>
{reprocessandoAsana ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Reprocessando...
</>
) : (
"Confirmar reprocessamento"
)}
</Button>
</DialogFooter>
</DialogContent>
@@ -948,10 +1153,15 @@ export default function FechamentoDetalhes() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsEditarOpen(false)}
disabled={savingEdicao}
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}>
{savingEdicao ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>