atualizacoes API Integracao
This commit is contained in:
@@ -39,6 +39,12 @@ function getDisplayNome(row: FechamentoDaCompetenciaItem): string {
|
|||||||
return row.parceiroNome;
|
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() {
|
function AsanaImportLoadingCard() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -406,7 +412,8 @@ export default function CompetenciaFechamentos() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="min-w-[100px]">Logo</TableHead>
|
<TableHead className="min-w-[100px]">Logo</TableHead>
|
||||||
<TableHead className="min-w-[280px]">Nome</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="min-w-[120px]">Status</TableHead>
|
||||||
<TableHead className="text-center">Ações</TableHead>
|
<TableHead className="text-center">Ações</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -414,7 +421,7 @@ export default function CompetenciaFechamentos() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="py-16">
|
<TableCell colSpan={6} className="py-16">
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center justify-center gap-4 text-center"
|
className="flex flex-col items-center justify-center gap-4 text-center"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -443,7 +450,14 @@ export default function CompetenciaFechamentos() {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">{getDisplayNome(row)}</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>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { LayoutDashboard, Loader2, RefreshCw } from "lucide-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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -12,47 +19,128 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
import {
|
import {
|
||||||
dashboardPontosPorClienteService,
|
dashboardPontosPorClienteService,
|
||||||
type DashboardPontosPorParceiro,
|
type DashboardPontosPorParceiro,
|
||||||
} from "@/services/fechamento/dashboardPontosPorCliente";
|
} from "@/services/fechamento/dashboardPontosPorCliente";
|
||||||
import { fechamentoParceirosService, type ParceiroItem } from "@/services/fechamento/parceiros";
|
import { fechamentoParceirosService, type ParceiroItem } from "@/services/fechamento/parceiros";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function formatPontos(valor: number): string {
|
function formatPontos(valor: number): string {
|
||||||
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function primeiroDiaMesAtual(): string {
|
/** Primeiro dia do mês civil (hora local). */
|
||||||
const d = new Date();
|
function inicioDoMesAtual(ref = new Date()): Date {
|
||||||
const y = d.getFullYear();
|
return new Date(ref.getFullYear(), ref.getMonth(), 1);
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${y}-${m}-01`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hojeIsoDate(): string {
|
/** Último dia do mês civil (hora local). */
|
||||||
const d = new Date();
|
function fimDoMesAtual(ref = new Date()): Date {
|
||||||
const y = d.getFullYear();
|
return new Date(ref.getFullYear(), ref.getMonth() + 1, 0);
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
}
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${day}`;
|
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() {
|
export default function DashboardPontos() {
|
||||||
const { papel, me } = useAuthAccess();
|
const { papel, me } = useAuthAccess();
|
||||||
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
const [parceiros, setParceiros] = useState<ParceiroItem[]>([]);
|
||||||
const [dataInicio, setDataInicio] = useState(primeiroDiaMesAtual);
|
const [dataInicio, setDataInicio] = useState(() => inicioDoMesAtual());
|
||||||
const [dataFim, setDataFim] = useState(hojeIsoDate);
|
const [dataFim, setDataFim] = useState(() => fimDoMesAtual());
|
||||||
const [parceiroId, setParceiroId] = useState<string>("all");
|
/** `null` = todos os parceiros (sem filtro na API). */
|
||||||
const [cliente, setCliente] = useState("");
|
const [selectedPartnerIds, setSelectedPartnerIds] = useState<string[] | null>(null);
|
||||||
|
const [parceirosMenuOpen, setParceirosMenuOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dados, setDados] = useState<DashboardPontosPorParceiro[]>([]);
|
const [dados, setDados] = useState<DashboardPontosPorParceiro[]>([]);
|
||||||
const clienteRef = useRef(cliente);
|
|
||||||
clienteRef.current = cliente;
|
|
||||||
|
|
||||||
const parceiroFixoSupervisor = papel === "supervisor" && me?.parceiroId;
|
const parceiroFixoSupervisor = papel === "supervisor" && me?.parceiroId;
|
||||||
|
|
||||||
|
const selectionKey = selectedPartnerIds === null ? "all" : [...selectedPartnerIds].sort().join(",");
|
||||||
|
const periodoKey = `${dataInicio.getTime()}-${dataFim.getTime()}`;
|
||||||
|
|
||||||
const loadParceiros = useCallback(async () => {
|
const loadParceiros = useCallback(async () => {
|
||||||
if (parceiroFixoSupervisor && me?.parceiroId) {
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
||||||
try {
|
try {
|
||||||
@@ -63,12 +151,10 @@ export default function DashboardPontos() {
|
|||||||
});
|
});
|
||||||
const um = res.data.find((p) => p.id === me.parceiroId);
|
const um = res.data.find((p) => p.id === me.parceiroId);
|
||||||
setParceiros(um ? [um] : []);
|
setParceiros(um ? [um] : []);
|
||||||
setParceiroId(me.parceiroId);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
const message = e instanceof Error ? e.message : "Erro ao carregar parceiros.";
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
setParceiros([]);
|
setParceiros([]);
|
||||||
setParceiroId(me.parceiroId);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,17 +171,21 @@ export default function DashboardPontos() {
|
|||||||
const loadRelatorio = useCallback(async () => {
|
const loadRelatorio = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const pid =
|
let parceiroIds: string[] | undefined;
|
||||||
parceiroFixoSupervisor && me?.parceiroId
|
if (parceiroFixoSupervisor && me?.parceiroId) {
|
||||||
? me.parceiroId
|
parceiroIds = [me.parceiroId];
|
||||||
: parceiroId === "all"
|
} else if (selectedPartnerIds !== null && selectedPartnerIds.length > 0) {
|
||||||
? undefined
|
parceiroIds = selectedPartnerIds;
|
||||||
: parceiroId;
|
}
|
||||||
|
if (startOfDay(dataFim) < startOfDay(dataInicio)) {
|
||||||
|
toast.error("A data final não pode ser anterior à inicial.");
|
||||||
|
setDados([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await dashboardPontosPorClienteService.relatorioPontosPorCliente({
|
const res = await dashboardPontosPorClienteService.relatorioPontosPorCliente({
|
||||||
dataInicio: dataInicio.trim() || undefined,
|
dataInicio: dateToApiYmd(dataInicio),
|
||||||
dataFim: dataFim.trim() || undefined,
|
dataFim: dateToApiYmd(dataFim),
|
||||||
parceiroId: pid,
|
parceiroIds,
|
||||||
cliente: clienteRef.current.trim() || undefined,
|
|
||||||
});
|
});
|
||||||
setDados(res);
|
setDados(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -105,7 +195,7 @@ export default function DashboardPontos() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, parceiroId]);
|
}, [dataFim, dataInicio, me?.parceiroId, parceiroFixoSupervisor, periodoKey, selectionKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadParceiros();
|
void loadParceiros();
|
||||||
@@ -113,149 +203,313 @@ export default function DashboardPontos() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadRelatorio();
|
void loadRelatorio();
|
||||||
}, [dataFim, dataInicio, loadRelatorio, parceiroId, me?.parceiroId, papel]);
|
}, [loadRelatorio, me?.parceiroId, papel]);
|
||||||
|
|
||||||
const totalGeral = useMemo(
|
const allParceiroIds = useMemo(() => parceiros.map((p) => p.id), [parceiros]);
|
||||||
() => dados.reduce((acc, p) => acc + p.totalParceiro, 0),
|
|
||||||
[dados],
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-6 p-4 pb-10 lg:p-8">
|
<div className="relative mx-auto max-w-7xl space-y-8 p-4 pb-14 lg:p-8">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<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" />
|
||||||
<div>
|
|
||||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
<motion.div
|
||||||
<LayoutDashboard className="h-7 w-7 text-primary" />
|
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
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="max-w-xl text-sm text-muted-foreground">
|
||||||
Pontos alocados por cliente (tarefas revisadas), por parceiro.
|
Distribuição de pontos por cliente — visão rápida do que cada parceiro entregou no período.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" className="gap-2 self-start" onClick={() => void loadRelatorio()}>
|
<Button
|
||||||
<RefreshCw className="h-4 w-4" />
|
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
|
Atualizar
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<Card>
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
<CardHeader className="pb-4">
|
<Label className="text-[11px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground sm:text-xs">
|
||||||
<CardTitle className="text-lg">Filtros</CardTitle>
|
Data final
|
||||||
<CardDescription>Período por data de conclusão da tarefa, ou data de criação se ainda não concluída.</CardDescription>
|
</Label>
|
||||||
</CardHeader>
|
<Popover>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<PopoverTrigger asChild>
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="dash-data-ini">Data início</Label>
|
type="button"
|
||||||
<Input
|
variant="outline"
|
||||||
id="dash-data-ini"
|
className="h-11 w-full min-w-0 justify-start gap-2 text-left text-sm font-normal tabular-nums"
|
||||||
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>
|
<CalendarIcon className="h-4 w-4 shrink-0" />
|
||||||
<SelectValue placeholder="Todos" />
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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) => (
|
{parceiros.map((p) => (
|
||||||
<SelectItem key={p.id} value={p.id}>
|
<SelectItem key={p.id} value={p.id}>
|
||||||
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
{p.codinome?.trim() ? `${p.nome} (${p.codinome.trim()})` : p.nome}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{parceiros.length === 0 && me?.parceiroId ? (
|
||||||
|
<SelectItem value={me.parceiroId}>Parceiro vinculado</SelectItem>
|
||||||
|
) : null}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
<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>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{loading && dados.length === 0 ? (
|
{loading && dados.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-24">
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary/60" />
|
||||||
</div>
|
</div>
|
||||||
) : dados.length === 0 ? (
|
) : dados.length === 0 ? (
|
||||||
<Card>
|
<Card className="border-dashed">
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-16 text-center text-muted-foreground">
|
||||||
Nenhum dado para os filtros selecionados.
|
Nenhum dado para os filtros atuais. Ajuste o período ou os parceiros.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<motion.div
|
||||||
{dados.map((bloco) => (
|
initial={{ opacity: 0 }}
|
||||||
<Card key={bloco.parceiroId}>
|
animate={{ opacity: 1 }}
|
||||||
<CardHeader className="pb-2">
|
transition={{ duration: 0.35 }}
|
||||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2"
|
||||||
<CardTitle className="text-base">{bloco.parceiroNome}</CardTitle>
|
>
|
||||||
<span className="text-sm font-medium text-muted-foreground tabular-nums">
|
{dados.map((bloco, idx) => (
|
||||||
Total: {formatPontos(bloco.totalParceiro)} pts
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-6">
|
||||||
<Table>
|
<DistribuicaoClientesDonut bloco={bloco} />
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Repeat,
|
Repeat,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Sigma,
|
||||||
Target,
|
Target,
|
||||||
Trash2,
|
Trash2,
|
||||||
TrendingUp,
|
|
||||||
User,
|
User,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -307,10 +307,13 @@ export default function FechamentoDetalhes() {
|
|||||||
|
|
||||||
const totais = useMemo(() => {
|
const totais = useMemo(() => {
|
||||||
const aprovadas = tarefas.filter((t) => t.estaRevisada);
|
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 {
|
return {
|
||||||
pontos,
|
/** Soma das pontuações em tarefas marcadas como revisadas (equivale ao que entra no fechamento). */
|
||||||
aprovadas: aprovadas.length,
|
pontos: pontosAprovados,
|
||||||
|
/** Soma de todas as pontuações das tarefas listadas no fechamento (mês). */
|
||||||
|
pontosTotalMes,
|
||||||
};
|
};
|
||||||
}, [tarefas]);
|
}, [tarefas]);
|
||||||
const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
|
const pontuacaoPagaNumero = parsePontuacaoInput(pontuacaoPagaInput || "0");
|
||||||
@@ -371,11 +374,7 @@ export default function FechamentoDetalhes() {
|
|||||||
return timeA - timeB;
|
return timeA - timeB;
|
||||||
});
|
});
|
||||||
const estadoInicialBanco = Object.fromEntries(tarefasOrdenadas.map((tarefa) => [tarefa.id, Boolean(tarefa.estaRevisada)]));
|
const estadoInicialBanco = Object.fromEntries(tarefasOrdenadas.map((tarefa) => [tarefa.id, Boolean(tarefa.estaRevisada)]));
|
||||||
const tarefasDefaultRevisadas = tarefasOrdenadas.map((tarefa) => ({
|
setTarefas(tarefasOrdenadas);
|
||||||
...tarefa,
|
|
||||||
estaRevisada: true,
|
|
||||||
}));
|
|
||||||
setTarefas(tarefasDefaultRevisadas);
|
|
||||||
setEstadoRevisaoInicial(estadoInicialBanco);
|
setEstadoRevisaoInicial(estadoInicialBanco);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Erro ao carregar detalhes do fechamento.";
|
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">
|
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||||
<CardHeader className="space-y-2 p-4">
|
<CardHeader className="space-y-2 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<CardDescription className="text-[11px] uppercase tracking-wide">Tarefas aprovadas</CardDescription>
|
<CardDescription className="text-[11px] uppercase tracking-wide">
|
||||||
<Target className="h-4 w-4 text-muted-foreground" />
|
Pontuação total do mês
|
||||||
|
</CardDescription>
|
||||||
|
<Sigma className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl">{totais.aprovadas}</CardTitle>
|
<CardTitle className="text-3xl">{formatPontos(totais.pontosTotalMes)}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
<Card className="border-border/70 bg-card shadow-sm transition hover:shadow-md">
|
||||||
<CardHeader className="space-y-2 p-4">
|
<CardHeader className="space-y-2 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<CardDescription className="text-[11px] uppercase tracking-wide">Pontuação aprovada</CardDescription>
|
<CardDescription className="text-[11px] uppercase tracking-wide">
|
||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
Pontuação aprovada
|
||||||
|
</CardDescription>
|
||||||
|
<Target className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
|
<CardTitle className="text-3xl">{formatPontos(totais.pontos)}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type FechamentoDaCompetenciaItem = {
|
|||||||
status: "em_aberto" | "fechado";
|
status: "em_aberto" | "fechado";
|
||||||
versao: number;
|
versao: number;
|
||||||
pontuacaoTotalEntregue: number;
|
pontuacaoTotalEntregue: number;
|
||||||
|
pontuacaoAprovada: number;
|
||||||
pontuacaoMeta: number;
|
pontuacaoMeta: number;
|
||||||
pontuacaoPaga: number;
|
pontuacaoPaga: number;
|
||||||
pontuacaoBanco: number;
|
pontuacaoBanco: number;
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ type ApiErrorShape = {
|
|||||||
export type RelatorioPontosPorClienteParams = {
|
export type RelatorioPontosPorClienteParams = {
|
||||||
dataInicio?: string;
|
dataInicio?: string;
|
||||||
dataFim?: string;
|
dataFim?: string;
|
||||||
|
/** Um único parceiro (legado; preferir `parceiroIds`). */
|
||||||
parceiroId?: string;
|
parceiroId?: string;
|
||||||
cliente?: string;
|
/** Vários parceiros (CSV na query). Se vazio/ausente com `parceiroId`, usa só o id único. */
|
||||||
|
parceiroIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
class DashboardPontosPorClienteService {
|
class DashboardPontosPorClienteService {
|
||||||
@@ -58,8 +60,14 @@ class DashboardPontosPorClienteService {
|
|||||||
params: {
|
params: {
|
||||||
data_inicio: params.dataInicio?.trim() || undefined,
|
data_inicio: params.dataInicio?.trim() || undefined,
|
||||||
data_fim: params.dataFim?.trim() || undefined,
|
data_fim: params.dataFim?.trim() || undefined,
|
||||||
parceiro_id: params.parceiroId?.trim() || undefined,
|
parceiro_ids:
|
||||||
cliente: params.cliente?.trim() || undefined,
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user