atualizacoes modulo fechamento
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, CheckCircle2, Clock3, ListChecks, Loader2, Pencil, Plus, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { fechamentoCompetenciasService } from "@/services/fechamento/competencias";
|
||||
import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos";
|
||||
|
||||
function formatHoras(minutos: number | null): string {
|
||||
if (!minutos || minutos <= 0) return "0h";
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatHorasResumo(minutos: number): string {
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = Math.round(minutos % 60);
|
||||
if (horas === 0) return `${mins}min`;
|
||||
if (mins === 0) return `${horas}h`;
|
||||
return `${horas}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatPontos(valor: number): string {
|
||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function parsePontuacaoInput(value: string): number {
|
||||
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
type FechamentoDetalhesLocationState = {
|
||||
competenciaId?: string;
|
||||
status?: "em_aberto" | "fechado";
|
||||
};
|
||||
|
||||
export default function FechamentoDetalhes() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { me } = 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 [togglingTaskId, setTogglingTaskId] = useState<string | null>(null);
|
||||
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 [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 [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
|
||||
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(null);
|
||||
const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false);
|
||||
const [reprocessandoAsana, setReprocessandoAsana] = useState(false);
|
||||
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 isFechado = fechamentoStatus === "fechado";
|
||||
|
||||
const totais = useMemo(() => {
|
||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
||||
const pontos = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
|
||||
const minutos = aprovadas.reduce((acc, t) => acc + Number(t.tempoMinutos || 0), 0);
|
||||
return {
|
||||
pontos,
|
||||
horas: formatHorasResumo(minutos),
|
||||
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 = String(totais.pontos);
|
||||
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
|
||||
|
||||
const loadTarefas = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fechamentoFechamentosService.listarTarefas(fechamentoId);
|
||||
setTarefas(data);
|
||||
} 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 = await fechamentoCompetenciasService.listarFechamentosDaCompetencia(currentCompetenciaId);
|
||||
const current = rows.find((row) => row.id === fechamentoId);
|
||||
if (!current) return;
|
||||
setFechamentoStatus(current.status);
|
||||
setPontuacaoMeta(current.pontuacaoMeta);
|
||||
if (!competenciaId) {
|
||||
setCompetenciaId(current.competenciaId);
|
||||
}
|
||||
} catch {
|
||||
// mantém status atual em caso de erro para evitar bloquear navegação
|
||||
}
|
||||
};
|
||||
|
||||
const toastBloqueioFechado = () => {
|
||||
toast.error("Fechamento está fechado. Reabra para editar.");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fechamentoId) {
|
||||
void loadTarefas();
|
||||
}
|
||||
}, [fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (competenciaId) {
|
||||
void loadFechamentoStatus(competenciaId);
|
||||
}
|
||||
}, [competenciaId, fechamentoId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFechado) {
|
||||
setIsConcluirOpen(false);
|
||||
setIsLancamentoOpen(false);
|
||||
}
|
||||
}, [isFechado]);
|
||||
|
||||
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const previous = tarefas;
|
||||
setTogglingTaskId(tarefa.id);
|
||||
setTarefas((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === tarefa.id
|
||||
? {
|
||||
...item,
|
||||
estaRevisada: approved,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (!me?.id) {
|
||||
throw new Error("Não foi possível identificar o usuário para registrar a edição.");
|
||||
}
|
||||
await fechamentoFechamentosService.patchTarefa(fechamentoId, tarefa.id, {
|
||||
estaRevisada: approved,
|
||||
editadoPorId: me.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setTarefas(previous);
|
||||
const message = error instanceof Error ? error.message : "Erro ao atualizar aprovação da tarefa.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setTogglingTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLancamentoForm = () => {
|
||||
setLancamentoTipo("bonus");
|
||||
setLancamentoDescricao("");
|
||||
setLancamentoPontuacao("0");
|
||||
};
|
||||
|
||||
const handleSalvarLancamento = async () => {
|
||||
if (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
const descricao = lancamentoDescricao.trim();
|
||||
const pontuacao = Number(lancamentoPontuacao);
|
||||
if (!descricao) {
|
||||
toast.error("Informe a descrição do lançamento.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
|
||||
toast.error("Informe uma pontuação válida maior que zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSavingLancamento(true);
|
||||
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 (isFechado) {
|
||||
toastBloqueioFechado();
|
||||
return;
|
||||
}
|
||||
setPontuacaoPagaInput(pontuacaoTotalLabel);
|
||||
setMotivoAjuste("");
|
||||
setIsConcluirOpen(true);
|
||||
};
|
||||
|
||||
const handleConcluirFechamento = async () => {
|
||||
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);
|
||||
const data = await fechamentoFechamentosService.concluirFechamento(fechamentoId, {
|
||||
pontuacaoPaga: pontuacaoPagaRaw,
|
||||
motivoAjuste: motivoAjuste.trim() || undefined,
|
||||
});
|
||||
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
|
||||
setFechamentoStatus("fechado");
|
||||
navigate(`/fechamento-hgtx/competencias/${data.competenciaId}`);
|
||||
} 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;
|
||||
}
|
||||
try {
|
||||
setReabrindo(true);
|
||||
const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, {
|
||||
reabertoPorId: me.id,
|
||||
motivo: motivoReabertura,
|
||||
});
|
||||
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 (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 (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 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 = 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" 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={isFechado ? "secondary" : "outline"}>{isFechado ? "Fechado" : "Em aberto"}</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsLancamentoOpen(true)}
|
||||
disabled={isFechado}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Fazer lançamento
|
||||
</Button>
|
||||
{!isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReprocessAsanaOpen(true)}
|
||||
disabled={reprocessandoAsana}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reprocessar Asana
|
||||
</Button>
|
||||
) : null}
|
||||
{isFechado ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setIsReabrirOpen(true)}
|
||||
disabled={reabrindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reabrir fechamento
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAbrirConcluir}
|
||||
disabled={tarefas.length === 0 || concluindo}
|
||||
className="min-w-[152px]"
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Concluir fechamento
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!loading ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<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>
|
||||
<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">Horas aprovadas</CardDescription>
|
||||
<Clock3 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-3xl">{totais.horas}</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="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-[120px]">Horas</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={8} className="py-8 text-center text-muted-foreground">
|
||||
Carregando tarefas...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tarefas.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={togglingTaskId === tarefa.id || isFechado}
|
||||
/>
|
||||
{togglingTaskId === tarefa.id ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
|
||||
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
|
||||
<TableCell>{tarefa.cliente || "—"}</TableCell>
|
||||
<TableCell>{tarefa.tipo}</TableCell>
|
||||
<TableCell>{formatHoras(tarefa.tempoMinutos)}</TableCell>
|
||||
<TableCell>{Number(tarefa.pontuacao || 0)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditarTarefa(tarefa)}
|
||||
disabled={isFechado}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="ml-1">Editar</span>
|
||||
</Button>
|
||||
{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={isFechado || 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fazer lançamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adicione uma bonificação ou desconto em pontuação para este fechamento.
|
||||
</DialogDescription>
|
||||
</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">Bonificação</SelectItem>
|
||||
<SelectItem value="desconto">Desconto</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
|
||||
<Input
|
||||
id="lancamento-pontuacao"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={lancamentoPontuacao}
|
||||
onChange={(e) => setLancamentoPontuacao(e.target.value)}
|
||||
/>
|
||||
</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"
|
||||
onClick={() => {
|
||||
setIsLancamentoOpen(false);
|
||||
resetLancamentoForm();
|
||||
}}
|
||||
disabled={savingLancamento}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isFechado}>
|
||||
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Concluir fechamento</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revise os totais e confirme a pontuação paga para concluir este fechamento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Aprovada</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(totais.pontos)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Meta</p>
|
||||
<p className="text-2xl font-bold">{formatPontos(Number(pontuacaoMeta ?? 0))}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Diferença</p>
|
||||
<p className={`text-2xl font-bold ${diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(diferencaParaMeta)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">Banco de Pontos</p>
|
||||
<p className={`text-2xl font-bold ${bancoCalculado >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{formatPontos(bancoCalculado)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Pagar total aprovado
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
|
||||
>
|
||||
Pagar meta
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pontuacao-paga">Pontuação paga</Label>
|
||||
{isValorEditado ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 transition hover:text-foreground"
|
||||
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
|
||||
>
|
||||
Usar valor total
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Input
|
||||
id="pontuacao-paga"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={pontuacaoPagaInput}
|
||||
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
|
||||
placeholder="Ex.: 10,5"
|
||||
/>
|
||||
</div>
|
||||
{requerMotivoAjuste ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo-ajuste">Motivo do ajuste *</Label>
|
||||
<Input
|
||||
id="motivo-ajuste"
|
||||
value={motivoAjuste}
|
||||
onChange={(e) => setMotivoAjuste(e.target.value)}
|
||||
placeholder="Ex.: pagamento parcial acordado com o parceiro"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConcluirOpen(false)} disabled={concluindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleConcluirFechamento()} disabled={concluindo || isFechado}>
|
||||
{concluindo ? "Concluindo..." : "Confirmar conclusão"}
|
||||
</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 (opcional)</Label>
|
||||
<Input
|
||||
id="motivo-reabertura"
|
||||
value={motivoReabertura}
|
||||
onChange={(e) => setMotivoReabertura(e.target.value)}
|
||||
placeholder="Ex.: ajuste após revisão financeira"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsReabrirOpen(false)} disabled={reabrindo}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
|
||||
{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"
|
||||
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"
|
||||
onClick={() => setIsReprocessAsanaOpen(false)}
|
||||
disabled={reprocessandoAsana}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleReprocessarAsana()} disabled={reprocessandoAsana}>
|
||||
{reprocessandoAsana ? "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" onClick={() => setIsEditarOpen(false)} disabled={savingEdicao}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao}>
|
||||
{savingEdicao ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user