1979 lines
80 KiB
TypeScript
1979 lines
80 KiB
TypeScript
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>
|
||
);
|
||
}
|