import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { AlertCircle, ArrowLeft, CheckCircle2, ExternalLink, FileText, ListChecks, Loader2, Pencil, Plus, Repeat, RotateCcw, Target, Trash2, TrendingUp, User, Wallet, } from "lucide-react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { useAuthAccess } from "@/contexts/AuthAccessContext"; import { fechamentoBancoPontosService } from "@/services/fechamento/bancoPontos"; import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias"; import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos"; function formatPontos(valor: number): string { return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 }); } /** Ex.: toast após concluir — vírgula pt-BR e no máximo 2 casas decimais. */ function formatPontosAte2Casas(valor: number): string { if (!Number.isFinite(valor)) return "—"; return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } /** Alinhado ao `requerMotivoAjuste`: evita "-0" e cor errada por ruído de float. */ const SALDO_PONTOS_EPS = 0.0001; function corSaldoPontos(valor: number): string { if (!Number.isFinite(valor)) return "text-foreground"; if (valor > SALDO_PONTOS_EPS) return "text-emerald-600"; if (valor < -SALDO_PONTOS_EPS) return "text-red-600"; return "text-foreground"; } function formatPontosSaldoModal(valor: number): string { if (!Number.isFinite(valor)) return "—"; const v = Math.abs(valor) < SALDO_PONTOS_EPS ? 0 : valor; return formatPontos(v); } function parsePontuacaoInput(value: string): number { const normalized = value.trim().replace(/\s/g, "").replace(",", "."); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : Number.NaN; } const PONTUACAO_LANCAMENTO_MAX_FRAC = 2; /** Pontuação do modal de lançamento: ≥ 0, até duas casas decimais (o tipo bonus/desconto define o sinal no backend). */ function sanitizePontuacaoLancamentoDigitando(raw: string): string { const t = raw.trim().replace(/\s/g, ""); if (t === "") return ""; if (t === "," || t === ".") return "0" + t; const c = t.indexOf(","); const d = t.indexOf("."); const si = c >= 0 && d >= 0 ? Math.min(c, d) : c >= 0 ? c : d >= 0 ? d : -1; const sep = si >= 0 ? t[si]! : null; if (sep == null) { const digits = t.replace(/\D/g, ""); if (digits === "") return ""; const n = Number(digits); if (n < 0) return "0"; return digits; } const intStr = t.slice(0, si).replace(/\D/g, ""); const rawAfter = t.slice(si + 1); const fracStr = rawAfter.replace(/\D/g, "").slice(0, PONTUACAO_LANCAMENTO_MAX_FRAC); const intPart = intStr === "" ? "0" : intStr; if (t.endsWith(sep) && fracStr.length === 0) { return intPart + sep; } return intPart + sep + fracStr; } /** Valor em R$ no modal de lançamento: dígitos + um separador decimal, até 2 casas (pt-BR). */ function sanitizeValorRealLancamentoDigitando(raw: string): string { const cleaned = raw.replace(/[^\d.,]/g, ""); if (!cleaned) return ""; let int = ""; let frac = ""; let sawSeparator = false; for (const ch of cleaned) { if (ch === "," || ch === ".") { if (!sawSeparator) sawSeparator = true; continue; } if (!sawSeparator) int += ch; else if (frac.length < 2) frac += ch; } if (!sawSeparator) return int; return frac.length > 0 ? `${int},${frac}` : `${int},`; } function parseValorRealMonetarioInput(raw: string): number { let t = raw.trim().replace(/\s/g, "").replace(/R\$\s?/gi, ""); if (!t) return Number.NaN; if (t.includes(",") && t.includes(".")) { t = t.replace(/\./g, "").replace(",", "."); } else if (t.includes(",")) { t = t.replace(",", "."); } return Number(t); } /** Pontuação: no máximo 2 casas decimais, sempre para cima (ex.: 1,1111 → 1,12). Valores já em centésimos exatos (ex.: 1,00) não são inflados por erro de float. */ function pontuacaoParaCimaAte2Casas(value: number): number { if (!Number.isFinite(value)) return value; if (value === 0) return 0; const sign = value < 0 ? -1 : 1; const m = Math.abs(value); const scaled = m * 100; const nearest = Math.round(scaled); const n = Math.abs(scaled - nearest) < 1e-7 ? nearest : Math.ceil(scaled - 1e-9); return sign * (n / 100); } function toPontuacaoInput(value: number): string { if (!Number.isFinite(value)) return "0"; const rounded = pontuacaoParaCimaAte2Casas(value); 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", }); } function normalizeTextFilter(value: string): string { return value.trim().toLocaleLowerCase("pt-BR"); } function toFilterDate(value: string | null): string { if (!value) return ""; const isoMatch = value.match(/^\d{4}-\d{2}-\d{2}/); if (isoMatch) return isoMatch[0]; const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return ""; const year = parsed.getFullYear(); const month = String(parsed.getMonth() + 1).padStart(2, "0"); const day = String(parsed.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function formatCompetenciaMesAno(item: Pick): string { return `${String(item.mes).padStart(2, "0")}/${item.ano}`; } function getTipoLancamentoLabel(tipo: string): string { if (tipo === "bonus") return "Bônus Comercial"; if (tipo === "desconto") return "Desconto Comercial"; return tipo; } type FechamentoDetalhesLocationState = { competenciaId?: string; status?: "em_aberto" | "fechado"; readonlyView?: boolean; }; export default function FechamentoDetalhes() { const location = useLocation(); const navigate = useNavigate(); const { me, papel } = 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([]); const [estadoRevisaoInicial, setEstadoRevisaoInicial] = useState>({}); const [bulkUpdatingRevisao, setBulkUpdatingRevisao] = useState(false); const [deletingTaskId, setDeletingTaskId] = useState(null); const [isLancamentoOpen, setIsLancamentoOpen] = useState(false); const [savingLancamento, setSavingLancamento] = useState(false); const [isConcluirOpen, setIsConcluirOpen] = useState(false); const [concluindo, setConcluindo] = useState(false); const [exportandoPdf, setExportandoPdf] = 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 [lancamentoModo, setLancamentoModo] = useState<"pontos" | "real">("pontos"); const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0"); const [lancamentoValorReal, setLancamentoValorReal] = useState(""); const [pontuacaoMeta, setPontuacaoMeta] = useState(null); const [parceiroId, setParceiroId] = useState(null); const [parceiroNome, setParceiroNome] = useState(null); const [parceiroFator, setParceiroFator] = useState(null); const [saldoBancoAtual, setSaldoBancoAtual] = useState(null); const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false); const [erroSaldoBanco, setErroSaldoBanco] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingTarefa, setDeletingTarefa] = useState(null); const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false); const [reprocessandoAsana, setReprocessandoAsana] = useState(false); const [isAlterarCompetenciaOpen, setIsAlterarCompetenciaOpen] = useState(false); const [loadingCompetenciasAbertas, setLoadingCompetenciasAbertas] = useState(false); const [savingAlteracaoCompetencia, setSavingAlteracaoCompetencia] = useState(false); const [competenciasAbertas, setCompetenciasAbertas] = useState([]); const [tarefaAlteracaoCompetencia, setTarefaAlteracaoCompetencia] = useState(null); const [competenciaDestinoId, setCompetenciaDestinoId] = useState(""); const [motivoAlteracaoCompetencia, setMotivoAlteracaoCompetencia] = useState(""); const [isEditarOpen, setIsEditarOpen] = useState(false); const [savingEdicao, setSavingEdicao] = useState(false); const [editingTarefa, setEditingTarefa] = useState(null); const [edicaoNumeroTicket, setEdicaoNumeroTicket] = useState(""); const [edicaoDescricao, setEdicaoDescricao] = useState(""); const [edicaoCliente, setEdicaoCliente] = useState(""); const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState(""); const [edicaoPontuacao, setEdicaoPontuacao] = useState(""); const tableScrollRef = useRef(null); const stickyScrollRef = useRef(null); const stickyScrollSpacerRef = useRef(null); const [showStickyScrollbar, setShowStickyScrollbar] = useState(false); const [showFilters, setShowFilters] = useState(false); const [filtroDescricao, setFiltroDescricao] = useState(""); const [filtroTicket, setFiltroTicket] = useState(""); const [filtroCliente, setFiltroCliente] = useState(""); const [filtroDataConclusao, setFiltroDataConclusao] = 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 caps = me?.capabilities; const podeTarefaEditarRevisao = caps?.fechamentos.tarefaEditarRevisao ?? false; const podeTarefaLancarAjuste = caps?.fechamentos.tarefaLancarAjuste ?? false; const verFatorParceiro = caps?.parceiros.verFator ?? false; const fatorParceiroValidoParaReal = parceiroFator != null && Number.isFinite(parceiroFator) && parceiroFator > 0; /** Quem vê o fator na API sabe se está cadastrado; sem fator não dá para lançar em Real no front. */ const lancamentoEmRealBloqueado = verFatorParceiro && !fatorParceiroValidoParaReal; const podeReprocessarAsana = caps?.fechamentos.reprocessarAsana ?? false; const podeConcluirFechamento = caps?.fechamentos.concluir ?? false; const podeReabrirFechamento = caps?.fechamentos.reabrir ?? false; const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false; const supervisorPodeExportarPdf = podeExportarPdf && (papel !== "supervisor" || Boolean(me?.parceiroId && parceiroId != null && parceiroId === me.parceiroId)); const podeAlterarCompetenciaTarefa = caps?.fechamentos.tarefaAlterarCompetencia ?? false; const podeExcluirLancamentoManual = caps?.fechamentos.tarefaExcluirManual ?? false; const bloqueioEdicaoTarefa = isReadonly || !podeTarefaEditarRevisao; const lancamentoPreviewPontos = useMemo(() => { if (lancamentoModo !== "real" || parceiroFator == null || !(parceiroFator > 0)) return null; const valor = parseValorRealMonetarioInput(lancamentoValorReal.trim()); if (!Number.isFinite(valor) || valor <= 0) return null; return pontuacaoParaCimaAte2Casas(valor / (parceiroFator * 3)); }, [lancamentoModo, parceiroFator, lancamentoValorReal]); const totais = useMemo(() => { const aprovadas = tarefas.filter((t) => t.estaRevisada); const pontos = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0); return { pontos, 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 = toPontuacaoInput(totais.pontos); const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel; const saldoBancoPosFechamento = saldoBancoAtual != null ? saldoBancoAtual + bancoCalculado : null; const dividaParaQuitar = saldoBancoAtual != null && saldoBancoAtual < 0 ? -saldoBancoAtual : 0; const saldoPositivoDisponivel = saldoBancoAtual != null && saldoBancoAtual > 0 ? saldoBancoAtual : 0; /** Zera o banco: saldo + (aprovados − pago) = 0 ⇒ pago = aprovados + saldo (saldo negativo = dívida). */ const sugestaoQuitarDivida = totais.pontos - dividaParaQuitar; /** Zera o banco com saldo positivo: precisa movimento negativo no banco ⇒ pago = aprovados + saldo. */ const sugestaoUsarSaldoPositivo = totais.pontos + saldoPositivoDisponivel; const todasTarefasRevisadas = tarefas.length > 0 && tarefas.every((tarefa) => tarefa.estaRevisada); const competenciasDestino = useMemo( () => competenciasAbertas.filter((item) => item.id !== competenciaId), [competenciasAbertas, competenciaId], ); const hasActiveFilters = Boolean( filtroDescricao || filtroTicket || filtroCliente || filtroDataConclusao, ); const tarefasFiltradas = useMemo(() => { const descricaoFilter = normalizeTextFilter(filtroDescricao); const ticketFilter = normalizeTextFilter(filtroTicket); const clienteFilter = normalizeTextFilter(filtroCliente); return tarefas.filter((tarefa) => { if (descricaoFilter && !normalizeTextFilter(tarefa.descricao ?? "").includes(descricaoFilter)) return false; if (ticketFilter && !normalizeTextFilter(tarefa.numeroTicket ?? "").includes(ticketFilter)) return false; if (clienteFilter && !normalizeTextFilter(tarefa.cliente ?? "").includes(clienteFilter)) return false; if (filtroDataConclusao && toFilterDate(tarefa.dataConclusao) !== filtroDataConclusao) return false; return true; }); }, [ tarefas, filtroDescricao, filtroTicket, filtroCliente, filtroDataConclusao, ]); const loadTarefas = async () => { try { setLoading(true); const data = await fechamentoFechamentosService.listarTarefas(fechamentoId); 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); setTarefas([]); } finally { setLoading(false); } }; const loadFechamentoStatus = async (currentCompetenciaId: string) => { if (!currentCompetenciaId) return; try { 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"); setParceiroId(current.parceiroId); setParceiroNome(current.parceiroNome); const f = current.parceiroFator; setParceiroFator( f !== undefined && f !== null && Number.isFinite(Number(f)) && Number(f) > 0 ? Number(f) : null, ); if (!competenciaId) { setCompetenciaId(current.competenciaId); } } catch { // mantém status atual em caso de erro para evitar bloquear navegação } }; const loadSaldoBanco = async (id: string) => { setLoadingSaldoBanco(true); setErroSaldoBanco(false); try { const response = await fechamentoBancoPontosService.listarExtrato(id, { page: 1, perPage: 1 }); setSaldoBancoAtual(response.parceiro?.saldo ?? 0); } catch { setErroSaldoBanco(true); setSaldoBancoAtual(null); } finally { setLoadingSaldoBanco(false); } }; const toastBloqueioFechado = () => { 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(); } }, [fechamentoId]); useEffect(() => { if (competenciaId) { void loadFechamentoStatus(competenciaId); } }, [competenciaId, fechamentoId]); useEffect(() => { if (isFechado) { setIsConcluirOpen(false); setIsLancamentoOpen(false); } }, [isFechado]); useEffect(() => { if (!isLancamentoOpen || !lancamentoEmRealBloqueado) return; setLancamentoModo("pontos"); }, [isLancamentoOpen, lancamentoEmRealBloqueado]); useEffect(() => { if (isConcluirOpen && parceiroId) { void loadSaldoBanco(parceiroId); } if (!isConcluirOpen) { setSaldoBancoAtual(null); setErroSaldoBanco(false); } }, [isConcluirOpen, parceiroId]); useEffect(() => { const main = tableScrollRef.current; const sticky = stickyScrollRef.current; if (!main || !sticky) return; let syncingFromMain = false; let syncingFromSticky = false; const syncMetricsAndVisibility = () => { const spacer = stickyScrollSpacerRef.current; const canScrollX = main.scrollWidth > main.clientWidth + 1; if (spacer) spacer.style.width = `${main.scrollWidth}px`; if (sticky.scrollLeft !== main.scrollLeft) sticky.scrollLeft = main.scrollLeft; setShowStickyScrollbar(canScrollX); }; const onMainScroll = () => { if (syncingFromSticky) return; syncingFromMain = true; sticky.scrollLeft = main.scrollLeft; syncingFromMain = false; }; const onStickyScroll = () => { if (syncingFromMain) return; syncingFromSticky = true; main.scrollLeft = sticky.scrollLeft; syncingFromSticky = false; }; syncMetricsAndVisibility(); main.addEventListener("scroll", onMainScroll, { passive: true }); sticky.addEventListener("scroll", onStickyScroll, { passive: true }); window.addEventListener("resize", syncMetricsAndVisibility); const resizeObserver = new ResizeObserver(() => syncMetricsAndVisibility()); resizeObserver.observe(main); return () => { main.removeEventListener("scroll", onMainScroll); sticky.removeEventListener("scroll", onStickyScroll); window.removeEventListener("resize", syncMetricsAndVisibility); resizeObserver.disconnect(); }; }, [tarefas.length, loading]); const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => { if (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } if (isFechado) { toastBloqueioFechado(); return; } setTarefas((prev) => prev.map((item) => item.id === tarefa.id ? { ...item, estaRevisada: approved, } : item, ), ); }; const handleLimparFiltros = () => { setFiltroDescricao(""); setFiltroTicket(""); setFiltroCliente(""); setFiltroDataConclusao(""); }; 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 = () => { setLancamentoTipo("bonus"); setLancamentoDescricao(""); setLancamentoModo("pontos"); setLancamentoPontuacao("0"); setLancamentoValorReal(""); }; const handleSalvarLancamento = async () => { if (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } if (isFechado) { toastBloqueioFechado(); return; } const descricao = lancamentoDescricao.trim(); if (!descricao) { toast.error("Informe a descrição do lançamento."); return; } if (lancamentoModo === "real") { if (lancamentoEmRealBloqueado) { toast.error("Primeiro cadastre o fator do parceiro para poder lançar em Real."); return; } const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim()); if (!Number.isFinite(valorNum) || valorNum <= 0) { toast.error("Informe um valor em real válido maior que zero."); return; } } else { const pontuacao = (() => { const n = parsePontuacaoInput(lancamentoPontuacao); if (!Number.isFinite(n)) return Number.NaN; return pontuacaoParaCimaAte2Casas(n); })(); if (!Number.isFinite(pontuacao) || pontuacao <= 0) { toast.error("Informe uma pontuação válida maior que zero."); return; } } try { setSavingLancamento(true); if (lancamentoModo === "real") { const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim()); await fechamentoFechamentosService.criarLancamento(fechamentoId, { tipo: lancamentoTipo, descricao, valorReal: valorNum, }); } else { const pontuacao = (() => { const n = parsePontuacaoInput(lancamentoPontuacao); if (!Number.isFinite(n)) return Number.NaN; return pontuacaoParaCimaAte2Casas(n); })(); 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 (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } if (isFechado) { toastBloqueioFechado(); return; } setPontuacaoPagaInput(pontuacaoTotalLabel); setMotivoAjuste(""); setIsConcluirOpen(true); }; const handleExportarPdf = async () => { if (!fechamentoId || !isFechado) return; try { setExportandoPdf(true); const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId); const blob = new Blob([buffer], { type: "application/pdf" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename ?? `fechamento-${fechamentoId}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success("PDF exportado com sucesso."); } catch (error) { const message = error instanceof Error ? error.message : "Erro ao exportar PDF."; toast.error(message); } finally { setExportandoPdf(false); } }; const handleConcluirFechamento = async () => { if (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } 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); await persistirRevisoesPendentes(); const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, { pontuacaoPaga: pontuacaoPagaRaw, motivoAjuste: motivoAjuste.trim() || undefined, }); toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`); setFechamentoStatus("fechado"); if (supervisorPodeExportarPdf) { try { const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId); const blob = new Blob([buffer], { type: "application/pdf" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename ?? `fechamento-${fechamentoId}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success("PDF do fechamento baixado."); } catch { toast.warning( "Fechamento concluído, mas o PDF não pôde ser gerado automaticamente. Use Exportar PDF na tela.", ); } } navigate(`/intelligence-score/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; } if (!motivoReabertura.trim()) { toast.error("Informe o motivo da reabertura do fechamento."); return; } try { setReabrindo(true); const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, { reabertoPorId: me.id, motivo: motivoReabertura.trim(), }); 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 (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } 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 (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } 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 carregarCompetenciasAbertas = async () => { try { setLoadingCompetenciasAbertas(true); const competencias = await fechamentoCompetenciasService.listarCompetencias({ status: "em_aberto" }); setCompetenciasAbertas(competencias); return competencias; } catch (error) { const message = error instanceof Error ? error.message : "Erro ao carregar competências em aberto."; toast.error(message); return [] as CompetenciaItem[]; } finally { setLoadingCompetenciasAbertas(false); } }; const openAlterarCompetencia = async (tarefa: FechamentoTarefaItem) => { if (!podeAlterarCompetenciaTarefa) { toast.error("Sem permissão para alterar competência da tarefa."); return; } if (isCompetenciaConcluida) { toastBloqueioCompetenciaConcluida(); return; } if (isFechado) { toastBloqueioFechado(); return; } const competencias = await carregarCompetenciasAbertas(); const opcoes = competencias.filter((item) => item.id !== competenciaId); if (opcoes.length === 0) { toast.error("Não há outra competência em aberto para mover a tarefa."); return; } setTarefaAlteracaoCompetencia(tarefa); setCompetenciaDestinoId(opcoes[0]?.id ?? ""); setMotivoAlteracaoCompetencia(""); setIsAlterarCompetenciaOpen(true); }; const handleConfirmarAlteracaoCompetencia = async () => { if (!tarefaAlteracaoCompetencia) return; if (!me?.id) { toast.error("Não foi possível identificar o usuário para registrar a alteração."); return; } if (!competenciaDestinoId) { toast.error("Selecione a competência de destino."); return; } try { setSavingAlteracaoCompetencia(true); await fechamentoFechamentosService.alterarCompetenciaTarefa(fechamentoId, tarefaAlteracaoCompetencia.id, { competenciaDestinoId, alteradoPorId: me.id, motivo: motivoAlteracaoCompetencia, }); toast.success("Competência da tarefa alterada com sucesso."); setIsAlterarCompetenciaOpen(false); setTarefaAlteracaoCompetencia(null); setCompetenciaDestinoId(""); setMotivoAlteracaoCompetencia(""); await loadTarefas(); } catch (error) { const message = error instanceof Error ? error.message : "Erro ao alterar competência da tarefa."; toast.error(message); } finally { setSavingAlteracaoCompetencia(false); } }; 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 = pontuacaoParaCimaAte2Casas(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 (

Detalhes do Fechamento

Revisão operacional de tarefas, pontos e horas.

{!loading ? (
{isFechado ? "Fechado" : "Em aberto"} {isCompetenciaConcluida ? Competência concluída : null} {!isReadonly && podeReprocessarAsana ? ( ) : null} {isFechado ? ( <> {supervisorPodeExportarPdf ? ( ) : null} {podeReabrirFechamento ? ( ) : null} ) : podeConcluirFechamento ? ( ) : null}
) : null}
{!loading ? (
{isCompetenciaConcluida ? (
Competência concluída. Reabra a competência para editar este fechamento.
) : null}
Total de tarefas
{tarefas.length}
Tarefas aprovadas
{totais.aprovadas}
Pontuação aprovada
{formatPontos(totais.pontos)}
) : null}
{!loading && tarefas.length === 0 ? ( Nenhuma tarefa encontrada Este fechamento não possui tarefas cadastradas. ) : (
{!loading ? (
{showFilters ? (
setFiltroDescricao(e.target.value)} placeholder="Digite parte da descrição" />
setFiltroTicket(e.target.value)} placeholder="Ex.: TKT-1234" />
setFiltroCliente(e.target.value)} placeholder="Nome do cliente" />
setFiltroDataConclusao(e.target.value)} />
) : null}
) : null}
Aprovada Ticket Descrição Cliente Tipo Data início Data vencimento Data conclusão Etiquetas Pontuação Ações {loading ? ( Carregando tarefas... ) : tarefasFiltradas.length === 0 ? ( Nenhuma tarefa encontrada com os filtros aplicados. ) : ( tarefasFiltradas.map((tarefa) => (
void handleToggleAprovada(tarefa, Boolean(checked))} disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao} />
{tarefa.numeroTicket || "—"} {tarefa.descricao} {tarefa.cliente || "—"} {getTipoLancamentoLabel(tarefa.tipo)} {formatDateOnly(tarefa.dataInicio)} {formatDateOnly(tarefa.dataVencimento)} {formatDateTime(tarefa.dataConclusao)} {tarefa.etiquetas && tarefa.etiquetas.length > 0 ? (
{tarefa.etiquetas.map((etiqueta) => ( {etiqueta.name} ))}
) : ( "—" )}
{Number(tarefa.pontuacao || 0)}
{tarefa.linkAsana ? ( ) : null} {podeAlterarCompetenciaTarefa && tarefa.tipo === "tarefa" ? ( ) : null} {tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? ( ) : null}
)) )}
{showStickyScrollbar ? (
) : null}
)}
{ setIsLancamentoOpen(open); if (!open) { resetLancamentoForm(); } }} > Fazer lançamento
{lancamentoEmRealBloqueado ? ( Primeiro cadastre o fator do parceiro para poder lançar em Real. ) : null} setLancamentoModo(v as "pontos" | "real")} className="flex flex-wrap gap-4" >
{lancamentoModo === "pontos" ? (
setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))} placeholder="Ex.: 1,5" />
) : (
setLancamentoValorReal(sanitizeValorRealLancamentoDigitando(e.target.value))} placeholder="Ex.: 90,00" /> {lancamentoPreviewPontos != null ? (

Pontuação estimada: {formatPontos(lancamentoPreviewPontos)}

) : (

A conversão é feita ao salvar com o fator cadastrado do parceiro (valor ÷ (fator × 3)).

)}
)}
setLancamentoDescricao(e.target.value)} placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint" />
{ setIsAlterarCompetenciaOpen(open); if (!open) { setTarefaAlteracaoCompetencia(null); setMotivoAlteracaoCompetencia(""); setCompetenciaDestinoId(""); } }} > Alterar competência da tarefa Mova a tarefa para outra competência em aberto. O histórico da alteração ficará registrado.

{competenciaId ? (() => { const atual = competenciasAbertas.find((item) => item.id === competenciaId); if (atual) return formatCompetenciaMesAno(atual); return "Competência atual do fechamento"; })() : "Competência atual do fechamento"}

setMotivoAlteracaoCompetencia(e.target.value)} placeholder="Ex.: tarefa entregue no mês seguinte por atraso de baixa" maxLength={500} disabled={savingAlteracaoCompetencia} />
Concluir fechamento Informe a pontuação paga e confirme. {parceiroNome ? (

Parceiro

{parceiroNome}

) : null}

Este mês

Aprovados
{formatPontos(totais.pontos)}
Meta
{formatPontos(Number(pontuacaoMeta ?? 0))}
Diferença
= 0 ? "text-emerald-600" : "text-red-600" }`} > {diferencaParaMeta >= 0 ? "+" : ""} {formatPontos(diferencaParaMeta)}

Banco de pontos

Saldo hoje
{loadingSaldoBanco ? ( ) : erroSaldoBanco ? (
Não foi possível carregar {parceiroId ? ( ) : null}
) : ( {formatPontosSaldoModal(saldoBancoAtual ?? 0)} )}
Movimento
{bancoCalculado > SALDO_PONTOS_EPS ? "+" : ""} {formatPontosSaldoModal(bancoCalculado)}
Depois
{saldoBancoPosFechamento == null ? ( ) : ( {formatPontosSaldoModal(saldoBancoPosFechamento)} )}
setPontuacaoPagaInput(e.target.value)} placeholder="Ex.: 10,5" /> {isValorEditado ? ( ) : null}
{dividaParaQuitar > 0 && sugestaoQuitarDivida > SALDO_PONTOS_EPS ? ( ) : null} {saldoPositivoDisponivel > 0 ? ( ) : null}
{requerMotivoAjuste ? (
setMotivoAjuste(e.target.value)} placeholder="Ex.: pagamento parcial acordado com o parceiro" className="bg-background" />
) : null}
Reabrir fechamento Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
setMotivoReabertura(e.target.value)} placeholder="Ex.: ajuste após revisão financeira" required />
Excluir lançamento Deseja realmente excluir o lançamento manual{" "} {deletingTarefa?.descricao}? Esta ação não pode ser desfeita. Reprocessar Asana deste fechamento 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. Editar tarefa {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."}
{editingTarefa?.tipo === "tarefa" ? ( <>
setEdicaoNumeroTicket(e.target.value)} />
setEdicaoDescricao(e.target.value)} />
setEdicaoCliente(e.target.value)} />
setEdicaoTempoMinutos(e.target.value)} />
) : null} {editingTarefa?.tipo !== "tarefa" ? (
setEdicaoDescricao(e.target.value)} />
) : null}
setEdicaoPontuacao(e.target.value)} />
); }