Files
OPEN_CODEX_API/src/modules/fechamento-hgtx/pages/FechamentoDetalhes.tsx
T

1979 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CompetenciaItem, "mes" | "ano">): 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<FechamentoTarefaItem[]>([]);
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);
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<number | null>(null);
const [parceiroId, setParceiroId] = useState<string | null>(null);
const [parceiroNome, setParceiroNome] = useState<string | null>(null);
const [parceiroFator, setParceiroFator] = useState<number | null>(null);
const [saldoBancoAtual, setSaldoBancoAtual] = useState<number | null>(null);
const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false);
const [erroSaldoBanco, setErroSaldoBanco] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(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<CompetenciaItem[]>([]);
const [tarefaAlteracaoCompetencia, setTarefaAlteracaoCompetencia] = useState<FechamentoTarefaItem | null>(null);
const [competenciaDestinoId, setCompetenciaDestinoId] = useState("");
const [motivoAlteracaoCompetencia, setMotivoAlteracaoCompetencia] = useState("");
const [isEditarOpen, setIsEditarOpen] = useState(false);
const [savingEdicao, setSavingEdicao] = useState(false);
const [editingTarefa, setEditingTarefa] = useState<FechamentoTarefaItem | null>(null);
const [edicaoNumeroTicket, setEdicaoNumeroTicket] = useState("");
const [edicaoDescricao, setEdicaoDescricao] = useState("");
const [edicaoCliente, setEdicaoCliente] = useState("");
const [edicaoTempoMinutos, setEdicaoTempoMinutos] = useState("");
const [edicaoPontuacao, setEdicaoPontuacao] = useState("");
const tableScrollRef = useRef<HTMLDivElement | null>(null);
const stickyScrollRef = useRef<HTMLDivElement | null>(null);
const stickyScrollSpacerRef = useRef<HTMLDivElement | null>(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 (
<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" className="hover:bg-muted/60 hover:text-foreground" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-1 h-4 w-4" />
Voltar
</Button>
</div>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-xl font-bold text-foreground md:text-3xl">
<ListChecks className="h-5 w-5 md:h-6 md:w-6" />
Detalhes do Fechamento
</h1>
<p className="mt-1 text-sm text-muted-foreground">Revisão operacional de tarefas, pontos e horas.</p>
</div>
{!loading ? (
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
<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={() => {
resetLancamentoForm();
setIsLancamentoOpen(true);
}}
disabled={isReadonly || !podeTarefaLancarAjuste}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<Plus className="mr-2 h-4 w-4" />
Fazer lançamento
</Button>
{!isReadonly && podeReprocessarAsana ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReprocessAsanaOpen(true)}
disabled={reprocessandoAsana}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reprocessar Asana
</Button>
) : null}
{isFechado ? (
<>
{supervisorPodeExportarPdf ? (
<Button
size="sm"
variant="outline"
onClick={() => void handleExportarPdf()}
disabled={exportandoPdf || isCompetenciaConcluida}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<FileText className="mr-2 h-4 w-4" />
{exportandoPdf ? "Gerando PDF..." : "Exportar PDF"}
</Button>
) : null}
{podeReabrirFechamento ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirOpen(true)}
disabled={reabrindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reabrir fechamento
</Button>
) : null}
</>
) : podeConcluirFechamento ? (
<Button
size="sm"
variant="secondary"
onClick={handleAbrirConcluir}
disabled={tarefas.length === 0 || concluindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Concluir fechamento
</Button>
) : null}
</div>
) : null}
</div>
{!loading ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{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">
<CardDescription className="text-[11px] uppercase tracking-wide">Total de tarefas</CardDescription>
<ListChecks className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{tarefas.length}</CardTitle>
</CardHeader>
</Card>
<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">
<CardDescription className="text-[11px] uppercase tracking-wide">Tarefas aprovadas</CardDescription>
<Target className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
</CardHeader>
</Card>
<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">
<CardDescription className="text-[11px] uppercase tracking-wide">Pontuação aprovada</CardDescription>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
</CardHeader>
</Card>
</div>
) : null}
</div>
<div className="flex-1 overflow-auto p-3 md:p-6">
{!loading && tarefas.length === 0 ? (
<Card className="mx-auto mt-12 max-w-2xl">
<CardHeader>
<CardTitle>Nenhuma tarefa encontrada</CardTitle>
<CardDescription>Este fechamento não possui tarefas cadastradas.</CardDescription>
</CardHeader>
</Card>
) : (
<div className="space-y-3">
{!loading ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setShowFilters((prev) => !prev)}>
{showFilters ? "Ocultar filtros" : "Exibir filtros"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleLimparFiltros}
disabled={!hasActiveFilters}
>
Limpar filtros
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleToggleTodasAprovadas()}
disabled={bloqueioEdicaoTarefa || 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>
{showFilters ? (
<div className="grid grid-cols-1 gap-3 rounded-lg border bg-muted/20 p-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="filtro-descricao">Descrição</Label>
<Input
id="filtro-descricao"
value={filtroDescricao}
onChange={(e) => setFiltroDescricao(e.target.value)}
placeholder="Digite parte da descrição"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filtro-ticket">Ticket</Label>
<Input
id="filtro-ticket"
value={filtroTicket}
onChange={(e) => setFiltroTicket(e.target.value)}
placeholder="Ex.: TKT-1234"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filtro-cliente">Cliente</Label>
<Input
id="filtro-cliente"
value={filtroCliente}
onChange={(e) => setFiltroCliente(e.target.value)}
placeholder="Nome do cliente"
/>
</div>
<div className="space-y-1">
<Label htmlFor="filtro-data-conclusao">Data de conclusão</Label>
<Input
id="filtro-data-conclusao"
type="date"
value={filtroDataConclusao}
onChange={(e) => setFiltroDataConclusao(e.target.value)}
/>
</div>
</div>
) : null}
</div>
) : null}
<div ref={tableScrollRef} className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">Aprovada</TableHead>
<TableHead className="min-w-[120px]">Ticket</TableHead>
<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]">Pontuação</TableHead>
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center text-muted-foreground">
Carregando tarefas...
</TableCell>
</TableRow>
) : tarefasFiltradas.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="py-8 text-center text-muted-foreground">
Nenhuma tarefa encontrada com os filtros aplicados.
</TableCell>
</TableRow>
) : (
tarefasFiltradas.map((tarefa) => (
<TableRow key={tarefa.id}>
<TableCell>
<div className="flex items-center gap-2">
<Checkbox
checked={tarefa.estaRevisada}
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao}
/>
</div>
</TableCell>
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
<TableCell>{tarefa.cliente || "—"}</TableCell>
<TableCell>{getTipoLancamentoLabel(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>{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={bloqueioEdicaoTarefa}
>
<Pencil className="h-4 w-4" />
<span className="ml-1">Editar</span>
</Button>
{podeAlterarCompetenciaTarefa && tarefa.tipo === "tarefa" ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void openAlterarCompetencia(tarefa)}
disabled={bloqueioEdicaoTarefa || savingAlteracaoCompetencia}
>
<Repeat className="h-4 w-4" />
<span className="ml-1">Alterar competência</span>
</Button>
) : null}
{tarefa.tipo === "bonus" || tarefa.tipo === "desconto" ? (
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => void handleExcluirLancamento(tarefa)}
disabled={bloqueioEdicaoTarefa || !podeExcluirLancamentoManual || deletingTaskId === tarefa.id}
>
{deletingTaskId === tarefa.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="ml-1">Excluir</span>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{showStickyScrollbar ? (
<div className="fixed bottom-2 left-2 right-2 z-50 rounded-md border bg-background/95 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div ref={stickyScrollRef} className="overflow-x-auto">
<div ref={stickyScrollSpacerRef} className="h-4 min-w-full" />
</div>
</div>
) : null}
</div>
)}
</div>
<Dialog
open={isLancamentoOpen}
onOpenChange={(open) => {
setIsLancamentoOpen(open);
if (!open) {
resetLancamentoForm();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Fazer lançamento</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lancamento-tipo">Tipo de lançamento</Label>
<Select
value={lancamentoTipo}
onValueChange={(value) => setLancamentoTipo(value as "bonus" | "desconto")}
>
<SelectTrigger id="lancamento-tipo">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonus">{getTipoLancamentoLabel("bonus")}</SelectItem>
<SelectItem value="desconto">{getTipoLancamentoLabel("desconto")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Forma do lançamento</Label>
{lancamentoEmRealBloqueado ? (
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Primeiro cadastre o fator do parceiro para poder lançar em Real.
</AlertDescription>
</Alert>
) : null}
<RadioGroup
value={lancamentoModo}
onValueChange={(v) => setLancamentoModo(v as "pontos" | "real")}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pontos" id="lancamento-modo-pontos" />
<Label htmlFor="lancamento-modo-pontos" className="cursor-pointer font-normal">
Pontuação
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="real" id="lancamento-modo-real" disabled={lancamentoEmRealBloqueado} />
<Label
htmlFor="lancamento-modo-real"
className={
lancamentoEmRealBloqueado ? "cursor-not-allowed font-normal text-muted-foreground" : "cursor-pointer font-normal"
}
>
Valor em real (R$)
</Label>
</div>
</RadioGroup>
</div>
{lancamentoModo === "pontos" ? (
<div className="space-y-2">
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
<Input
id="lancamento-pontuacao"
type="text"
inputMode="decimal"
value={lancamentoPontuacao}
onChange={(e) => setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))}
placeholder="Ex.: 1,5"
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="lancamento-valor-real">Valor (R$)</Label>
<Input
id="lancamento-valor-real"
type="text"
inputMode="decimal"
autoComplete="off"
value={lancamentoValorReal}
onChange={(e) => setLancamentoValorReal(sanitizeValorRealLancamentoDigitando(e.target.value))}
placeholder="Ex.: 90,00"
/>
{lancamentoPreviewPontos != null ? (
<p className="text-xs font-medium text-foreground">
Pontuação estimada: {formatPontos(lancamentoPreviewPontos)}
</p>
) : (
<p className="text-xs text-muted-foreground">
A conversão é feita ao salvar com o fator cadastrado do parceiro (valor ÷ (fator × 3)).
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="lancamento-descricao">Descrição</Label>
<Input
id="lancamento-descricao"
value={lancamentoDescricao}
onChange={(e) => setLancamentoDescricao(e.target.value)}
placeholder="Ex.: ajuste de meta / retrabalho / bônus de sprint"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setIsLancamentoOpen(false);
resetLancamentoForm();
}}
disabled={savingLancamento}
>
Cancelar
</Button>
<Button
onClick={() => void handleSalvarLancamento()}
disabled={
savingLancamento ||
isReadonly ||
!podeTarefaLancarAjuste ||
(lancamentoModo === "real" && lancamentoEmRealBloqueado)
}
>
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isAlterarCompetenciaOpen}
onOpenChange={(open) => {
setIsAlterarCompetenciaOpen(open);
if (!open) {
setTarefaAlteracaoCompetencia(null);
setMotivoAlteracaoCompetencia("");
setCompetenciaDestinoId("");
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Alterar competência da tarefa</DialogTitle>
<DialogDescription>
Mova a tarefa para outra competência em aberto. O histórico da alteração ficará registrado.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Competência atual</Label>
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
{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"}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="competencia-destino">Competência destino</Label>
<Select
value={competenciaDestinoId}
onValueChange={setCompetenciaDestinoId}
disabled={loadingCompetenciasAbertas || savingAlteracaoCompetencia}
>
<SelectTrigger id="competencia-destino">
<SelectValue placeholder="Selecione a competência de destino" />
</SelectTrigger>
<SelectContent>
{competenciasDestino.map((item) => (
<SelectItem key={item.id} value={item.id}>
{formatCompetenciaMesAno(item)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="motivo-alteracao-competencia">Motivo (opcional)</Label>
<Input
id="motivo-alteracao-competencia"
value={motivoAlteracaoCompetencia}
onChange={(e) => setMotivoAlteracaoCompetencia(e.target.value)}
placeholder="Ex.: tarefa entregue no mês seguinte por atraso de baixa"
maxLength={500}
disabled={savingAlteracaoCompetencia}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsAlterarCompetenciaOpen(false)}
disabled={savingAlteracaoCompetencia}
>
Cancelar
</Button>
<Button
onClick={() => void handleConfirmarAlteracaoCompetencia()}
disabled={savingAlteracaoCompetencia || !competenciaDestinoId}
>
{savingAlteracaoCompetencia ? "Alterando..." : "Confirmar alteração"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
<div className="border-b border-border bg-muted/40 px-6 pb-4 pt-6 sm:px-8">
<DialogHeader className="space-y-1 text-left">
<DialogTitle className="text-xl font-semibold tracking-tight">Concluir fechamento</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Informe a pontuação paga e confirme.
</DialogDescription>
</DialogHeader>
{parceiroNome ? (
<div className="mt-4 flex items-center gap-2 rounded-lg border border-border/80 bg-background/80 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Parceiro</p>
<p className="truncate text-sm font-semibold text-foreground">{parceiroNome}</p>
</div>
</div>
) : null}
</div>
<div className="max-h-[min(75vh,36rem)] space-y-0 overflow-y-auto px-6 py-5 sm:px-8">
<div className="grid gap-4 md:grid-cols-2 md:gap-5">
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted">
<ListChecks className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
<h3 className="text-sm font-semibold text-foreground">Este mês</h3>
</div>
<dl className="space-y-0">
<div className="flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5 first:pt-0">
<dt className="text-sm text-muted-foreground">Aprovados</dt>
<dd className="text-right text-lg font-semibold tabular-nums tracking-tight text-foreground">
{formatPontos(totais.pontos)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5">
<dt className="text-sm text-muted-foreground">Meta</dt>
<dd className="text-right text-lg font-semibold tabular-nums tracking-tight text-foreground">
{formatPontos(Number(pontuacaoMeta ?? 0))}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2.5 last:pb-0">
<dt className="text-sm text-muted-foreground">Diferença</dt>
<dd
className={`text-right text-lg font-semibold tabular-nums tracking-tight ${
diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{diferencaParaMeta >= 0 ? "+" : ""}
{formatPontos(diferencaParaMeta)}
</dd>
</div>
</dl>
</section>
<section className="rounded-xl border border-primary/20 bg-primary/[0.06] p-4">
<div className="mb-3 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-background shadow-sm">
<Wallet className="h-4 w-4 text-primary" aria-hidden />
</div>
<h3 className="text-sm font-semibold text-foreground">Banco de pontos</h3>
</div>
<dl className="space-y-0">
<div className="flex items-baseline justify-between gap-4 border-b border-primary/10 py-2.5 first:pt-0">
<dt className="text-sm text-muted-foreground">Saldo hoje</dt>
<dd className="text-right">
{loadingSaldoBanco ? (
<Skeleton className="ml-auto h-7 w-24 rounded-md" />
) : erroSaldoBanco ? (
<div className="flex flex-col items-end gap-1">
<span className="text-sm font-medium text-destructive">Não foi possível carregar</span>
{parceiroId ? (
<button
type="button"
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
onClick={() => void loadSaldoBanco(parceiroId)}
>
Tentar de novo
</button>
) : null}
</div>
) : (
<span
className={`text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(saldoBancoAtual ?? 0)}`}
>
{formatPontosSaldoModal(saldoBancoAtual ?? 0)}
</span>
)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 border-b border-primary/10 py-2.5">
<dt className="text-sm text-muted-foreground">Movimento</dt>
<dd
className={`text-right text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(bancoCalculado)}`}
>
{bancoCalculado > SALDO_PONTOS_EPS ? "+" : ""}
{formatPontosSaldoModal(bancoCalculado)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2.5 last:pb-0">
<dt className="text-sm font-medium text-foreground">Depois</dt>
<dd className="text-right">
{saldoBancoPosFechamento == null ? (
<span className="text-lg font-semibold tabular-nums text-muted-foreground"></span>
) : (
<span
className={`text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(saldoBancoPosFechamento)}`}
>
{formatPontosSaldoModal(saldoBancoPosFechamento)}
</span>
)}
</dd>
</div>
</dl>
</section>
</div>
<Separator className="my-5" />
<div className="space-y-3">
<Label htmlFor="pontuacao-paga" className="text-sm font-semibold">
Pontuação paga
</Label>
<Input
id="pontuacao-paga"
type="text"
inputMode="decimal"
className="h-11 text-base font-medium tabular-nums"
value={pontuacaoPagaInput}
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
placeholder="Ex.: 10,5"
/>
{isValorEditado ? (
<button
type="button"
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Voltar para o total aprovado ({formatPontos(totais.pontos)})
</button>
) : null}
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center font-normal"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Igual aos aprovados
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center font-normal"
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
>
Igual à meta
</Button>
{dividaParaQuitar > 0 && sugestaoQuitarDivida > SALDO_PONTOS_EPS ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center border border-red-200 bg-red-50 font-normal text-red-800 hover:bg-red-100 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-200 dark:hover:bg-red-950/60"
title={`${formatPontos(sugestaoQuitarDivida)} pontos — aprovados menos a dívida; zera o banco`}
onClick={() => setPontuacaoPagaInput(toPontuacaoInput(sugestaoQuitarDivida))}
>
Quitar dívida ({formatPontos(dividaParaQuitar)})
</Button>
) : null}
{saldoPositivoDisponivel > 0 ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center border border-emerald-200 bg-emerald-50 font-normal text-emerald-900 hover:bg-emerald-100 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-100 dark:hover:bg-emerald-950/60"
title={`${formatPontos(sugestaoUsarSaldoPositivo)} pontos — aprovados + saldo; zera o banco`}
onClick={() => setPontuacaoPagaInput(toPontuacaoInput(sugestaoUsarSaldoPositivo))}
>
Usar saldo positivo ({formatPontos(saldoPositivoDisponivel)})
</Button>
) : null}
</div>
</div>
</div>
{requerMotivoAjuste ? (
<div className="mt-5 space-y-2 rounded-lg border border-amber-200/80 bg-amber-50/80 p-3 dark:border-amber-900/40 dark:bg-amber-950/25">
<Label htmlFor="motivo-ajuste" className="text-sm font-semibold text-amber-950 dark:text-amber-100">
Motivo do ajuste *
</Label>
<Input
id="motivo-ajuste"
value={motivoAjuste}
onChange={(e) => setMotivoAjuste(e.target.value)}
placeholder="Ex.: pagamento parcial acordado com o parceiro"
className="bg-background"
/>
</div>
) : null}
</div>
<DialogFooter className="gap-2 border-t border-border bg-muted/30 px-6 py-4 sm:justify-end sm:px-8">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsConcluirOpen(false)}
disabled={concluindo}
>
Cancelar
</Button>
<Button
onClick={() => void handleConcluirFechamento()}
disabled={concluindo || isReadonly || !podeConcluirFechamento}
className="min-w-[160px] shadow-md hover:shadow-primary/40"
>
{concluindo ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Concluir"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReabrirOpen} onOpenChange={setIsReabrirOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Reabrir fechamento</DialogTitle>
<DialogDescription>
Ao reabrir, o fechamento volta para edição e será necessário concluir novamente depois.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabertura">Motivo *</Label>
<Input
id="motivo-reabertura"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: ajuste após revisão financeira"
required
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsReabrirOpen(false)}
disabled={reabrindo}
>
Cancelar
</Button>
<Button
onClick={() => void handleReabrirFechamento()}
disabled={reabrindo || !motivoReabertura.trim()}
>
{reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Excluir lançamento</DialogTitle>
<DialogDescription>
Deseja realmente excluir o lançamento manual{" "}
<strong>{deletingTarefa?.descricao}</strong>? Esta ação não pode ser desfeita.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => {
setIsDeleteDialogOpen(false);
setDeletingTarefa(null);
}}
disabled={deletingTaskId !== null}
>
Cancelar
</Button>
<Button
variant="destructive"
onClick={() => void confirmDeleteLancamento()}
disabled={deletingTaskId !== null}
>
{deletingTaskId !== null ? "Excluindo..." : "Confirmar exclusão"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isReprocessAsanaOpen} onOpenChange={setIsReprocessAsanaOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reprocessar Asana deste fechamento</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<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}
variant="secondary"
className="shadow-md hover:shadow-secondary/40"
>
{reprocessandoAsana ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Reprocessando...
</>
) : (
"Confirmar reprocessamento"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isEditarOpen} onOpenChange={setIsEditarOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Editar tarefa</DialogTitle>
<DialogDescription>
{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."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{editingTarefa?.tipo === "tarefa" ? (
<>
<div className="space-y-1">
<Label htmlFor="edit-ticket">Ticket</Label>
<Input id="edit-ticket" value={edicaoNumeroTicket} onChange={(e) => setEdicaoNumeroTicket(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-descricao">Descrição</Label>
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-cliente">Cliente</Label>
<Input id="edit-cliente" value={edicaoCliente} onChange={(e) => setEdicaoCliente(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="edit-tempo">Horas (minutos)</Label>
<Input
id="edit-tempo"
type="number"
min={0}
step={1}
value={edicaoTempoMinutos}
onChange={(e) => setEdicaoTempoMinutos(e.target.value)}
/>
</div>
</>
) : null}
{editingTarefa?.tipo !== "tarefa" ? (
<div className="space-y-1">
<Label htmlFor="edit-descricao">Descrição</Label>
<Input id="edit-descricao" value={edicaoDescricao} onChange={(e) => setEdicaoDescricao(e.target.value)} />
</div>
) : null}
<div className="space-y-1">
<Label htmlFor="edit-pontuacao">Pontuação</Label>
<Input
id="edit-pontuacao"
type="text"
inputMode="decimal"
value={edicaoPontuacao}
onChange={(e) => setEdicaoPontuacao(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
onClick={() => setIsEditarOpen(false)}
disabled={savingEdicao}
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || bloqueioEdicaoTarefa}>
{savingEdicao ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}