From d7d6b748cc1c391c299eae92af75c134d8c639cc Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 6 Nov 2025 01:40:10 -0300 Subject: [PATCH] feat: refresh dashboards experience --- convex/metrics.ts | 319 +++++- .../dashboards/dashboard-builder.tsx | 912 ++++++++++++++---- src/components/dashboards/dashboard-list.tsx | 21 +- src/components/dashboards/metric-catalog.ts | 312 ++++++ src/components/dashboards/report-canvas.tsx | 47 +- src/components/dashboards/widget-renderer.tsx | 247 +++-- src/components/site-header.tsx | 22 +- src/components/ui/searchable-combobox.tsx | 13 +- src/server/company-service.ts | 14 + 9 files changed, 1626 insertions(+), 281 deletions(-) create mode 100644 src/components/dashboards/metric-catalog.ts diff --git a/convex/metrics.ts b/convex/metrics.ts index 91d03a9..9da0090 100644 --- a/convex/metrics.ts +++ b/convex/metrics.ts @@ -1,4 +1,4 @@ -import { v } from "convex/values" +import { ConvexError, v } from "convex/values" import type { Doc, Id } from "./_generated/dataModel" import { query } from "./_generated/server" @@ -90,6 +90,178 @@ function filterTicketsByQueue | 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, 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() + + 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() + 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 = { "tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) @@ -419,6 +591,151 @@ const metricResolvers: Record = { 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({ diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx index 2a22e37..d60c8c4 100644 --- a/src/components/dashboards/dashboard-builder.tsx +++ b/src/components/dashboards/dashboard-builder.tsx @@ -34,6 +34,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" +import { Textarea } from "@/components/ui/textarea" import { Dialog, DialogContent, @@ -75,16 +76,23 @@ import { z } from "zod" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { + Check, Copy, Download, Edit3, LayoutTemplate, + Maximize2, + Minimize2, MonitorPlay, + PauseCircle, + PlayCircle, Plus, Sparkles, Table2, Trash2, } from "lucide-react" +import { IconPencil } from "@tabler/icons-react" +import { getMetricDefinition, getMetricOptionsForRole } from "@/components/dashboards/metric-catalog" const GRID_COLUMNS = 12 const DEFAULT_ROW_HEIGHT = 80 @@ -133,6 +141,8 @@ type LayoutItemFromServer = { h: number minW?: number minH?: number + maxW?: number + maxH?: number static?: boolean } @@ -142,6 +152,8 @@ type LayoutStateItem = { h: number minW?: number minH?: number + maxW?: number + maxH?: number static?: boolean } @@ -155,6 +167,8 @@ type CanvasRenderableItem = { layout: PackedLayoutItem minW?: number minH?: number + maxW?: number + maxH?: number element: React.ReactNode } @@ -183,35 +197,50 @@ const WIDGET_LIBRARY: Array<{ title: string description: string }> = [ - { type: "kpi", title: "KPI", description: "Número agregado com destaque e tendência." }, - { type: "bar", title: "Barras", description: "Comparação de valores por categoria ou tempo." }, - { type: "line", title: "Linha", description: "Evolução temporal com curva contínua." }, - { type: "area", title: "Área", description: "Tendência acumulada com preenchimento suave." }, - { type: "pie", title: "Pizza/Donut", description: "Distribuição percentual por categoria." }, - { type: "radar", title: "Radar", description: "Comparativo entre dimensões em formato radial." }, - { type: "gauge", title: "Gauge", description: "Indicador circular de performance (0-100%)." }, - { type: "table", title: "Tabela", description: "Visão tabular detalhada com drill-down." }, - { type: "text", title: "Texto", description: "Notas, destaques e orientações em rich-text." }, + { type: "kpi", title: "Indicador KPI", description: "Valor agregado com destaque visual e variação opcional." }, + { type: "bar", title: "Gráfico de barras", description: "Comparação entre categorias ou períodos em colunas verticais." }, + { type: "line", title: "Gráfico de linhas", description: "Evolução temporal contínua para séries múltiplas." }, + { type: "area", title: "Gráfico de área", description: "Tendência temporal com preenchimento suave e suporte a empilhamento." }, + { type: "pie", title: "Gráfico de pizza/donut", description: "Participação percentual por categoria com rótulos opcionais." }, + { type: "radar", title: "Gráfico radar", description: "Comparação radial entre dimensões de performance." }, + { type: "gauge", title: "Indicador radial", description: "Mede um percentual (0-100%) em formato de gauge." }, + { type: "table", title: "Tabela dinâmica", description: "Lista tabular com cabeçalhos personalizáveis e ordenação." }, + { type: "text", title: "Bloco de texto", description: "Destaques, insights ou instruções em rich-text." }, ] +const WIDGET_TYPE_LABELS = Object.fromEntries(WIDGET_LIBRARY.map((item) => [item.type, item.title])) as Record< + string, + string +> + const widgetSizePresets: Record< string, - { default: { w: number; h: number }; min: { w: number; h: number } } + { + default: { w: number; h: number } + min: { w: number; h: number } + max: { w: number; h: number } + } > = { - kpi: { default: { w: 4, h: 4 }, min: { w: 3, h: 3 } }, - bar: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 } }, - line: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 } }, - area: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 } }, - pie: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 } }, - radar: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 } }, - gauge: { default: { w: 4, h: 5 }, min: { w: 3, h: 4 } }, - table: { default: { w: 8, h: 8 }, min: { w: 6, h: 5 } }, - text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 } }, + kpi: { default: { w: 4, h: 4 }, min: { w: 3, h: 3 }, max: { w: 6, h: 6 } }, + bar: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } }, + line: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } }, + area: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } }, + pie: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 }, max: { w: 8, h: 9 } }, + radar: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 }, max: { w: 8, h: 9 } }, + gauge: { default: { w: 4, h: 5 }, min: { w: 3, h: 4 }, max: { w: 6, h: 7 } }, + table: { default: { w: 8, h: 8 }, min: { w: 6, h: 5 }, max: { w: 12, h: 12 } }, + text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } }, } function getWidgetSize(type: string) { const preset = widgetSizePresets[type] ?? widgetSizePresets.text - return { ...preset.default, minW: preset.min.w, minH: preset.min.h } + return { + ...preset.default, + minW: preset.min.w, + minH: preset.min.h, + maxW: preset.max.w, + maxH: preset.max.h, + } } function useDebounce(value: T, delay: number): T { @@ -310,8 +339,16 @@ function packLayout(items: LayoutStateItem[], columns: number): PackedLayoutItem } for (const item of items) { - const width = Math.max(1, Math.min(columns, Math.max(item.minW ?? 2, Math.round(item.w)))) - const height = Math.max(1, Math.min(MAX_ROWS, Math.max(item.minH ?? 2, Math.round(item.h)))) + const maxAllowedWidth = Math.min(columns, item.maxW ?? columns) + const maxAllowedHeight = Math.min(MAX_ROWS, item.maxH ?? MAX_ROWS) + const width = Math.max( + 1, + Math.min(maxAllowedWidth, Math.max(item.minW ?? 2, Math.round(item.w))), + ) + const height = Math.max( + 1, + Math.min(maxAllowedHeight, Math.max(item.minH ?? 2, Math.round(item.h))), + ) let placed = false let row = 0 while (!placed && row < MAX_ROWS * 4) { @@ -327,6 +364,8 @@ function packLayout(items: LayoutStateItem[], columns: number): PackedLayoutItem y: row, minW: item.minW, minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, static: item.static, }) placed = true @@ -348,6 +387,8 @@ function packLayout(items: LayoutStateItem[], columns: number): PackedLayoutItem y: occupied.length - height, minW: item.minW, minH: item.minH, + maxW: item.maxW, + maxH: item.maxH, static: item.static, }) } @@ -364,23 +405,27 @@ function buildInitialLayout( existingLayout?.forEach((item) => layoutMap.set(item.i, item)) return widgets.map((widget) => { const current = layoutMap.get(widget.widgetKey) + const defaults = getWidgetSize(widget.type) if (current) { return { i: widget.widgetKey, w: current.w, h: current.h, - minW: current.minW ?? getWidgetSize(widget.type).minW, - minH: current.minH ?? getWidgetSize(widget.type).minH, + minW: current.minW ?? defaults.minW, + minH: current.minH ?? defaults.minH, + maxW: current.maxW ?? defaults.maxW, + maxH: current.maxH ?? defaults.maxH, static: current.static ?? false, } } - const defaults = getWidgetSize(widget.type) return { i: widget.widgetKey, w: defaults.w, h: defaults.h, minW: defaults.minW, minH: defaults.minH, + maxW: defaults.maxW, + maxH: defaults.maxH, static: false, } }) @@ -395,8 +440,15 @@ function syncLayoutWithWidgets( const serverMap = new Map(layoutFromServer?.map((item) => [item.i, item]) ?? []) return widgets.map((widget) => { const existing = previousMap.get(widget.widgetKey) + const defaults = getWidgetSize(widget.type) if (existing) { - return existing + return { + ...existing, + minW: existing.minW ?? defaults.minW, + minH: existing.minH ?? defaults.minH, + maxW: existing.maxW ?? defaults.maxW, + maxH: existing.maxH ?? defaults.maxH, + } } const server = serverMap.get(widget.widgetKey) if (server) { @@ -404,12 +456,23 @@ function syncLayoutWithWidgets( i: widget.widgetKey, w: server.w, h: server.h, - minW: server.minW ?? getWidgetSize(widget.type).minW, - minH: server.minH ?? getWidgetSize(widget.type).minH, + minW: server.minW ?? defaults.minW, + minH: server.minH ?? defaults.minH, + maxW: server.maxW ?? defaults.maxW, + maxH: server.maxH ?? defaults.maxH, static: server.static ?? false, } } - return buildInitialLayout([widget], layoutFromServer)[0]! + return { + i: widget.widgetKey, + w: defaults.w, + h: defaults.h, + minW: defaults.minW, + minH: defaults.minH, + maxW: defaults.maxW, + maxH: defaults.maxH, + static: false, + } }) } @@ -471,6 +534,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const viewerId = convexUserId as Id<"users"> | null const canEdit = editable && Boolean(viewerId) && isStaff const hasDashboardId = typeof dashboardId === "string" && dashboardId.length > 0 + const userRole = session?.user.role ?? null const detail = useQuery( api.dashboards.get, @@ -522,7 +586,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } setLayoutState(syncedLayout) } filtersHydratingRef.current = false - }, [detail]) + }, [detail, filters]) useEffect(() => { layoutRef.current = layoutState @@ -530,6 +594,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState]) + const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole]) + const widgetMap = useMemo(() => { const map = new Map() widgets.forEach((widget) => map.set(widget.widgetKey, widget)) @@ -604,6 +670,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } ), ...(item.minW !== undefined ? { minW: item.minW } : {}), ...(item.minH !== undefined ? { minH: item.minH } : {}), + ...(item.maxW !== undefined ? { maxW: item.maxW } : {}), + ...(item.maxH !== undefined ? { maxH: item.maxH } : {}), } satisfies CanvasRenderableItem }) .filter(Boolean) as CanvasRenderableItem[] @@ -639,12 +707,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } async (nextState: LayoutStateItem[]) => { if (!canEdit || !viewerId || !dashboard) return const packed = packLayout(nextState, GRID_COLUMNS) + const sanitized = packed.map(({ maxW, maxH, ...rest }) => rest) try { await updateLayoutMutation({ tenantId, actorId: viewerId as Id<"users">, dashboardId: dashboard.id, - layout: packed, + layout: sanitized, }) } catch (error) { console.error(error) @@ -657,7 +726,18 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const handleLayoutResize = useCallback( (key: string, size: { w: number; h: number }, options?: { commit?: boolean }) => { setLayoutState((prev) => { - const next = prev.map((item) => (item.i === key ? { ...item, w: size.w, h: size.h } : item)) + const next = prev.map((item) => { + if (item.i !== key) return item + const minW = item.minW ?? 1 + const minH = item.minH ?? 1 + const maxW = item.maxW ?? GRID_COLUMNS + const maxH = item.maxH ?? MAX_ROWS + return { + ...item, + w: Math.min(maxW, Math.max(minW, size.w)), + h: Math.min(maxH, Math.max(minH, size.h)), + } + }) if (options?.commit) { persistLayout(next) } @@ -748,29 +828,59 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const handleUpdateWidgetConfig = async (values: WidgetConfigFormValues) => { if (!configTarget || !canEdit || !viewerId || !dashboard) return - const currentConfig = configTarget.config && typeof configTarget.config === "object" ? (configTarget.config as Record) : {} - const nextConfig = { + const currentConfig = + configTarget.config && typeof configTarget.config === "object" + ? (configTarget.config as Record) + : {} + const preset = getMetricDefinition(values.metricKey) + + const baseDataSource = (currentConfig.dataSource as Record | undefined) ?? {} + const baseParams = (baseDataSource.params as Record | undefined) ?? {} + const params: Record = { ...baseParams } + if (values.rangeOverride && values.rangeOverride.trim().length > 0) { + params.range = values.rangeOverride.trim() + } else if ("range" in params) { + delete params.range + } + + const baseEncoding = (currentConfig.encoding as Record | undefined) ?? {} + const mergedEncoding: Record = { ...baseEncoding } + if (preset?.encoding) { + Object.assign(mergedEncoding, preset.encoding) + } + mergedEncoding.stacked = values.stacked ?? preset?.stacked ?? false + + const baseOptions = (currentConfig.options as Record | undefined) ?? {} + const mergedOptions: Record = { + ...baseOptions, + ...(preset?.options ?? {}), + legend: values.legend ?? true, + tooltip: values.showTooltip ?? true, + } + if (preset?.options?.indicator) { + mergedOptions.indicator = preset.options.indicator + } + if (preset?.options?.valueFormatter) { + mergedOptions.valueFormatter = preset.options.valueFormatter + } + + const nextConfig: Record = { ...currentConfig, type: values.type, title: values.title, dataSource: { - ...(currentConfig.dataSource as Record | undefined), + ...baseDataSource, metricKey: values.metricKey, - params: { - ...((currentConfig.dataSource as { params?: Record } | undefined)?.params ?? {}), - ...(values.rangeOverride ? { range: values.rangeOverride } : {}), - }, - }, - encoding: { - ...(currentConfig.encoding as Record | undefined), - stacked: values.stacked ?? false, - }, - options: { - ...(currentConfig.options as Record | undefined), - legend: values.legend ?? true, - tooltip: values.showTooltip ?? true, + params, }, + encoding: mergedEncoding, + options: mergedOptions, } + + if (preset?.columns) { + nextConfig.columns = preset.columns + } + try { await updateWidgetMutation({ tenantId, @@ -789,7 +899,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } } } - const handleUpdateMetadata = async (payload: { name?: string; description?: string | null }) => { + const handleUpdateMetadata = async (payload: { name?: string; description?: string | null; tvIntervalSeconds?: number }) => { if (!dashboard || !viewerId || !canEdit) return try { await updateMetadataMutation({ @@ -798,6 +908,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } dashboardId: dashboard.id, name: payload.name, description: payload.description ?? undefined, + tvIntervalSeconds: payload.tvIntervalSeconds, }) } catch (error) { console.error(error) @@ -848,6 +959,20 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } } } + const handleToggleTvMode = useCallback(() => { + if (!dashboardId) return + const baseRoute = `/dashboards/${dashboardId}` + const params = new URLSearchParams(searchParams ? searchParams.toString() : "") + if (enforceTv) { + params.delete("tv") + const query = params.toString() + router.push(query ? `${baseRoute}?${query}` : baseRoute) + } else { + params.set("tv", "1") + router.push(`${baseRoute}?${params.toString()}`) + } + }, [dashboardId, enforceTv, router, searchParams]) + const handleFiltersChange = (next: DashboardFilters) => { setFilters(next) } @@ -880,6 +1005,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } enforceTv={enforceTv} activeSectionIndex={activeSectionIndex} totalSections={sections.length} + onToggleTvMode={handleToggleTvMode} + totalWidgets={widgets.length} /> @@ -921,11 +1048,23 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } editable={canEdit && !enforceTv && mode !== "print"} columns={GRID_COLUMNS} rowHeight={DEFAULT_ROW_HEIGHT} + gap={20} ready={allWidgetsReady} onResize={handleLayoutResize} onReorder={handleLayoutReorder} /> + {canEdit && !enforceTv ? ( + + ) : null} + { @@ -933,6 +1072,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } if (!open) setConfigTarget(null) }} widget={configTarget} + metricOptions={metricOptions} onSubmit={handleUpdateWidgetConfig} /> @@ -959,6 +1099,8 @@ function BuilderHeader({ enforceTv, activeSectionIndex, totalSections, + onToggleTvMode, + totalWidgets, }: { dashboard: DashboardRecord canEdit: boolean @@ -966,105 +1108,272 @@ function BuilderHeader({ onExport: (format: "pdf" | "png") => Promise isAddingWidget: boolean isExporting: boolean - onMetadataChange: (payload: { name?: string; description?: string | null }) => void + onMetadataChange: (payload: { name?: string; description?: string | null; tvIntervalSeconds?: number }) => void enforceTv: boolean activeSectionIndex: number totalSections: number + onToggleTvMode: () => void + totalWidgets: number }) { const [name, setName] = useState(dashboard.name) const [description, setDescription] = useState(dashboard.description ?? "") - const initialNameRef = useRef(dashboard.name) - const initialDescriptionRef = useRef(dashboard.description ?? "") + const [isEditingHeader, setIsEditingHeader] = useState(false) + const [draftName, setDraftName] = useState(dashboard.name) + const [draftDescription, setDraftDescription] = useState(dashboard.description ?? "") + const [isSavingHeader, setIsSavingHeader] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) useEffect(() => { setName(dashboard.name) setDescription(dashboard.description ?? "") - initialNameRef.current = dashboard.name - initialDescriptionRef.current = dashboard.description ?? "" - }, [dashboard.name, dashboard.description]) + if (!isEditingHeader) { + setDraftName(dashboard.name) + setDraftDescription(dashboard.description ?? "") + } + }, [dashboard.name, dashboard.description, isEditingHeader]) - const handleBlurName = () => { + useEffect(() => { + if (typeof document === "undefined") return + const handleFullscreenChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)) + } + document.addEventListener("fullscreenchange", handleFullscreenChange) + handleFullscreenChange() + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange) + } + }, []) + + const toggleFullscreen = useCallback(() => { + if (typeof document === "undefined") return + if (document.fullscreenElement) { + const exit = document.exitFullscreen + if (typeof exit === "function") { + exit.call(document).catch(() => undefined) + } + return + } + const element = document.documentElement + if (element && typeof element.requestFullscreen === "function") { + element.requestFullscreen().catch(() => undefined) + } + }, []) + + const rotationInterval = Math.max(5, dashboard.tvIntervalSeconds ?? 30) + + const handleStartEditHeader = () => { + setDraftName(name) + setDraftDescription(description ?? "") + setIsEditingHeader(true) + } + + const handleCancelEditHeader = () => { + setDraftName(name) + setDraftDescription(description ?? "") + setIsEditingHeader(false) + } + + const handleSaveHeader = async () => { if (!canEdit) return - if (name.trim() && name.trim() !== initialNameRef.current.trim()) { - onMetadataChange({ name: name.trim() }) - initialNameRef.current = name.trim() - } else { - setName(initialNameRef.current) + const trimmedName = draftName.trim() + if (!trimmedName) { + toast.error("Informe um nome para o dashboard.") + return + } + const trimmedDescription = draftDescription.trim() + const nextDescription = trimmedDescription.length > 0 ? trimmedDescription : null + const didChangeName = trimmedName !== name + const didChangeDescription = nextDescription !== (description ?? null) + if (!didChangeName && !didChangeDescription) { + setIsEditingHeader(false) + return + } + setIsSavingHeader(true) + try { + await onMetadataChange({ + name: trimmedName, + description: nextDescription, + }) + setName(trimmedName) + setDescription(nextDescription ?? "") + setIsEditingHeader(false) + toast.success("Informações atualizadas.") + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar o dashboard.") + } finally { + setIsSavingHeader(false) } } - const handleBlurDescription = () => { - if (!canEdit) return - if (description.trim() !== initialDescriptionRef.current.trim()) { - onMetadataChange({ description: description.trim() || null }) - initialDescriptionRef.current = description.trim() - } - } + const isTvMode = enforceTv + const hasSections = totalSections > 0 return ( -
-
- {canEdit ? ( - <> - setName(event.target.value)} - onBlur={handleBlurName} - className="h-12 w-full max-w-xl rounded-2xl text-2xl font-semibold" - /> - setDescription(event.target.value)} - onBlur={handleBlurDescription} - placeholder="Breve descrição para contextualizar o dashboard" - className="h-10 w-full max-w-xl rounded-xl text-sm" - /> - - ) : ( - <> -

{dashboard.name}

- {dashboard.description ? ( -

{dashboard.description}

+ + +
+
+ {isEditingHeader ? ( +
+ setDraftName(event.target.value)} + placeholder="Nome do dashboard" + className="h-10 w-full max-w-xl rounded-lg border border-slate-300 px-3 text-lg font-semibold text-neutral-900" + /> +