atualizacoes API Integracao

This commit is contained in:
Vitex Tecnologia
2026-05-15 17:13:52 -03:00
parent 517c9751bc
commit b4622c9166
5 changed files with 458 additions and 178 deletions
@@ -39,6 +39,12 @@ function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
return row.parceiroNome;
}
/** Pontuação nas colunas da tabela: sempre centésimos (pt-BR). */
function formatPontuacaoDecimal(valor: number): string {
if (!Number.isFinite(valor)) return "—";
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function AsanaImportLoadingCard() {
return (
<div
@@ -406,7 +412,8 @@ export default function CompetenciaFechamentos() {
<TableRow>
<TableHead className="min-w-[100px]">Logo</TableHead>
<TableHead className="min-w-[280px]">Nome</TableHead>
<TableHead className="min-w-[140px]">Pontuação Total</TableHead>
<TableHead className="min-w-[140px] text-right">Pontuação Total</TableHead>
<TableHead className="min-w-[150px] text-right">Pontuação Aprovada</TableHead>
<TableHead className="min-w-[120px]">Status</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
@@ -414,7 +421,7 @@ export default function CompetenciaFechamentos() {
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="py-16">
<TableCell colSpan={6} className="py-16">
<div
className="flex flex-col items-center justify-center gap-4 text-center"
role="status"
@@ -443,7 +450,14 @@ export default function CompetenciaFechamentos() {
)}
</TableCell>
<TableCell className="font-medium">{getDisplayNome(row)}</TableCell>
<TableCell>{row.pontuacaoTotalEntregue}</TableCell>
<TableCell className="text-right tabular-nums">
{formatPontuacaoDecimal(Number(row.pontuacaoTotalEntregue))}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{row.status === "fechado"
? formatPontuacaoDecimal(Number(row.pontuacaoAprovada))
: "—"}
</TableCell>
<TableCell>
<Badge
className={
@@ -1,10 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LayoutDashboard, Loader2, RefreshCw } from "lucide-react";
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
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,
@@ -12,47 +19,128 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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 });
}
function primeiroDiaMesAtual(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
return `${y}-${m}-01`;
/** Primeiro dia do mês civil (hora local). */
function inicioDoMesAtual(ref = new Date()): Date {
return new Date(ref.getFullYear(), ref.getMonth(), 1);
}
function hojeIsoDate(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
/** Ú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(primeiroDiaMesAtual);
const [dataFim, setDataFim] = useState(hojeIsoDate);
const [parceiroId, setParceiroId] = useState<string>("all");
const [cliente, setCliente] = useState("");
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 clienteRef = useRef(cliente);
clienteRef.current = cliente;
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 {
@@ -63,12 +151,10 @@ export default function DashboardPontos() {
});
const um = res.data.find((p) => p.id === me.parceiroId);
setParceiros(um ? [um] : []);
setParceiroId(me.parceiroId);
} catch (e) {
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
toast.error(message);
setParceiros([]);
setParceiroId(me.parceiroId);
}
return;
}
@@ -85,17 +171,21 @@ export default function DashboardPontos() {
const loadRelatorio = useCallback(async () => {
try {
setLoading(true);
const pid =
parceiroFixoSupervisor && me?.parceiroId
? me.parceiroId
: parceiroId === "all"
? undefined
: parceiroId;
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: dataInicio.trim() || undefined,
dataFim: dataFim.trim() || undefined,
parceiroId: pid,
cliente: clienteRef.current.trim() || undefined,
dataInicio: dateToApiYmd(dataInicio),
dataFim: dateToApiYmd(dataFim),
parceiroIds,
});
setDados(res);
} catch (e) {
@@ -105,7 +195,7 @@ export default function DashboardPontos() {
} finally {
setLoading(false);
}
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, parceiroId]);
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, periodoKey, selectionKey]);
useEffect(() => {
void loadParceiros();
@@ -113,149 +203,313 @@ export default function DashboardPontos() {
useEffect(() => {
void loadRelatorio();
}, [dataFim, dataInicio, loadRelatorio, parceiroId, me?.parceiroId, papel]);
}, [loadRelatorio, me?.parceiroId, papel]);
const totalGeral = useMemo(
() => dados.reduce((acc, p) => acc + p.totalParceiro, 0),
[dados],
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="mx-auto max-w-6xl space-y-6 p-4 pb-10 lg:p-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<LayoutDashboard className="h-7 w-7 text-primary" />
<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="text-muted-foreground">
Pontos alocados por cliente (tarefas revisadas), por parceiro.
<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" onClick={() => void loadRelatorio()}>
<RefreshCw className="h-4 w-4" />
<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>
</div>
</motion.div>
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">Filtros</CardTitle>
<CardDescription>Período por data de conclusão da tarefa, ou data de criação se ainda não concluída.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="dash-data-ini">Data início</Label>
<Input
id="dash-data-ini"
type="date"
value={dataInicio}
onChange={(e) => setDataInicio(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dash-data-fim">Data fim</Label>
<Input id="dash-data-fim" type="date" value={dataFim} onChange={(e) => setDataFim(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Parceiro</Label>
<Select
value={parceiroId}
onValueChange={setParceiroId}
disabled={Boolean(parceiroFixoSupervisor)}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{!parceiroFixoSupervisor ? (
<SelectItem value="all">Todos os parceiros</SelectItem>
) : null}
{parceiroFixoSupervisor && parceiros.length === 0 && me?.parceiroId ? (
<SelectItem value={me.parceiroId}>Parceiro vinculado</SelectItem>
) : null}
{parceiros.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="dash-cliente">Cliente (contém)</Label>
<Input
id="dash-cliente"
placeholder="Nome do cliente"
value={cliente}
onChange={(e) => setCliente(e.target.value)}
/>
</div>
</CardContent>
<CardContent className="pt-0">
<Button type="button" onClick={() => void loadRelatorio()}>
Aplicar filtros
</Button>
</CardContent>
</Card>
<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>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-lg">Resumo</CardTitle>
<CardDescription>Soma dos pontos no período e filtros atuais.</CardDescription>
</div>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
) : (
<span className="text-lg font-semibold tabular-nums">{formatPontos(totalGeral)} pts</span>
)}
</CardHeader>
</Card>
<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-16">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
<div className="flex justify-center py-24">
<Loader2 className="h-12 w-12 animate-spin text-primary/60" />
</div>
) : dados.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Nenhum dado para os filtros selecionados.
<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>
) : (
<div className="space-y-6">
{dados.map((bloco) => (
<Card key={bloco.parceiroId}>
<CardHeader className="pb-2">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-base">{bloco.parceiroNome}</CardTitle>
<span className="text-sm font-medium text-muted-foreground tabular-nums">
Total: {formatPontos(bloco.totalParceiro)} pts
</span>
</div>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Cliente</TableHead>
<TableHead className="text-right">Pontos</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bloco.linhas.map((linha) => (
<TableRow key={`${bloco.parceiroId}-${linha.cliente}`}>
<TableCell>{linha.cliente}</TableCell>
<TableCell className="text-right tabular-nums">{formatPontos(linha.pontos)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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>
))}
</div>
</motion.div>
)}
</div>
);
@@ -12,9 +12,9 @@ import {
Plus,
Repeat,
RotateCcw,
Sigma,
Target,
Trash2,
TrendingUp,
User,
Wallet,
} from "lucide-react";
@@ -307,10 +307,13 @@ 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 pontosAprovados = aprovadas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
const pontosTotalMes = tarefas.reduce((acc, t) => acc + Number(t.pontuacao || 0), 0);
return {
pontos,
aprovadas: aprovadas.length,
/** Soma das pontuações em tarefas marcadas como revisadas (equivale ao que entra no fechamento). */
pontos: pontosAprovados,
/** Soma de todas as pontuações das tarefas listadas no fechamento (mês). */
pontosTotalMes,
};
}, [tarefas]);
const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
@@ -371,11 +374,7 @@ export default function FechamentoDetalhes() {
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);
setTarefas(tarefasOrdenadas);
setEstadoRevisaoInicial(estadoInicialBanco);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
@@ -1109,17 +1108,21 @@ export default function FechamentoDetalhes() {
<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" />
<CardDescription className="text-[11px] uppercase tracking-wide">
Pontuação total do mês
</CardDescription>
<Sigma className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
<CardTitle className="text-3xl">{formatPontos(totais.pontosTotalMes)}</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" />
<CardDescription className="text-[11px] uppercase tracking-wide">
Pontuação aprovada
</CardDescription>
<Target className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
</CardHeader>
+1
View File
@@ -26,6 +26,7 @@ export type FechamentoDaCompetenciaItem = {
status: "em_aberto" | "fechado";
versao: number;
pontuacaoTotalEntregue: number;
pontuacaoAprovada: number;
pontuacaoMeta: number;
pontuacaoPaga: number;
pontuacaoBanco: number;
@@ -27,8 +27,10 @@ type ApiErrorShape = {
export type RelatorioPontosPorClienteParams = {
dataInicio?: string;
dataFim?: string;
/** Um único parceiro (legado; preferir `parceiroIds`). */
parceiroId?: string;
cliente?: string;
/** Vários parceiros (CSV na query). Se vazio/ausente com `parceiroId`, usa só o id único. */
parceiroIds?: string[];
};
class DashboardPontosPorClienteService {
@@ -58,8 +60,14 @@ class DashboardPontosPorClienteService {
params: {
data_inicio: params.dataInicio?.trim() || undefined,
data_fim: params.dataFim?.trim() || undefined,
parceiro_id: params.parceiroId?.trim() || undefined,
cliente: params.cliente?.trim() || undefined,
parceiro_ids:
params.parceiroIds && params.parceiroIds.length > 0
? params.parceiroIds.join(",")
: undefined,
parceiro_id:
(!params.parceiroIds || params.parceiroIds.length === 0) && params.parceiroId?.trim()
? params.parceiroId.trim()
: undefined,
},
},
);