517 lines
21 KiB
TypeScript
517 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { format, startOfDay } from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
import { CalendarIcon, ChevronDown, LayoutDashboard, Loader2, RefreshCw, Sparkles } from "lucide-react";
|
|
import { motion } from "motion/react";
|
|
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
|
import {
|
|
dashboardPontosPorClienteService,
|
|
type DashboardPontosPorParceiro,
|
|
} from "@/services/fechamento/dashboardPontosPorCliente";
|
|
import { fechamentoParceirosService, type ParceiroItem } from "@/services/fechamento/parceiros";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
function formatPontos(valor: number): string {
|
|
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
|
}
|
|
|
|
/** Primeiro dia do mês civil (hora local). */
|
|
function inicioDoMesAtual(ref = new Date()): Date {
|
|
return new Date(ref.getFullYear(), ref.getMonth(), 1);
|
|
}
|
|
|
|
/** Último dia do mês civil (hora local). */
|
|
function fimDoMesAtual(ref = new Date()): Date {
|
|
return new Date(ref.getFullYear(), ref.getMonth() + 1, 0);
|
|
}
|
|
|
|
function dateToApiYmd(d: Date): string {
|
|
return format(d, "yyyy-MM-dd");
|
|
}
|
|
|
|
function sliceColor(i: number): string {
|
|
const hue = (i * 47 + 198) % 360;
|
|
return `hsl(${hue} 72% ${52 - (i % 4) * 4}%)`;
|
|
}
|
|
|
|
function DistribuicaoClientesDonut({ bloco }: { bloco: DashboardPontosPorParceiro }) {
|
|
const total = bloco.totalParceiro > 0 ? bloco.totalParceiro : 1;
|
|
const data = useMemo(
|
|
() =>
|
|
bloco.linhas.map((l) => ({
|
|
name: l.cliente,
|
|
value: l.pontos,
|
|
})),
|
|
[bloco.linhas],
|
|
);
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="flex h-[220px] items-center justify-center text-sm text-muted-foreground">
|
|
Sem clientes neste período.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-[min(320px,55vw)] w-full min-h-[220px] min-w-0">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={data}
|
|
cx="50%"
|
|
cy="48%"
|
|
innerRadius="42%"
|
|
outerRadius="72%"
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
stroke="hsl(var(--border))"
|
|
strokeWidth={1}
|
|
label={({ name, percent }) =>
|
|
(percent ?? 0) >= 0.06
|
|
? `${String(name).slice(0, 14)}${String(name).length > 14 ? "…" : ""} ${(((percent ?? 0) as number) * 100).toFixed(0)}%`
|
|
: ""
|
|
}
|
|
labelLine={false}
|
|
>
|
|
{data.map((_, i) => (
|
|
<Cell key={`${bloco.parceiroId}-${i}`} fill={sliceColor(i)} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (!active || !payload?.[0]) return null;
|
|
const row = payload[0].payload as { name: string; value: number };
|
|
const pct = total > 0 ? (row.value / total) * 100 : 0;
|
|
return (
|
|
<div className="rounded-lg border border-border/80 bg-background/95 px-3 py-2 text-xs shadow-lg backdrop-blur">
|
|
<p className="font-medium">{row.name}</p>
|
|
<p className="tabular-nums text-muted-foreground">
|
|
{formatPontos(row.value)} pts · {pct.toFixed(1)}%
|
|
</p>
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
<Legend
|
|
verticalAlign="bottom"
|
|
height={44}
|
|
formatter={(value) => (
|
|
<span className="text-[11px] text-muted-foreground">{String(value).slice(0, 28)}</span>
|
|
)}
|
|
wrapperStyle={{ paddingTop: 4 }}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPontos() {
|
|
const { papel, me } = useAuthAccess();
|
|
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
|
const [dataInicio, setDataInicio] = useState(() => inicioDoMesAtual());
|
|
const [dataFim, setDataFim] = useState(() => fimDoMesAtual());
|
|
/** `null` = todos os parceiros (sem filtro na API). */
|
|
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[] | null>(null);
|
|
const [parceirosMenuOpen, setParceirosMenuOpen] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dados, setDados] = useState<DashboardPontosPorParceiro[]>([]);
|
|
|
|
const parceiroFixoSupervisor = papel === "supervisor" && me?.parceiroId;
|
|
|
|
const selectionKey = selectedPartnerIds === null ? "all" : [...selectedPartnerIds].sort().join(",");
|
|
const periodoKey = `${dataInicio.getTime()}-${dataFim.getTime()}`;
|
|
|
|
const loadParceiros = useCallback(async () => {
|
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
|
try {
|
|
const res = await fechamentoParceirosService.listarParceiros({
|
|
estaAtivo: "all",
|
|
page: 1,
|
|
perPage: 200,
|
|
});
|
|
const um = res.data.find((p) => p.id === me.parceiroId);
|
|
setParceiros(um ? [um] : []);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
|
toast.error(message);
|
|
setParceiros([]);
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
const lista = await fechamentoParceirosService.listarParceirosAtivos();
|
|
setParceiros(lista);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
|
toast.error(message);
|
|
setParceiros([]);
|
|
}
|
|
}, [me?.parceiroId, parceiroFixoSupervisor]);
|
|
|
|
const loadRelatorio = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
let parceiroIds: string[] | undefined;
|
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
|
parceiroIds = [me.parceiroId];
|
|
} else if (selectedPartnerIds !== null && selectedPartnerIds.length > 0) {
|
|
parceiroIds = selectedPartnerIds;
|
|
}
|
|
if (startOfDay(dataFim) < startOfDay(dataInicio)) {
|
|
toast.error("A data final não pode ser anterior à inicial.");
|
|
setDados([]);
|
|
return;
|
|
}
|
|
const res = await dashboardPontosPorClienteService.relatorioPontosPorCliente({
|
|
dataInicio: dateToApiYmd(dataInicio),
|
|
dataFim: dateToApiYmd(dataFim),
|
|
parceiroIds,
|
|
});
|
|
setDados(res);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "Erro ao carregar dashboard.";
|
|
toast.error(message);
|
|
setDados([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, periodoKey, selectionKey]);
|
|
|
|
useEffect(() => {
|
|
void loadParceiros();
|
|
}, [loadParceiros]);
|
|
|
|
useEffect(() => {
|
|
void loadRelatorio();
|
|
}, [loadRelatorio, me?.parceiroId, papel]);
|
|
|
|
const allParceiroIds = useMemo(() => parceiros.map((p) => p.id), [parceiros]);
|
|
|
|
const parceirosSelecaoLabel = useMemo(() => {
|
|
if (parceiroFixoSupervisor) {
|
|
const p = parceiros[0];
|
|
if (!p) return "Parceiro vinculado";
|
|
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
|
|
}
|
|
if (selectedPartnerIds === null) return "Todos os parceiros";
|
|
if (selectedPartnerIds.length === 1) {
|
|
const p = parceiros.find((x) => x.id === selectedPartnerIds[0]);
|
|
if (!p) return "1 parceiro";
|
|
return p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome;
|
|
}
|
|
return `${selectedPartnerIds.length} parceiros selecionados`;
|
|
}, [parceiroFixoSupervisor, parceiros, selectedPartnerIds]);
|
|
|
|
const toggleParceiro = useCallback(
|
|
(id: string, checked: boolean) => {
|
|
setSelectedPartnerIds((prev) => {
|
|
if (parceiroFixoSupervisor) return prev;
|
|
if (prev === null) {
|
|
if (checked) return null;
|
|
const next = allParceiroIds.filter((x) => x !== id);
|
|
if (next.length === 0) {
|
|
toast.error("Selecione ao menos um parceiro.");
|
|
return prev;
|
|
}
|
|
return next;
|
|
}
|
|
const set = new Set(prev);
|
|
if (checked) set.add(id);
|
|
else set.delete(id);
|
|
const arr = Array.from(set);
|
|
if (arr.length === 0) {
|
|
toast.error("Selecione ao menos um parceiro.");
|
|
return prev;
|
|
}
|
|
if (arr.length === allParceiroIds.length) return null;
|
|
return arr;
|
|
});
|
|
},
|
|
[allParceiroIds, parceiroFixoSupervisor],
|
|
);
|
|
|
|
const setTodosParceiros = useCallback(() => {
|
|
if (!parceiroFixoSupervisor) setSelectedPartnerIds(null);
|
|
}, [parceiroFixoSupervisor]);
|
|
|
|
const isParceiroChecked = (id: string) =>
|
|
selectedPartnerIds === null || (selectedPartnerIds?.includes(id) ?? false);
|
|
|
|
return (
|
|
<div className="relative mx-auto max-w-7xl space-y-8 p-4 pb-14 lg:p-8">
|
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-gradient-to-b from-primary/12 via-primary/5 to-transparent blur-3xl" />
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35 }}
|
|
className="relative flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6"
|
|
>
|
|
<div className="space-y-2">
|
|
<div className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
|
<Sparkles className="h-3.5 w-3.5" />
|
|
Intelligence Score
|
|
</div>
|
|
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight md:text-4xl">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-inner cyber-glow">
|
|
<LayoutDashboard className="h-6 w-6" />
|
|
</span>
|
|
Dashboard
|
|
</h1>
|
|
<p className="max-w-xl text-sm text-muted-foreground">
|
|
Distribuição de pontos por cliente — visão rápida do que cada parceiro entregou no período.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2 self-start border-primary/20 bg-background/80 shadow-sm backdrop-blur"
|
|
onClick={() => void loadRelatorio()}
|
|
>
|
|
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
|
Atualizar
|
|
</Button>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.05 }}
|
|
>
|
|
<Card className="overflow-hidden border-primary/10 shadow-xl">
|
|
<CardHeader className="border-b border-border/60 bg-muted/30 pb-4">
|
|
<CardTitle className="text-lg">Filtros</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-1 gap-4 pt-6 sm:grid-cols-2 sm:items-end lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.25fr)_auto] lg:gap-5">
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
|
Data inicial
|
|
</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full min-w-0 justify-start gap-2 text-left text-sm font-normal tabular-nums"
|
|
>
|
|
<CalendarIcon className="h-4 w-4 shrink-0" />
|
|
<span className="min-w-0 truncate">{format(dataInicio, "dd/MM/yyyy", { locale: ptBR })}</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={dataInicio}
|
|
onSelect={(d) => {
|
|
if (d) setDataInicio(d);
|
|
}}
|
|
locale={ptBR}
|
|
defaultMonth={dataInicio}
|
|
initialFocus
|
|
className="pointer-events-auto p-3"
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
<div className="flex min-w-0 flex-col gap-2">
|
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
|
Data final
|
|
</Label>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="h-11 w-full min-w-0 justify-start gap-2 text-left text-sm font-normal tabular-nums"
|
|
>
|
|
<CalendarIcon className="h-4 w-4 shrink-0" />
|
|
<span className="min-w-0 truncate">{format(dataFim, "dd/MM/yyyy", { locale: ptBR })}</span>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-auto p-0" align="start">
|
|
<Calendar
|
|
mode="single"
|
|
selected={dataFim}
|
|
onSelect={(d) => {
|
|
if (d) setDataFim(d);
|
|
}}
|
|
locale={ptBR}
|
|
defaultMonth={dataFim}
|
|
disabled={(date) => date < startOfDay(dataInicio)}
|
|
initialFocus
|
|
className="pointer-events-auto p-3"
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{!parceiroFixoSupervisor ? (
|
|
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
|
Parceiros
|
|
</Label>
|
|
<Popover open={parceirosMenuOpen} onOpenChange={setParceirosMenuOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={parceirosMenuOpen}
|
|
className="h-11 w-full min-w-0 justify-between gap-2 font-normal"
|
|
>
|
|
<span className="min-w-0 truncate text-left text-sm">{parceirosSelecaoLabel}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-[min(100vw-2rem,var(--radix-popover-trigger-width))] max-w-md p-0"
|
|
align="start"
|
|
>
|
|
<div className="space-y-2 border-b border-border/60 p-3">
|
|
<span className="text-sm font-semibold">Selecionar parceiros</span>
|
|
<div className="flex items-center gap-2 rounded-lg px-1 py-1 hover:bg-muted/50">
|
|
<Checkbox
|
|
id="par-todos"
|
|
checked={selectedPartnerIds === null}
|
|
onCheckedChange={(c) => {
|
|
if (c === true) {
|
|
setTodosParceiros();
|
|
return;
|
|
}
|
|
if (allParceiroIds.length <= 1) return;
|
|
setSelectedPartnerIds([allParceiroIds[0]]);
|
|
toast.info("Filtro por um parceiro. Use «Todos» para voltar a exibir todos.");
|
|
}}
|
|
/>
|
|
<label htmlFor="par-todos" className="cursor-pointer text-sm font-medium">
|
|
Todos os parceiros
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<Separator />
|
|
<ScrollArea className="h-[220px]">
|
|
<div className="space-y-1 p-3">
|
|
{parceiros.map((p) => (
|
|
<div
|
|
key={p.id}
|
|
className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-muted/50"
|
|
>
|
|
<Checkbox
|
|
id={`par-${p.id}`}
|
|
checked={isParceiroChecked(p.id)}
|
|
onCheckedChange={(c) => toggleParceiro(p.id, c === true)}
|
|
/>
|
|
<label htmlFor={`par-${p.id}`} className="flex-1 cursor-pointer truncate text-sm">
|
|
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
) : (
|
|
<div className="flex min-w-0 flex-col gap-2 sm:col-span-2 lg:col-span-1">
|
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
|
Parceiro
|
|
</Label>
|
|
<Select disabled value={me?.parceiroId ?? ""}>
|
|
<SelectTrigger className="h-11 w-full min-w-0">
|
|
<SelectValue placeholder="Parceiro vinculado" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{parceiros.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
|
</SelectItem>
|
|
))}
|
|
{parceiros.length === 0 && me?.parceiroId ? (
|
|
<SelectItem value={me.parceiroId}>Parceiro vinculado</SelectItem>
|
|
) : null}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-end sm:col-span-2 lg:col-span-1">
|
|
<Button type="button" className="h-11 w-full sm:w-auto lg:w-full" onClick={() => void loadRelatorio()}>
|
|
Aplicar
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
|
|
{loading && dados.length === 0 ? (
|
|
<div className="flex justify-center py-24">
|
|
<Loader2 className="h-12 w-12 animate-spin text-primary/60" />
|
|
</div>
|
|
) : dados.length === 0 ? (
|
|
<Card className="border-dashed">
|
|
<CardContent className="py-16 text-center text-muted-foreground">
|
|
Nenhum dado para os filtros atuais. Ajuste o período ou os parceiros.
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.35 }}
|
|
className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2"
|
|
>
|
|
{dados.map((bloco, idx) => (
|
|
<motion.div
|
|
key={bloco.parceiroId}
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35, delay: Math.min(idx * 0.06, 0.35) }}
|
|
>
|
|
<Card className="h-full overflow-hidden border-border/80 bg-card/95 shadow-lg transition-shadow hover:shadow-xl">
|
|
<CardHeader className="border-b border-border/50 bg-gradient-to-r from-primary/8 to-transparent pb-4">
|
|
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<CardTitle
|
|
className="min-w-0 max-w-full flex-1 truncate text-left text-base font-semibold leading-snug sm:text-lg"
|
|
title={bloco.parceiroNome}
|
|
>
|
|
{bloco.parceiroNome}
|
|
</CardTitle>
|
|
<span className="inline-flex w-fit shrink-0 self-start rounded-full bg-primary/15 px-3 py-1.5 text-sm font-semibold tabular-nums text-primary sm:self-center">
|
|
{formatPontos(bloco.totalParceiro)} pts
|
|
</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-6">
|
|
<DistribuicaoClientesDonut bloco={bloco} />
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|