feat(ui): improve chart spacing and labels; format hours <1h as minutes; unify date format to dd/MM (ticks) and dd/MM/yyyy (tooltips); fix tooltips labels ('Total', 'Resolvidos')
This commit is contained in:
parent
4b4c0d8e69
commit
f255a4c780
6 changed files with 133 additions and 50 deletions
|
|
@ -24,6 +24,7 @@ import {
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
|
import { formatDateDM, formatDateDMY } from "@/lib/utils"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -233,24 +234,13 @@ export function ChartAreaInteractive() {
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => formatDateDM(new Date(value))}
|
||||||
const date = new Date(value)
|
|
||||||
return date.toLocaleDateString("pt-BR", {
|
|
||||||
month: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={false}
|
cursor={false}
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(value) =>
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
||||||
new Date(value).toLocaleDateString("pt-BR", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "long",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
indicator="dot"
|
indicator="dot"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { formatDateDM, formatDateDMY } from "@/lib/utils"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
type SeriesPoint = { date: string; opened: number; resolved: number }
|
type SeriesPoint = { date: string; opened: number; resolved: number }
|
||||||
|
|
@ -99,10 +100,16 @@ export function ChartOpenedResolved() {
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={false}
|
cursor={false}
|
||||||
content={<ChartTooltipContent indicator="line" />}
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator="line"
|
||||||
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Line dataKey="opened" type="monotone" stroke="var(--color-opened)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
<Line dataKey="opened" type="monotone" stroke="var(--color-opened)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
||||||
<Line dataKey="resolved" type="monotone" stroke="var(--color-resolved)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
<Line dataKey="resolved" type="monotone" stroke="var(--color-resolved)" strokeWidth={2} dot={{ r: 2 }} strokeLinecap="round" />
|
||||||
|
|
|
||||||
|
|
@ -206,11 +206,23 @@ export function BacklogReport() {
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
<BarChart data={data.queueCounts.map((q: { name: string; total: number }) => ({ name: q.name, total: q.total }))}>
|
<BarChart
|
||||||
|
data={data.queueCounts.map((q: { name: string; total: number }) => ({ name: q.name, total: q.total }))}
|
||||||
|
margin={{ left: 12, right: 12, bottom: 28 }}
|
||||||
|
barCategoryGap={16}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={14}
|
||||||
|
interval={0}
|
||||||
|
angle={-30}
|
||||||
|
height={68}
|
||||||
|
/>
|
||||||
<Bar dataKey="total" fill="var(--chart-5)" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="total" fill="var(--chart-5)" radius={[4, 4, 0, 0]} />
|
||||||
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Abertos" />} />
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Total" />} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { formatHoursCompact } from "@/lib/utils"
|
||||||
|
|
||||||
type HoursItem = {
|
type HoursItem = {
|
||||||
companyId: string
|
companyId: string
|
||||||
|
|
@ -111,12 +112,25 @@ export function HoursReport() {
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((r) => ({ name: r.name, internas: r.internal, externas: r.external }))}
|
.map((r) => ({ name: r.name, internas: r.internal, externas: r.external }))}
|
||||||
|
margin={{ left: 12, right: 12, bottom: 28 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={14} interval={0} angle={-30} height={68} />
|
||||||
<Bar dataKey="internas" stackId="a" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="internas" stackId="a" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="externas" stackId="a" fill="var(--chart-2)" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="externas" stackId="a" fill="var(--chart-2)" radius={[4, 4, 0, 0]} />
|
||||||
<ChartTooltip content={<ChartTooltipContent className="w-[200px]" />} />
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[200px]"
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">{name}</span>
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">{formatHoursCompact(Number(value))}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
|
|
@ -165,13 +179,13 @@ export function HoursReport() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{[
|
{[
|
||||||
{ key: "internal", label: "Horas internas", value: numberFormatter.format(totals.internal) },
|
{ key: "internal", label: "Horas internas", value: formatHoursCompact(totals.internal) },
|
||||||
{ key: "external", label: "Horas externas", value: numberFormatter.format(totals.external) },
|
{ key: "external", label: "Horas externas", value: formatHoursCompact(totals.external) },
|
||||||
{ key: "total", label: "Total acumulado", value: numberFormatter.format(totals.total) },
|
{ key: "total", label: "Total acumulado", value: formatHoursCompact(totals.total) },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div key={item.key} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
|
<div key={item.key} className="rounded-xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm">
|
||||||
<p className="text-xs uppercase tracking-wide text-neutral-500">{item.label}</p>
|
<p className="text-xs uppercase tracking-wide text-neutral-500">{item.label}</p>
|
||||||
<p className="mt-2 text-2xl font-semibold text-neutral-900">{item.value} h</p>
|
<p className="mt-2 text-2xl font-semibold text-neutral-900">{item.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -199,17 +213,17 @@ export function HoursReport() {
|
||||||
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
||||||
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.internal)} h</span>
|
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.internal)}</span>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
|
||||||
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.external)} h</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs uppercase text-neutral-500">Total</span>
|
|
||||||
<span className="font-semibold text-neutral-900">{numberFormatter.format(row.total)} h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
||||||
|
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.external)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Total</span>
|
||||||
|
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs text-neutral-500">
|
<div className="flex items-center justify-between text-xs text-neutral-500">
|
||||||
<span>Contratadas/mês</span>
|
<span>Contratadas/mês</span>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
|
||||||
|
|
||||||
function formatMinutes(value: number | null) {
|
function formatMinutes(value: number | null) {
|
||||||
if (value === null) return "—"
|
if (value === null) return "—"
|
||||||
|
|
@ -214,8 +215,22 @@ export function SlaReport() {
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
<XAxis
|
||||||
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" />} />
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[180px]"
|
||||||
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Area dataKey="opened" type="natural" fill="url(#fillOpened)" stroke="var(--chart-1)" name="Abertos" />
|
<Area dataKey="opened" type="natural" fill="url(#fillOpened)" stroke="var(--chart-1)" name="Abertos" />
|
||||||
<Area dataKey="resolved" type="natural" fill="url(#fillResolved)" stroke="var(--chart-2)" name="Resolvidos" />
|
<Area dataKey="resolved" type="natural" fill="url(#fillResolved)" stroke="var(--chart-2)" name="Resolvidos" />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
|
@ -240,8 +255,22 @@ export function SlaReport() {
|
||||||
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
<AreaChart data={channelsSeries.points.map((p) => ({ date: p.date, ...p.values }))}>
|
<AreaChart data={channelsSeries.points.map((p) => ({ date: p.date, ...p.values }))}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} />
|
<XAxis
|
||||||
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
tickFormatter={(v) => formatDateDM(new Date(v))}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[220px]"
|
||||||
|
labelFormatter={(value) => formatDateDMY(new Date(value as string))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{channelsSeries.channels.map((ch, idx) => (
|
{channelsSeries.channels.map((ch, idx) => (
|
||||||
<Area key={ch} dataKey={ch} type="natural" stackId="a" stroke={`var(--chart-${(idx % 5) + 1})`} fill={`var(--chart-${(idx % 5) + 1})`} />
|
<Area key={ch} dataKey={ch} type="natural" stackId="a" stroke={`var(--chart-${(idx % 5) + 1})`} fill={`var(--chart-${(idx % 5) + 1})`} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -272,11 +301,14 @@ export function SlaReport() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-neutral-500">Resolvidos por agente</p>
|
<p className="text-xs text-neutral-500">Resolvidos por agente</p>
|
||||||
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
<BarChart data={agents.items.slice(0, 10).map((a) => ({ name: a.name || a.email || 'Agente', resolved: a.resolved }))} margin={{ left: 12, right: 12 }}>
|
<BarChart
|
||||||
|
data={agents.items.slice(0, 10).map((a) => ({ name: a.name || a.email || 'Agente', resolved: a.resolved }))}
|
||||||
|
margin={{ left: 12, right: 12, bottom: 28 }}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={8} interval={0} angle={-30} height={60} />
|
<XAxis dataKey="name" tickLine={false} axisLine={false} tickMargin={14} interval={0} angle={-30} height={68} />
|
||||||
<Bar dataKey="resolved" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="resolved" fill="var(--chart-1)" radius={[4, 4, 0, 0]} />
|
||||||
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="resolved" />} />
|
<ChartTooltip content={<ChartTooltipContent className="w-[180px]" nameKey="Resolvidos" />} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -286,7 +318,7 @@ export function SlaReport() {
|
||||||
{agents.items.slice(0, 10).map((a) => (
|
{agents.items.slice(0, 10).map((a) => (
|
||||||
<li key={a.agentId} className="flex items-center justify-between px-3 py-2 text-sm">
|
<li key={a.agentId} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
<span className="truncate">{a.name || a.email || 'Agente'}</span>
|
<span className="truncate">{a.name || a.email || 'Agente'}</span>
|
||||||
<span className="text-neutral-700">{a.workedHours.toFixed(1)} h</span>
|
<span className="text-neutral-700">{formatHoursCompact(a.workedHours)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,31 @@ import { twMerge } from "tailwind-merge"
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format hours so that values under 1h are shown in minutes.
|
||||||
|
// Examples: 0.06 -> "4 min"; 0.5 -> "30 min"; 1.25 -> "1,25 h" (pt-BR)
|
||||||
|
export function formatHoursCompact(value: number, locale: string = "pt-BR"): string {
|
||||||
|
const hours = Number(value) || 0
|
||||||
|
if (hours < 1 && hours > 0) {
|
||||||
|
const mins = Math.round(hours * 60)
|
||||||
|
return `${mins} min`
|
||||||
|
}
|
||||||
|
const nf = new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
return `${nf.format(hours)} h`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date to dd/MM or dd/MM/yyyy. Accepts Date or ISO-like string (YYYY-MM-DD).
|
||||||
|
export function formatDateDM(value: Date | string | number): string {
|
||||||
|
const d = typeof value === "string" || typeof value === "number" ? new Date(value) : value
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0")
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
return `${dd}/${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateDMY(value: Date | string | number): string {
|
||||||
|
const d = typeof value === "string" || typeof value === "number" ? new Date(value) : value
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0")
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
const yyyy = d.getFullYear()
|
||||||
|
return `${dd}/${mm}/${yyyy}`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue