novas atualizações na exportacao planilha, tela de listar fechamento, filtros
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user