novas atualizações na exportacao planilha, tela de listar fechamento, filtros

This commit is contained in:
Vitex Tecnologia
2026-05-07 11:34:55 -03:00
parent 1c1e62c7db
commit 343aae602f
13 changed files with 228 additions and 88 deletions
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
CheckCircle2,
Clock3,
ExternalLink,
ListChecks,
Loader2,
@@ -41,23 +40,6 @@ import { fechamentoBancoPontosService } from "@/services/fechamento/bancoPontos"
import { fechamentoCompetenciasService, type CompetenciaItem } 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 });
}
@@ -146,10 +128,33 @@ function formatDateTime(value: string | null): string {
});
}
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";
@@ -206,6 +211,15 @@ export default function FechamentoDetalhes() {
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";
@@ -215,10 +229,8 @@ export default function FechamentoDetalhes() {
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]);
@@ -244,6 +256,31 @@ export default function FechamentoDetalhes() {
() => 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 {
@@ -344,6 +381,51 @@ export default function FechamentoDetalhes() {
}
}, [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();
@@ -365,6 +447,13 @@ export default function FechamentoDetalhes() {
);
};
const handleLimparFiltros = () => {
setFiltroDescricao("");
setFiltroTicket("");
setFiltroCliente("");
setFiltroDataConclusao("");
};
const handleToggleTodasAprovadas = async () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
@@ -501,7 +590,7 @@ export default function FechamentoDetalhes() {
});
toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`);
setFechamentoStatus("fechado");
navigate(`/fechamento/competencias/${data.competenciaId}`);
navigate(`/intelligence-score/competencias/${data.competenciaId}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
toast.error(message);
@@ -824,7 +913,7 @@ export default function FechamentoDetalhes() {
</div>
{!loading ? (
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<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.
@@ -857,15 +946,6 @@ export default function FechamentoDetalhes() {
<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>
@@ -881,24 +961,78 @@ export default function FechamentoDetalhes() {
) : (
<div className="space-y-3">
{!loading ? (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void handleToggleTodasAprovadas()}
disabled={isReadonly || bulkUpdatingRevisao}
>
{bulkUpdatingRevisao ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{todasTarefasRevisadas ? "Desmarcar todas" : "Marcar todas"}
</Button>
<div 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={isReadonly || bulkUpdatingRevisao}
>
{bulkUpdatingRevisao ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{todasTarefasRevisadas ? "Desmarcar todas" : "Marcar todas"}
</Button>
</div>
{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 className="overflow-x-auto rounded-lg border">
<div ref={tableScrollRef} className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
@@ -911,7 +1045,6 @@ export default function FechamentoDetalhes() {
<TableHead className="min-w-[140px]">Data vencimento</TableHead>
<TableHead className="min-w-[170px]">Data conclusão</TableHead>
<TableHead className="min-w-[240px]">Etiquetas</TableHead>
<TableHead className="min-w-[120px]">Horas</TableHead>
<TableHead className="min-w-[120px]">Pontuação</TableHead>
<TableHead className="min-w-[140px] text-right">Ações</TableHead>
</TableRow>
@@ -919,12 +1052,18 @@ export default function FechamentoDetalhes() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={12} className="py-8 text-center text-muted-foreground">
<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>
) : (
tarefas.map((tarefa) => (
tarefasFiltradas.map((tarefa) => (
<TableRow key={tarefa.id}>
<TableCell>
<div className="flex items-center gap-2">
@@ -938,7 +1077,7 @@ export default function FechamentoDetalhes() {
<TableCell>{tarefa.numeroTicket || "—"}</TableCell>
<TableCell className="font-medium">{tarefa.descricao}</TableCell>
<TableCell>{tarefa.cliente || "—"}</TableCell>
<TableCell>{tarefa.tipo}</TableCell>
<TableCell>{getTipoLancamentoLabel(tarefa.tipo)}</TableCell>
<TableCell>{formatDateOnly(tarefa.dataInicio)}</TableCell>
<TableCell>{formatDateOnly(tarefa.dataVencimento)}</TableCell>
<TableCell>{formatDateTime(tarefa.dataConclusao)}</TableCell>
@@ -955,7 +1094,6 @@ export default function FechamentoDetalhes() {
"—"
)}
</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">
@@ -1014,6 +1152,13 @@ export default function FechamentoDetalhes() {
</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>
@@ -1038,8 +1183,8 @@ export default function FechamentoDetalhes() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bonus">Bonificação</SelectItem>
<SelectItem value="desconto">Desconto</SelectItem>
<SelectItem value="bonus">{getTipoLancamentoLabel("bonus")}</SelectItem>
<SelectItem value="desconto">{getTipoLancamentoLabel("desconto")}</SelectItem>
</SelectContent>
</Select>
</div>