feat: refresh dashboards experience
This commit is contained in:
parent
1900f65e5e
commit
d7d6b748cc
9 changed files with 1626 additions and 281 deletions
|
|
@ -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
|
|
@ -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">
|
||||||
|
|
|
||||||
312
src/components/dashboards/metric-catalog.ts
Normal file
312
src/components/dashboards/metric-catalog.ts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -760,8 +886,9 @@ 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">
|
||||||
|
<Table className="min-w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
|
|
@ -771,7 +898,7 @@ function renderTable({
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableCell key={column.field}>
|
<TableCell key={column.field}>
|
||||||
{renderTableCellValue(row[column.field as keyof typeof row])}
|
{renderTableCellValue(row[column.field as keyof typeof row])}
|
||||||
|
|
@ -782,6 +909,7 @@ function renderTable({
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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 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">
|
||||||
<div
|
<div
|
||||||
className="prose max-w-none text-sm leading-relaxed text-muted-foreground [&_ul]:list-disc [&_ol]:list-decimal"
|
dangerouslySetInnerHTML={{
|
||||||
dangerouslySetInnerHTML={{ __html: sanitized || "<p>Adicione conteúdo informativo para contextualizar os dados.</p>" }}
|
__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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue