feat: refresh dashboards experience

This commit is contained in:
Esdras Renan 2025-11-06 01:40:10 -03:00
parent 1900f65e5e
commit d7d6b748cc
9 changed files with 1626 additions and 281 deletions

View file

@ -1,4 +1,4 @@
import { v } from "convex/values" import { ConvexError, v } from "convex/values"
import type { Doc, Id } from "./_generated/dataModel" import type { Doc, Id } from "./_generated/dataModel"
import { query } from "./_generated/server" import { query } from "./_generated/server"
@ -90,6 +90,178 @@ function filterTicketsByQueue<T extends { queueId?: Id<"queues"> | null }>(
}) })
} }
type AgentStatsRaw = {
agentId: Id<"users">
name: string | null
email: string | null
open: number
paused: number
resolved: number
totalSla: number
compliantSla: number
resolutionMinutes: number[]
firstResponseMinutes: number[]
}
type AgentStatsComputed = {
agentId: string
name: string | null
email: string | null
open: number
paused: number
resolved: number
slaRate: number | null
avgResolutionMinutes: number | null
avgFirstResponseMinutes: number | null
totalSla: number
compliantSla: number
}
function average(values: number[]): number | null {
if (!values || values.length === 0) return null
const sum = values.reduce((acc, value) => acc + value, 0)
return sum / values.length
}
function isTicketCompliant(ticket: Doc<"tickets">, now: number) {
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null
if (dueAt) {
if (resolvedAt) {
return resolvedAt <= dueAt
}
return dueAt >= now
}
return resolvedAt !== null
}
function ensureAgentStats(map: Map<string, AgentStatsRaw>, ticket: Doc<"tickets">): AgentStatsRaw | null {
const assigneeId = ticket.assigneeId
if (!assigneeId) return null
const key = String(assigneeId)
let stats = map.get(key)
const snapshot = ticket.assigneeSnapshot as { name?: string | null; email?: string | null } | undefined
const snapshotName = snapshot?.name ?? null
const snapshotEmail = snapshot?.email ?? null
if (!stats) {
stats = {
agentId: assigneeId,
name: snapshotName,
email: snapshotEmail,
open: 0,
paused: 0,
resolved: 0,
totalSla: 0,
compliantSla: 0,
resolutionMinutes: [],
firstResponseMinutes: [],
}
map.set(key, stats)
} else {
if (!stats.name && snapshotName) stats.name = snapshotName
if (!stats.email && snapshotEmail) stats.email = snapshotEmail
}
return stats
}
async function computeAgentStats(
ctx: QueryCtx,
tenantId: string,
viewer: Viewer,
rangeDays: number,
agentFilter?: Id<"users">,
) {
const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer)
const end = new Date()
end.setUTCHours(0, 0, 0, 0)
const endMs = end.getTime() + ONE_DAY_MS
const startMs = endMs - rangeDays * ONE_DAY_MS
const statsMap = new Map<string, AgentStatsRaw>()
const matchesFilter = (ticket: Doc<"tickets">) => {
if (!ticket.assigneeId) return false
if (agentFilter && ticket.assigneeId !== agentFilter) return false
return true
}
for (const ticket of scopedTickets) {
if (!matchesFilter(ticket)) continue
const stats = ensureAgentStats(statsMap, ticket)
if (!stats) continue
const status = normalizeStatus(ticket.status)
if (status === "PAUSED") {
stats.paused += 1
} else if (OPEN_STATUSES.has(status)) {
stats.open += 1
}
}
const inRange = scopedTickets.filter(
(ticket) => matchesFilter(ticket) && ticket.createdAt >= startMs && ticket.createdAt < endMs,
)
const now = Date.now()
for (const ticket of inRange) {
const stats = ensureAgentStats(statsMap, ticket)
if (!stats) continue
stats.totalSla += 1
if (isTicketCompliant(ticket, now)) {
stats.compliantSla += 1
}
const status = normalizeStatus(ticket.status)
if (
status === "RESOLVED" &&
typeof ticket.resolvedAt === "number" &&
ticket.resolvedAt >= startMs &&
ticket.resolvedAt < endMs
) {
stats.resolved += 1
stats.resolutionMinutes.push((ticket.resolvedAt - ticket.createdAt) / 60000)
}
if (
typeof ticket.firstResponseAt === "number" &&
ticket.firstResponseAt >= startMs &&
ticket.firstResponseAt < endMs
) {
stats.firstResponseMinutes.push((ticket.firstResponseAt - ticket.createdAt) / 60000)
}
}
const agentIds = Array.from(statsMap.keys()) as string[]
if (agentIds.length > 0) {
const docs = await Promise.all(agentIds.map((id) => ctx.db.get(id as Id<"users">)))
docs.forEach((doc, index) => {
const stats = statsMap.get(agentIds[index])
if (!stats || !doc) return
if (!stats.name && doc.name) stats.name = doc.name
if (!stats.email && doc.email) stats.email = doc.email
})
}
const computed = new Map<string, AgentStatsComputed>()
for (const [key, raw] of statsMap.entries()) {
const avgResolution = average(raw.resolutionMinutes)
const avgFirstResponse = average(raw.firstResponseMinutes)
const slaRate =
raw.totalSla > 0 ? Math.min(1, Math.max(0, raw.compliantSla / raw.totalSla)) : null
computed.set(key, {
agentId: key,
name: raw.name ?? raw.email ?? null,
email: raw.email ?? null,
open: raw.open,
paused: raw.paused,
resolved: raw.resolved,
slaRate,
avgResolutionMinutes: avgResolution,
avgFirstResponseMinutes: avgFirstResponse,
totalSla: raw.totalSla,
compliantSla: raw.compliantSla,
})
}
return computed
}
const metricResolvers: Record<string, MetricResolver> = { const metricResolvers: Record<string, MetricResolver> = {
"tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => { "tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => {
const rangeDays = parseRange(params) const rangeDays = parseRange(params)
@ -419,6 +591,151 @@ const metricResolvers: Record<string, MetricResolver> = {
data: summary, data: summary,
} }
}, },
"agents.self_ticket_status": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
const data = [
{ status: "Abertos", total: stats?.open ?? 0 },
{ status: "Pausados", total: stats?.paused ?? 0 },
{ status: "Resolvidos", total: stats?.resolved ?? 0 },
]
return {
meta: { kind: "collection", key: "agents.self_ticket_status", rangeDays },
data,
}
},
"agents.self_open_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_open_total", unit: "tickets", rangeDays },
data: { value: stats?.open ?? 0 },
}
},
"agents.self_paused_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_paused_total", unit: "tickets", rangeDays },
data: { value: stats?.paused ?? 0 },
}
},
"agents.self_resolved_total": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_resolved_total", unit: "tickets", rangeDays },
data: { value: stats?.resolved ?? 0 },
}
},
"agents.self_sla_rate": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
return {
meta: { kind: "single", key: "agents.self_sla_rate", rangeDays },
data: {
value: stats?.slaRate ?? 0,
total: stats?.totalSla ?? 0,
compliant: stats?.compliantSla ?? 0,
},
}
},
"agents.self_avg_resolution_minutes": async (ctx, { tenantId, viewer, viewerId, params }) => {
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId)
const stats = statsMap.get(String(viewerId))
const raw = stats?.avgResolutionMinutes ?? null
const value = raw !== null ? Math.round(raw * 10) / 10 : 0
return {
meta: { kind: "single", key: "agents.self_avg_resolution_minutes", unit: "minutes", rangeDays },
data: { value },
}
},
"agents.team_overview": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
open: stats.open,
paused: stats.paused,
resolved: stats.resolved,
slaRate: stats.slaRate !== null ? Math.round(stats.slaRate * 1000) / 10 : null,
avgResolutionMinutes:
stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : null,
}))
.sort((a, b) => b.resolved - a.resolved)
return {
meta: { kind: "collection", key: "agents.team_overview", rangeDays },
data,
}
},
"agents.team_resolved_total": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
resolved: stats.resolved,
}))
.sort((a, b) => b.resolved - a.resolved)
return {
meta: { kind: "collection", key: "agents.team_resolved_total", rangeDays },
data,
}
},
"agents.team_sla_rate": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
compliance: stats.slaRate ?? 0,
total: stats.totalSla,
compliant: stats.compliantSla,
}))
.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0))
return {
meta: { kind: "collection", key: "agents.team_sla_rate", rangeDays },
data,
}
},
"agents.team_avg_resolution_minutes": async (ctx, { tenantId, viewer, params }) => {
if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") {
throw new ConvexError("Apenas administradores podem acessar esta métrica.")
}
const rangeDays = parseRange(params)
const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays)
const data = Array.from(statsMap.values())
.map((stats) => ({
agentId: stats.agentId,
agentName: stats.name ?? stats.email ?? "Agente",
minutes:
stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : 0,
}))
.sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0))
return {
meta: { kind: "collection", key: "agents.team_avg_resolution_minutes", rangeDays },
data,
}
},
} }
export const run = query({ export const run = query({

File diff suppressed because it is too large Load diff

View file

@ -255,12 +255,21 @@ export function DashboardListView() {
<CardDescription className="line-clamp-2 text-sm">{dashboard.description}</CardDescription> <CardDescription className="line-clamp-2 text-sm">{dashboard.description}</CardDescription>
) : null} ) : null}
</CardHeader> </CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground"> <CardContent className="space-y-3 text-sm text-muted-foreground">
<p> <div className="flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-medium text-neutral-700">
Última atualização{" "} <span className="inline-flex size-2 rounded-full bg-emerald-500" />
<span className="font-medium text-foreground">{updatedAt}</span> Atualizado {updatedAt}
</p> </div>
<p>Formato {dashboard.aspectRatio} · Tema {dashboard.theme}</p> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Formato {dashboard.aspectRatio}
</Badge>
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Tema {dashboard.theme}
</Badge>
</div>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button asChild className="w-full"> <Button asChild className="w-full">

View file

@ -0,0 +1,312 @@
import type { SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type MetricEncoding = {
x?: string
y?: Array<{ field: string; label?: string }>
category?: string
value?: string
angle?: string
radius?: string
}
type MetricOptions = {
legend?: boolean
tooltip?: boolean
indicator?: "line" | "dot" | "dashed"
valueFormatter?: "percent"
}
type MetricAudience = "all" | "agent" | "admin"
export type DashboardMetricDefinition = {
key: string
name: string
description: string
defaultTitle: string
recommendedWidget:
| "kpi"
| "bar"
| "line"
| "area"
| "pie"
| "radar"
| "gauge"
| "table"
| "text"
keywords?: string[]
encoding?: MetricEncoding
columns?: Array<{ field: string; label: string }>
stacked?: boolean
options?: MetricOptions
audience?: MetricAudience
}
export const DASHBOARD_METRIC_DEFINITIONS: DashboardMetricDefinition[] = [
{
key: "tickets.opened_resolved_by_day",
name: "Tickets abertos x resolvidos por dia",
description:
"Comparativo diário entre tickets abertos e resolvidos considerando os filtros globais do dashboard.",
defaultTitle: "Abertos x resolvidos (últimos dias)",
recommendedWidget: "area",
encoding: {
x: "date",
y: [
{ field: "opened", label: "Abertos" },
{ field: "resolved", label: "Resolvidos" },
],
},
options: { legend: true, tooltip: true, indicator: "line" },
keywords: ["linha", "tendência", "evolução", "resolução", "abertos"],
},
{
key: "tickets.waiting_action_now",
name: "Tickets aguardando ação (agora)",
description: "Total de tickets abertos que estão aguardando ação neste instante e quantos estão fora do SLA.",
defaultTitle: "Tickets aguardando ação",
recommendedWidget: "kpi",
keywords: ["sla", "pendentes", "agora", "urgente"],
},
{
key: "tickets.waiting_action_last_7d",
name: "Tickets aguardando ação (últimos 7 dias)",
description:
"Volume acumulado de tickets abertos nas últimas 7 noites, com destaque para aqueles em risco de SLA.",
defaultTitle: "Pendências nos últimos 7 dias",
recommendedWidget: "kpi",
keywords: ["tendência", "acumulado", "últimos 7 dias", "sla"],
},
{
key: "tickets.open_by_priority",
name: "Tickets abertos por prioridade",
description:
"Distribuição de tickets abertos segmentada por prioridade durante o período selecionado.",
defaultTitle: "Abertos por prioridade",
recommendedWidget: "bar",
encoding: {
x: "priority",
y: [{ field: "total", label: "Tickets" }],
},
options: { legend: false, tooltip: true, indicator: "dot" },
keywords: ["prioridade", "backup", "distribuição"],
},
{
key: "tickets.open_by_queue",
name: "Tickets abertos por fila",
description: "Ranking de filas com maior volume de tickets abertos no período filtrado.",
defaultTitle: "Abertos por fila",
recommendedWidget: "bar",
encoding: {
x: "name",
y: [{ field: "total", label: "Tickets abertos" }],
},
options: { legend: false, tooltip: true, indicator: "dot" },
keywords: ["fila", "ranking", "operacional"],
},
{
key: "tickets.sla_compliance_by_queue",
name: "Cumprimento de SLA por fila",
description: "Percentual de atendimento dentro do SLA por fila com detalhamento de volume total.",
defaultTitle: "Cumprimento de SLA por fila",
recommendedWidget: "bar",
encoding: {
x: "name",
y: [{ field: "compliance", label: "Cumprimento (%)" }],
},
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
keywords: ["sla", "fila", "percentual", "qualidade"],
},
{
key: "tickets.sla_rate",
name: "Taxa geral de cumprimento de SLA",
description: "Percentual geral de tickets resolvidos dentro do prazo no período filtrado.",
defaultTitle: "Cumprimento geral de SLA",
recommendedWidget: "gauge",
keywords: ["sla", "percentual", "qualidade", "indicador"],
},
{
key: "tickets.awaiting_table",
name: "Tickets aguardando com detalhes",
description: "Lista dos principais tickets aguardando ação, incluindo prioridade e responsável.",
defaultTitle: "Detalhes das pendências",
recommendedWidget: "table",
columns: [
{ field: "subject", label: "Assunto" },
{ field: "priority", label: "Prioridade" },
{ field: "status", label: "Status" },
{ field: "updatedAt", label: "Atualizado em" },
],
keywords: ["tabela", "lista", "pendências", "detalhes"],
},
{
key: "agents.self_ticket_status",
name: "Status dos meus tickets",
description: "Distribuição entre tickets abertos, pausados e resolvidos do próprio agente no período.",
defaultTitle: "Status dos meus tickets",
recommendedWidget: "bar",
encoding: {
x: "status",
y: [{ field: "total", label: "Chamados" }],
},
options: { legend: false, tooltip: true, indicator: "dot" },
keywords: ["agente", "status", "abertos", "resolvidos"],
audience: "agent",
},
{
key: "agents.self_open_total",
name: "Meus tickets abertos",
description: "Quantidade atual de tickets abertos atribuídos ao agente.",
defaultTitle: "Tickets abertos (meus)",
recommendedWidget: "kpi",
keywords: ["agente", "abertos", "backlog"],
audience: "agent",
},
{
key: "agents.self_paused_total",
name: "Meus tickets pausados",
description: "Quantidade de tickets pausados sob responsabilidade do agente.",
defaultTitle: "Tickets pausados (meus)",
recommendedWidget: "kpi",
keywords: ["agente", "pausados"],
audience: "agent",
},
{
key: "agents.self_resolved_total",
name: "Tickets resolvidos (meus)",
description: "Total de tickets resolvidos pelo agente no período selecionado.",
defaultTitle: "Resolvidos (meus)",
recommendedWidget: "kpi",
keywords: ["agente", "resolvidos", "produtividade"],
audience: "agent",
},
{
key: "agents.self_sla_rate",
name: "SLA cumprido (meus tickets)",
description: "Percentual de tickets do agente resolvidos dentro do SLA no período.",
defaultTitle: "SLA cumprido (meus)",
recommendedWidget: "gauge",
options: { valueFormatter: "percent" },
keywords: ["agente", "sla", "qualidade"],
audience: "agent",
},
{
key: "agents.self_avg_resolution_minutes",
name: "Tempo médio de resolução (meus)",
description: "Tempo médio de resolução, em minutos, dos tickets atribuídos ao agente no período.",
defaultTitle: "Tempo médio de resolução (meus)",
recommendedWidget: "kpi",
keywords: ["agente", "tempo médio", "resolução"],
audience: "agent",
},
{
key: "agents.team_overview",
name: "Visão geral por agente",
description: "Tabela com tickets abertos, pausados, resolvidos, SLA e tempo médio de resolução por agente.",
defaultTitle: "Visão geral dos agentes",
recommendedWidget: "table",
columns: [
{ field: "agentName", label: "Agente" },
{ field: "open", label: "Abertos" },
{ field: "paused", label: "Pausados" },
{ field: "resolved", label: "Resolvidos" },
{ field: "slaRate", label: "SLA (%)" },
{ field: "avgResolutionMinutes", label: "Tempo médio (min)" },
],
keywords: ["agentes", "tabela", "produtividade"],
audience: "admin",
},
{
key: "agents.team_resolved_total",
name: "Tickets resolvidos por agente",
description: "Comparativo de tickets resolvidos por cada agente no período.",
defaultTitle: "Resolvidos por agente",
recommendedWidget: "bar",
encoding: {
x: "agentName",
y: [{ field: "resolved", label: "Tickets resolvidos" }],
},
options: { legend: false, tooltip: true, indicator: "dot" },
keywords: ["agentes", "resolvidos", "ranking"],
audience: "admin",
},
{
key: "agents.team_sla_rate",
name: "SLA por agente",
description: "Percentual de cumprimento de SLA para cada agente no período.",
defaultTitle: "SLA por agente",
recommendedWidget: "bar",
encoding: {
x: "agentName",
y: [{ field: "compliance", label: "SLA (%)" }],
},
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
keywords: ["agentes", "sla", "qualidade"],
audience: "admin",
},
{
key: "agents.team_avg_resolution_minutes",
name: "Tempo médio por agente",
description: "Tempo médio de resolução por agente no período.",
defaultTitle: "Tempo médio por agente",
recommendedWidget: "bar",
encoding: {
x: "agentName",
y: [{ field: "minutes", label: "Minutos" }],
},
options: { legend: false, tooltip: true, indicator: "dot" },
keywords: ["agentes", "tempo médio", "resolução"],
audience: "admin",
},
{
key: "devices.health_summary",
name: "Saúde dos dispositivos",
description: "Lista os dispositivos monitorados com status, consumo de recursos e alertas recentes.",
defaultTitle: "Saúde dos dispositivos",
recommendedWidget: "table",
columns: [
{ field: "hostname", label: "Hostname" },
{ field: "status", label: "Status" },
{ field: "cpuUsagePercent", label: "CPU (%)" },
{ field: "memoryUsedPercent", label: "Memória (%)" },
{ field: "diskUsedPercent", label: "Disco (%)" },
{ field: "alerts", label: "Alertas" },
],
keywords: ["dispositivos", "hardware", "health"],
audience: "admin",
},
]
function normalizeRole(role?: string | null) {
return role?.toLowerCase() ?? null
}
function canUseMetric(definition: DashboardMetricDefinition, role?: string | null) {
const audience = definition.audience ?? "all"
const normalizedRole = normalizeRole(role)
if (audience === "all") return true
if (audience === "agent") {
return normalizedRole === "agent"
}
if (audience === "admin") {
return normalizedRole === "admin" || normalizedRole === "manager"
}
return true
}
export function getMetricOptionsForRole(role?: string | null): SearchableComboboxOption[] {
return DASHBOARD_METRIC_DEFINITIONS.filter((definition) => canUseMetric(definition, role)).map(
(definition) => ({
value: definition.key,
label: definition.name,
description: definition.description,
keywords: definition.keywords,
}),
)
}
export function getMetricDefinition(metricKey?: string | null): DashboardMetricDefinition | undefined {
if (!metricKey) return undefined
const normalized = metricKey.trim().toLowerCase()
if (!normalized) return undefined
return DASHBOARD_METRIC_DEFINITIONS.find((definition) => definition.key === normalized)
}

View file

@ -28,6 +28,8 @@ type PackedLayoutItem = {
h: number h: number
minW?: number minW?: number
minH?: number minH?: number
maxW?: number
maxH?: number
static?: boolean static?: boolean
} }
@ -37,6 +39,8 @@ type CanvasItem = {
layout: PackedLayoutItem layout: PackedLayoutItem
minW?: number minW?: number
minH?: number minH?: number
maxW?: number
maxH?: number
static?: boolean static?: boolean
} }
@ -59,6 +63,8 @@ type InternalResizeState = {
initialHeight: number initialHeight: number
minW: number minW: number
minH: number minH: number
maxW: number
maxH: number
} }
const DEFAULT_COLUMNS = 12 const DEFAULT_COLUMNS = 12
@ -96,21 +102,31 @@ export function ReportCanvas({
} }
}, []) }, [])
const columnWidth = containerWidth > 0 ? containerWidth / columns : 0 const columnWidth = useMemo(() => {
if (containerWidth <= 0 || columns <= 0) return 0
const totalGap = gap * (columns - 1)
const effective = containerWidth - totalGap
return effective > 0 ? effective / columns : 0
}, [containerWidth, columns, gap])
const unitColumnWidth = useMemo(() => {
if (columnWidth <= 0) return 0
return columnWidth + (columns > 1 ? gap : 0)
}, [columnWidth, columns, gap])
const unitRowHeight = useMemo(() => rowHeight + gap, [rowHeight, gap])
useEffect(() => { useEffect(() => {
if (!resizing) return if (!resizing) return
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (!resizing) return if (!resizing) return
if (!columnWidth) return if (!unitColumnWidth || !unitRowHeight) return
const deltaX = event.clientX - resizing.originX const deltaX = event.clientX - resizing.originX
const deltaY = event.clientY - resizing.originY const deltaY = event.clientY - resizing.originY
const deltaCols = Math.round(deltaX / columnWidth) const deltaCols = Math.round(deltaX / unitColumnWidth)
const deltaRows = Math.round(deltaY / rowHeight) const deltaRows = Math.round(deltaY / unitRowHeight)
let nextW = resizing.initialWidth + deltaCols let nextW = resizing.initialWidth + deltaCols
let nextH = resizing.initialHeight + deltaRows let nextH = resizing.initialHeight + deltaRows
nextW = Math.min(columns, Math.max(resizing.minW, nextW)) nextW = Math.min(resizing.maxW, Math.max(resizing.minW, nextW))
nextH = Math.min(MAX_ROWS, Math.max(resizing.minH, nextH)) nextH = Math.min(resizing.maxH, Math.max(resizing.minH, nextH))
const previous = lastResizeSizeRef.current const previous = lastResizeSizeRef.current
if (!previous || previous.w !== nextW || previous.h !== nextH) { if (!previous || previous.w !== nextW || previous.h !== nextH) {
lastResizeSizeRef.current = { w: nextW, h: nextH } lastResizeSizeRef.current = { w: nextW, h: nextH }
@ -119,8 +135,15 @@ export function ReportCanvas({
} }
function handlePointerUp() { function handlePointerUp() {
if (resizing) { if (resizing) {
const finalSize = lastResizeSizeRef.current ?? { w: resizing.initialWidth, h: resizing.initialHeight } const finalSize = lastResizeSizeRef.current ?? {
onResize?.(resizing.key, finalSize, { commit: true }) w: resizing.initialWidth,
h: resizing.initialHeight,
}
const clampedFinal = {
w: Math.min(resizing.maxW, Math.max(resizing.minW, finalSize.w)),
h: Math.min(resizing.maxH, Math.max(resizing.minH, finalSize.h)),
}
onResize?.(resizing.key, clampedFinal, { commit: true })
} }
setResizing(null) setResizing(null)
lastResizeSizeRef.current = null lastResizeSizeRef.current = null
@ -133,7 +156,7 @@ export function ReportCanvas({
window.removeEventListener("pointermove", handlePointerMove) window.removeEventListener("pointermove", handlePointerMove)
window.removeEventListener("pointerup", handlePointerUp) window.removeEventListener("pointerup", handlePointerUp)
} }
}, [columnWidth, onResize, resizing, rowHeight, columns]) }, [columns, onResize, resizing, unitColumnWidth, unitRowHeight])
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
@ -166,6 +189,8 @@ export function ReportCanvas({
const layout = item.layout const layout = item.layout
const minW = item.minW ?? layout.minW ?? 2 const minW = item.minW ?? layout.minW ?? 2
const minH = item.minH ?? layout.minH ?? 2 const minH = item.minH ?? layout.minH ?? 2
const maxW = item.maxW ?? layout.maxW ?? columns
const maxH = item.maxH ?? layout.maxH ?? MAX_ROWS
setResizing({ setResizing({
key: item.key, key: item.key,
originX: event.clientX, originX: event.clientX,
@ -174,6 +199,8 @@ export function ReportCanvas({
initialHeight: layout.h, initialHeight: layout.h,
minW, minW,
minH, minH,
maxW,
maxH,
}) })
} }
@ -210,7 +237,7 @@ export function ReportCanvas({
{editable && !item.static ? ( {editable && !item.static ? (
<div <div
role="presentation" role="presentation"
className="absolute bottom-1 right-1 size-4 cursor-se-resize rounded-sm border border-border/60 bg-background/70 shadow ring-1 ring-border/40" className="absolute bottom-2 right-2 size-4 cursor-se-resize rounded-sm border border-border/60 bg-background/80 shadow ring-1 ring-border/40"
onPointerDown={(event) => handleResizePointerDown(event, item)} onPointerDown={(event) => handleResizePointerDown(event, item)}
/> />
) : null} ) : null}

View file

@ -64,6 +64,7 @@ const numberFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits:
const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maximumFractionDigits: 1 }) const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maximumFractionDigits: 1 })
const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"] const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
const DEFAULT_CHART_HEIGHT = 320
export type DashboardFilters = { export type DashboardFilters = {
range?: "7d" | "30d" | "90d" | "custom" range?: "7d" | "30d" | "90d" | "custom"
@ -231,6 +232,34 @@ function parseNumeric(value: unknown): number | null {
return null return null
} }
type ValueFormatterKind = "number" | "percent"
function formatMetricValue(value: unknown, format: ValueFormatterKind = "number") {
const numeric = parseNumeric(value)
if (numeric === null) return "—"
return format === "percent" ? percentFormatter.format(numeric) : numberFormatter.format(numeric)
}
function resolveChartOptions(
config: WidgetConfig,
defaults: { indicator: "line" | "dot" | "dashed" },
): {
showLegend: boolean
showTooltip: boolean
indicator: "line" | "dot" | "dashed"
valueFormatter: ValueFormatterKind
} {
const options = (config.options ?? {}) as Record<string, unknown>
const showLegend = options.legend !== false
const showTooltip = options.tooltip !== false
const indicator =
typeof options.indicator === "string" && (options.indicator === "line" || options.indicator === "dot" || options.indicator === "dashed")
? (options.indicator as "line" | "dot" | "dashed")
: defaults.indicator
const valueFormatter: ValueFormatterKind = options.valueFormatter === "percent" ? "percent" : "number"
return { showLegend, showTooltip, indicator, valueFormatter }
}
export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }: WidgetRendererProps) { export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }: WidgetRendererProps) {
const config = normalizeWidgetConfig(widget) const config = normalizeWidgetConfig(widget)
const widgetType = (config.type ?? widget.type ?? "text").toLowerCase() const widgetType = (config.type ?? widget.type ?? "text").toLowerCase()
@ -364,13 +393,17 @@ type WidgetCardProps = {
function WidgetCard({ title, description, children, isLoading }: WidgetCardProps) { function WidgetCard({ title, description, children, isLoading }: WidgetCardProps) {
return ( return (
<Card className="h-full"> <Card className="group flex h-full flex-col rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-slate-100 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg">
<CardHeader className="pb-2"> <CardHeader className="flex-none pb-3">
<CardTitle className="text-base font-semibold">{title}</CardTitle> <CardTitle className="text-base font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="flex-1 pb-4 pt-0">
{isLoading ? <Skeleton className="h-[220px] w-full rounded-lg" /> : children} {isLoading ? (
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
) : (
<div className="h-full">{children}</div>
)}
</CardContent> </CardContent>
</Card> </Card>
) )
@ -396,17 +429,28 @@ function renderKpi({
const delta = trendValue !== null ? value - trendValue : null const delta = trendValue !== null ? value - trendValue : null
const isTv = mode === "tv" const isTv = mode === "tv"
return ( return (
<Card className={cn("h-full bg-gradient-to-br", isTv ? "from-primary/5 to-primary/10" : "from-muted/50 to-muted/30")}> <Card
<CardHeader className="pb-1"> className={cn(
"flex h-full flex-col rounded-2xl border bg-gradient-to-br shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-xl",
isTv ? "border-primary/50 from-primary/10 via-primary/5 to-primary/20" : "border-slate-200 from-white via-white to-slate-100"
)}
>
<CardHeader className="flex-none pb-1">
<CardDescription className="text-xs uppercase tracking-wide text-muted-foreground"> <CardDescription className="text-xs uppercase tracking-wide text-muted-foreground">
{description ?? "Indicador chave"} {description ?? "Indicador chave"}
</CardDescription> </CardDescription>
<CardTitle className={cn("text-2xl font-semibold", isTv ? "text-4xl" : "")}>{title}</CardTitle> <CardTitle className={cn("text-2xl font-semibold", isTv ? "text-4xl" : "")}>{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="flex-1 space-y-3">
<div className={cn("font-semibold text-4xl", isTv ? "text-6xl" : "text-4xl")}>{numberFormatter.format(value)}</div> <div className={cn("font-semibold text-4xl", isTv ? "text-6xl" : "text-4xl")}>{numberFormatter.format(value)}</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant={atRisk > 0 ? "destructive" : "outline"} className="rounded-full px-3 py-1 text-xs"> <Badge
variant={atRisk > 0 ? "destructive" : "secondary"}
className={cn(
"rounded-full px-3 py-1 text-xs",
atRisk > 0 ? "" : "border-none bg-white/70 text-foreground"
)}
>
{atRisk > 0 ? `${numberFormatter.format(atRisk)} em risco` : "Todos no prazo"} {atRisk > 0 ? `${numberFormatter.format(atRisk)} em risco` : "Todos no prazo"}
</Badge> </Badge>
{delta !== null ? ( {delta !== null ? (
@ -442,14 +486,23 @@ function renderBarChart({
} }
return acc return acc
}, {}) }, {})
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const yAxisTickFormatter =
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
const allowDecimals = valueFormatter === "percent"
return ( return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? ( {chartData.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full"> <ChartContainer
<BarChart data={chartData}> config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
>
<BarChart data={chartData} accessibilityLayer>
<CartesianGrid vertical={false} strokeDasharray="3 3" /> <CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis <XAxis
dataKey={xKey} dataKey={xKey}
@ -459,8 +512,18 @@ function renderBarChart({
minTickGap={24} minTickGap={24}
tickFormatter={formatDateLabel} tickFormatter={formatDateLabel}
/> />
<YAxis allowDecimals={false} /> <YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
<ChartTooltip content={<ChartTooltipContent />} /> {showTooltip ? (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
/>
) : null}
{showLegend ? (
<ChartLegend
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
/>
) : null}
{series.map((serie, index) => ( {series.map((serie, index) => (
<Bar <Bar
key={serie.field} key={serie.field}
@ -499,19 +562,37 @@ function renderLineChart({
} }
return acc return acc
}, {}) }, {})
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const allowDecimals = valueFormatter === "percent"
const yAxisTickFormatter =
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
return ( return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? ( {chartData.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full"> <ChartContainer
<LineChart data={chartData}> config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
>
<LineChart data={chartData} accessibilityLayer>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} tickFormatter={formatDateLabel} /> <XAxis dataKey={xKey} tickFormatter={formatDateLabel} />
<YAxis allowDecimals /> <YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
<ChartTooltip content={<ChartTooltipContent />} /> {showTooltip ? (
<ChartLegend content={<ChartLegendContent />} /> <ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
/>
) : null}
{showLegend ? (
<ChartLegend
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
/>
) : null}
{series.map((serie, index) => ( {series.map((serie, index) => (
<Line <Line
key={serie.field} key={serie.field}
@ -552,14 +633,23 @@ function renderAreaChart({
} }
return acc return acc
}, {}) }, {})
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
const allowDecimals = valueFormatter === "percent"
const yAxisTickFormatter =
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
return ( return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? ( {chartData.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full"> <ChartContainer
<AreaChart data={chartData}> config={chartConfig as ChartConfig}
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
>
<AreaChart data={chartData} accessibilityLayer>
<defs> <defs>
{series.map((serie, index) => ( {series.map((serie, index) => (
<linearGradient key={serie.field} id={`fill-${serie.field}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient key={serie.field} id={`fill-${serie.field}`} x1="0" y1="0" x2="0" y2="1">
@ -570,9 +660,18 @@ function renderAreaChart({
</defs> </defs>
<CartesianGrid vertical={false} strokeDasharray="3 3" /> <CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey={xKey} tickFormatter={formatDateLabel} /> <XAxis dataKey={xKey} tickFormatter={formatDateLabel} />
<YAxis /> <YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
<ChartTooltip content={<ChartTooltipContent />} /> {showTooltip ? (
<ChartLegend content={<ChartLegendContent />} /> <ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
/>
) : null}
{showLegend ? (
<ChartLegend
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
/>
) : null}
{series.map((serie, index) => ( {series.map((serie, index) => (
<Area <Area
key={serie.field} key={serie.field}
@ -606,6 +705,8 @@ function renderPieChart({
const categoryKey = config.encoding?.category ?? "name" const categoryKey = config.encoding?.category ?? "name"
const valueKey = config.encoding?.value ?? "value" const valueKey = config.encoding?.value ?? "value"
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : [] const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
return ( return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? ( {chartData.length === 0 ? (
@ -617,10 +718,16 @@ function renderPieChart({
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] } acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
return acc return acc
}, {}) as ChartConfig} }, {}) as ChartConfig}
className="mx-auto aspect-square max-h-[240px]" className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
> >
<PieChart> <PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} /> {showTooltip ? (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
/>
) : null}
<Pie <Pie
data={chartData} data={chartData}
dataKey={valueKey} dataKey={valueKey}
@ -633,6 +740,11 @@ function renderPieChart({
<Cell key={index} fill={CHART_COLORS[index % CHART_COLORS.length]} /> <Cell key={index} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))} ))}
</Pie> </Pie>
{showLegend ? (
<ChartLegend
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
/>
) : null}
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
)} )}
@ -654,6 +766,8 @@ function renderRadarChart({
const angleKey = config.encoding?.angle ?? "label" const angleKey = config.encoding?.angle ?? "label"
const radiusKey = config.encoding?.radius ?? "value" const radiusKey = config.encoding?.radius ?? "value"
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : [] const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
return ( return (
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
{chartData.length === 0 ? ( {chartData.length === 0 ? (
@ -661,13 +775,24 @@ function renderRadarChart({
) : ( ) : (
<ChartContainer <ChartContainer
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }} config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
className="mx-auto aspect-square max-h-[260px]" className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
> >
<RadarChart data={chartData}> <RadarChart data={chartData} accessibilityLayer>
<PolarGrid /> <PolarGrid />
<PolarAngleAxis dataKey={angleKey} /> <PolarAngleAxis dataKey={angleKey} />
<PolarRadiusAxis /> <PolarRadiusAxis />
<ChartTooltip content={<ChartTooltipContent />} /> {showTooltip ? (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
/>
) : null}
{showLegend ? (
<ChartLegend
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
/>
) : null}
<Radar <Radar
dataKey={radiusKey} dataKey={radiusKey}
stroke="var(--chart-1)" stroke="var(--chart-1)"
@ -699,7 +824,8 @@ function renderGauge({
<WidgetCard title={title} description={description} isLoading={metric.isLoading}> <WidgetCard title={title} description={description} isLoading={metric.isLoading}>
<ChartContainer <ChartContainer
config={{ value: { label: "SLA", color: "var(--chart-1)" } }} config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
className="mx-auto aspect-square max-h-[240px]" className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
> >
<RadialBarChart <RadialBarChart
startAngle={180} startAngle={180}
@ -708,7 +834,7 @@ function renderGauge({
outerRadius={110} outerRadius={110}
data={[{ name: "SLA", value: display }]} data={[{ name: "SLA", value: display }]}
> >
<RadialBar background dataKey="value" cornerRadius={5} fill="var(--color-value)" /> <RadialBar background dataKey="value" cornerRadius={5} fill="var(--color-value)" />
<RechartsTooltip <RechartsTooltip
cursor={false} cursor={false}
content={<ChartTooltipContent hideLabel valueFormatter={(val) => percentFormatter.format(Number(val ?? 0))} />} content={<ChartTooltipContent hideLabel valueFormatter={(val) => percentFormatter.format(Number(val ?? 0))} />}
@ -760,27 +886,29 @@ function renderTable({
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyState /> <EmptyState />
) : ( ) : (
<div className="overflow-hidden rounded-lg border border-border/60"> <div className="flex h-full min-h-[260px] flex-col overflow-hidden rounded-xl border border-border/60 bg-white/80">
<Table> <div className="max-h-[360px] overflow-auto">
<TableHeader> <Table className="min-w-full">
<TableRow> <TableHeader>
{columns.map((column) => ( <TableRow>
<TableHead key={column.field}>{column.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
{columns.map((column) => ( {columns.map((column) => (
<TableCell key={column.field}> <TableHead key={column.field}>{column.label}</TableHead>
{renderTableCellValue(row[column.field as keyof typeof row])}
</TableCell>
))} ))}
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {rows.map((row, index) => (
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
{columns.map((column) => (
<TableCell key={column.field}>
{renderTableCellValue(row[column.field as keyof typeof row])}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div> </div>
)} )}
</WidgetCard> </WidgetCard>
@ -833,16 +961,21 @@ function renderText({
allowedAttributes: { span: ["style"] }, allowedAttributes: { span: ["style"] },
}) })
return ( return (
<Card className="h-full"> <Card className="group flex h-full flex-col rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-slate-100 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg">
<CardHeader> <CardHeader className="flex-none">
<CardTitle>{title}</CardTitle> <CardTitle className="text-base font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null} {description ? <CardDescription>{description}</CardDescription> : null}
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 pt-0">
<div <div className="prose max-w-none rounded-xl bg-white/70 p-4 text-sm leading-relaxed text-muted-foreground shadow-inner transition group-hover:bg-white [&_ol]:list-decimal [&_ul]:list-disc">
className="prose max-w-none text-sm leading-relaxed text-muted-foreground [&_ul]:list-disc [&_ol]:list-decimal" <div
dangerouslySetInnerHTML={{ __html: sanitized || "<p>Adicione conteúdo informativo para contextualizar os dados.</p>" }} dangerouslySetInnerHTML={{
/> __html:
sanitized ||
"<p>Adicione destaques, insights ou instruções para contextualizar os dados apresentados.</p>",
}}
/>
</div>
</CardContent> </CardContent>
</Card> </Card>
) )
@ -850,8 +983,8 @@ function renderText({
function EmptyState() { function EmptyState() {
return ( return (
<div className="flex h-[220px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border/60 text-sm text-muted-foreground"> <div className="flex h-full min-h-[160px] w-full flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-100 px-6 text-center text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-200/70">
Sem dados para os filtros selecionados. Sem dados disponíveis para os filtros atuais.
</div> </div>
) )
} }

View file

@ -1,6 +1,6 @@
'use client' "use client"
import type { ReactNode } from "react" import type { ComponentProps, ReactNode } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
@ -15,7 +15,7 @@ interface SiteHeaderProps {
primaryAlignment?: "right" | "center" primaryAlignment?: "right" | "center"
} }
export function SiteHeader({ function SiteHeaderBase({
title, title,
lead, lead,
primaryAction, primaryAction,
@ -47,7 +47,7 @@ export function SiteHeaderPrimaryButton({
children, children,
className, className,
...props ...props
}: React.ComponentProps<typeof Button>) { }: ComponentProps<typeof Button>) {
return ( return (
<Button size="sm" className={cn("w-full sm:w-auto", className)} {...props}> <Button size="sm" className={cn("w-full sm:w-auto", className)} {...props}>
{children} {children}
@ -59,7 +59,7 @@ export function SiteHeaderSecondaryButton({
children, children,
className, className,
...props ...props
}: React.ComponentProps<typeof Button>) { }: ComponentProps<typeof Button>) {
return ( return (
<Button size="sm" variant="outline" className={cn("w-full sm:w-auto", className)} {...props}> <Button size="sm" variant="outline" className={cn("w-full sm:w-auto", className)} {...props}>
{children} {children}
@ -67,6 +67,12 @@ export function SiteHeaderSecondaryButton({
) )
} }
// Backward compatibility: attach as static members (client-only usage) type SiteHeaderComponent = ((props: SiteHeaderProps) => JSX.Element) & {
;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton PrimaryButton: typeof SiteHeaderPrimaryButton
;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton SecondaryButton: typeof SiteHeaderSecondaryButton
}
export const SiteHeader: SiteHeaderComponent = Object.assign(SiteHeaderBase, {
PrimaryButton: SiteHeaderPrimaryButton,
SecondaryButton: SiteHeaderSecondaryButton,
})

View file

@ -31,6 +31,9 @@ type SearchableComboboxProps = {
clearLabel?: string clearLabel?: string
renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
contentClassName?: string
scrollClassName?: string
scrollProps?: React.HTMLAttributes<HTMLDivElement>
} }
export function SearchableCombobox({ export function SearchableCombobox({
@ -46,6 +49,9 @@ export function SearchableCombobox({
clearLabel = "Limpar seleção", clearLabel = "Limpar seleção",
renderValue, renderValue,
renderOption, renderOption,
contentClassName,
scrollClassName,
scrollProps,
}: SearchableComboboxProps) { }: SearchableComboboxProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
@ -103,7 +109,7 @@ export function SearchableCombobox({
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="z-50 w-[var(--radix-popover-trigger-width)] p-0"> <PopoverContent className={cn("z-50 w-[var(--radix-popover-trigger-width)] max-w-[480px] p-0", contentClassName)}>
<div className="border-b border-border/80 p-2"> <div className="border-b border-border/80 p-2">
<Input <Input
autoFocus autoFocus
@ -132,7 +138,10 @@ export function SearchableCombobox({
<Separator /> <Separator />
</> </>
) : null} ) : null}
<ScrollArea className="max-h-60"> <ScrollArea
className={cn("max-h-60", scrollClassName)}
{...scrollProps}
>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyText}</div> <div className="px-3 py-4 text-sm text-muted-foreground">{emptyText}</div>
) : ( ) : (

View file

@ -101,10 +101,24 @@ function isMissingProvisioningCodeColumn(error: unknown): boolean {
return false return false
} }
function isMissingCompanyTable(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2021") return true
const message = error.message.toLowerCase()
if (message.includes("table") && message.includes("company") && message.includes("does not exist")) {
return true
}
}
return false
}
export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> { export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> {
try { try {
return await prisma.company.findMany(args) return await prisma.company.findMany(args)
} catch (error) { } catch (error) {
if (isMissingCompanyTable(error)) {
return []
}
if (!isMissingProvisioningCodeColumn(error)) { if (!isMissingProvisioningCodeColumn(error)) {
throw error throw error
} }