From 741f1d7f9c664eac12501dc7c24af4342b9fcb27 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 4 Nov 2025 20:37:34 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adicionar=20construtor=20de=20dashboard?= =?UTF-8?q?s=20e=20api=20de=20m=C3=A9tricas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/_generated/api.d.ts | 4 + convex/dashboards.ts | 767 +++++++++ convex/metrics.ts | 469 ++++++ convex/reports.ts | 18 +- convex/schema.ts | 100 ++ src/app/api/export/pdf/route.ts | 77 + src/app/dashboards/[id]/page.tsx | 35 + src/app/dashboards/[id]/print/page.tsx | 17 + src/app/dashboards/page.tsx | 24 + src/components/app-sidebar.tsx | 2 + .../dashboards/dashboard-builder.tsx | 1439 +++++++++++++++++ src/components/dashboards/dashboard-list.tsx | 273 ++++ src/components/dashboards/report-canvas.tsx | 275 ++++ src/components/dashboards/widget-renderer.tsx | 865 ++++++++++ 14 files changed, 4356 insertions(+), 9 deletions(-) create mode 100644 convex/dashboards.ts create mode 100644 convex/metrics.ts create mode 100644 src/app/api/export/pdf/route.ts create mode 100644 src/app/dashboards/[id]/page.tsx create mode 100644 src/app/dashboards/[id]/print/page.tsx create mode 100644 src/app/dashboards/page.tsx create mode 100644 src/components/dashboards/dashboard-builder.tsx create mode 100644 src/components/dashboards/dashboard-list.tsx create mode 100644 src/components/dashboards/report-canvas.tsx create mode 100644 src/components/dashboards/widget-renderer.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index dce9e18..25c1ce6 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,6 +14,7 @@ import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; import type * as commentTemplates from "../commentTemplates.js"; import type * as companies from "../companies.js"; +import type * as dashboards from "../dashboards.js"; import type * as crons from "../crons.js"; import type * as deviceExportTemplates from "../deviceExportTemplates.js"; import type * as deviceFields from "../deviceFields.js"; @@ -22,6 +23,7 @@ import type * as fields from "../fields.js"; import type * as files from "../files.js"; import type * as invites from "../invites.js"; import type * as machines from "../machines.js"; +import type * as metrics from "../metrics.js"; import type * as migrations from "../migrations.js"; import type * as queues from "../queues.js"; import type * as rbac from "../rbac.js"; @@ -56,6 +58,7 @@ declare const fullApi: ApiFromModules<{ categories: typeof categories; commentTemplates: typeof commentTemplates; companies: typeof companies; + dashboards: typeof dashboards; crons: typeof crons; deviceExportTemplates: typeof deviceExportTemplates; deviceFields: typeof deviceFields; @@ -64,6 +67,7 @@ declare const fullApi: ApiFromModules<{ files: typeof files; invites: typeof invites; machines: typeof machines; + metrics: typeof metrics; migrations: typeof migrations; queues: typeof queues; rbac: typeof rbac; diff --git a/convex/dashboards.ts b/convex/dashboards.ts new file mode 100644 index 0000000..f1d88c9 --- /dev/null +++ b/convex/dashboards.ts @@ -0,0 +1,767 @@ +import { ConvexError, v } from "convex/values" + +import type { Doc, Id } from "./_generated/dataModel" +import { mutation, query } from "./_generated/server" +import { requireStaff } from "./rbac" + +const WIDGET_TYPES = [ + "kpi", + "bar", + "line", + "area", + "pie", + "radar", + "gauge", + "table", + "text", +] as const + +type WidgetType = (typeof WIDGET_TYPES)[number] + +const gridItemValidator = v.object({ + i: v.string(), + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}) + +const widgetLayoutValidator = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}) + +function assertWidgetType(type: string): asserts type is WidgetType { + if (!WIDGET_TYPES.includes(type as WidgetType)) { + throw new ConvexError(`Tipo de widget inválido: ${type}`) + } +} + +function sanitizeTitle(input?: string | null): string | undefined { + if (!input) return undefined + const trimmed = input.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function generateWidgetKey(dashboardId: Id<"dashboards">) { + const rand = Math.random().toString(36).slice(2, 8) + return `${dashboardId.toString().slice(-6)}-${rand}` +} + +function normalizeWidgetConfig(type: WidgetType, config: unknown) { + if (config && typeof config === "object") { + return config + } + switch (type) { + case "kpi": + return { + type: "kpi", + title: "Novo KPI", + dataSource: { metricKey: "tickets.waiting_action_now" }, + options: { trend: "tickets.waiting_action_last_7d" }, + } + case "bar": + return { + type: "bar", + title: "Abertos x Resolvidos", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [ + { field: "opened", label: "Abertos" }, + { field: "resolved", label: "Resolvidos" }, + ], + }, + options: { legend: true }, + } + case "line": + return { + type: "line", + title: "Resoluções por dia", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [{ field: "resolved", label: "Resolvidos" }], + }, + options: { legend: false }, + } + case "area": + return { + type: "area", + title: "Volume acumulado", + dataSource: { metricKey: "tickets.opened_resolved_by_day", params: { range: "30d" } }, + encoding: { + x: "date", + y: [ + { field: "opened", label: "Abertos" }, + { field: "resolved", label: "Resolvidos" }, + ], + stacked: true, + }, + options: { legend: true }, + } + case "pie": + return { + type: "pie", + title: "Backlog por prioridade", + dataSource: { metricKey: "tickets.open_by_priority", params: { range: "30d" } }, + encoding: { category: "priority", value: "total" }, + options: { legend: true }, + } + case "radar": + return { + type: "radar", + title: "SLA por fila", + dataSource: { metricKey: "tickets.sla_compliance_by_queue", params: { range: "30d" } }, + encoding: { angle: "queue", radius: "compliance" }, + options: {}, + } + case "gauge": + return { + type: "gauge", + title: "Cumprimento de SLA", + dataSource: { metricKey: "tickets.sla_rate", params: { range: "7d" } }, + options: { min: 0, max: 1, thresholds: [0.5, 0.8] }, + } + case "table": + return { + type: "table", + title: "Tickets recentes", + dataSource: { metricKey: "tickets.awaiting_table", params: { limit: 20 } }, + columns: [ + { field: "reference", label: "Ref." }, + { field: "subject", label: "Assunto" }, + { field: "status", label: "Status" }, + { field: "priority", label: "Prioridade" }, + { field: "updatedAt", label: "Atualizado em" }, + ], + options: { downloadCSV: true }, + } + case "text": + default: + return { + type: "text", + title: "Notas", + content: "Use este espaço para destacar insights ou orientações.", + } + } +} + +function sanitizeDashboard(dashboard: Doc<"dashboards">) { + return { + id: dashboard._id, + tenantId: dashboard.tenantId, + name: dashboard.name, + description: dashboard.description ?? null, + aspectRatio: dashboard.aspectRatio ?? "16:9", + theme: dashboard.theme ?? "system", + filters: dashboard.filters ?? {}, + layout: dashboard.layout ?? [], + sections: dashboard.sections ?? [], + tvIntervalSeconds: dashboard.tvIntervalSeconds ?? 30, + readySelector: dashboard.readySelector ?? null, + createdBy: dashboard.createdBy, + updatedBy: dashboard.updatedBy ?? null, + createdAt: dashboard.createdAt, + updatedAt: dashboard.updatedAt, + isArchived: dashboard.isArchived ?? false, + } +} + +function normalizeOrder(index: number) { + return index >= 0 ? index : 0 +} + +export const list = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + includeArchived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, viewerId, includeArchived }) => { + await requireStaff(ctx, viewerId, tenantId) + const dashboards = await ctx.db + .query("dashboards") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const filtered = (includeArchived ? dashboards : dashboards.filter((d) => !(d.isArchived ?? false))).sort( + (a, b) => b.updatedAt - a.updatedAt, + ) + + return Promise.all( + filtered.map(async (dashboard) => { + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboard._id)) + .collect() + return { + ...sanitizeDashboard(dashboard), + widgetsCount: widgets.length, + } + }), + ) + }, +}) + +export const get = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + dashboardId: v.id("dashboards"), + }, + handler: async (ctx, { tenantId, viewerId, dashboardId }) => { + await requireStaff(ctx, viewerId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard_order", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + widgets.sort((a, b) => a.order - b.order || a.createdAt - b.createdAt) + + const shares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + return { + dashboard: sanitizeDashboard(dashboard), + widgets: widgets.map((widget) => ({ + id: widget._id, + dashboardId: widget.dashboardId, + widgetKey: widget.widgetKey, + title: widget.title ?? null, + type: widget.type, + config: widget.config, + layout: widget.layout ?? null, + order: widget.order, + isHidden: widget.isHidden ?? false, + createdAt: widget.createdAt, + updatedAt: widget.updatedAt, + })), + shares: shares.map((share) => ({ + id: share._id, + audience: share.audience, + token: share.token ?? null, + expiresAt: share.expiresAt ?? null, + canEdit: share.canEdit, + createdBy: share.createdBy, + createdAt: share.createdAt, + lastAccessAt: share.lastAccessAt ?? null, + })), + } + }, +}) + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, name, description, aspectRatio, theme }) => { + await requireStaff(ctx, actorId, tenantId) + const trimmedName = name.trim() + if (trimmedName.length === 0) { + throw new ConvexError("Nome do dashboard inválido") + } + + const now = Date.now() + const dashboardId = await ctx.db.insert("dashboards", { + tenantId, + name: trimmedName, + description: sanitizeTitle(description), + aspectRatio: aspectRatio?.trim() || "16:9", + theme: theme?.trim() || "system", + filters: {}, + layout: [], + sections: [], + tvIntervalSeconds: 30, + readySelector: "[data-dashboard-ready=true]", + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isArchived: false, + }) + + return { id: dashboardId } + }, +}) + +export const updateMetadata = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + name: v.optional(v.string()), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + readySelector: v.optional(v.string()), + tvIntervalSeconds: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, name, description, aspectRatio, theme, readySelector, tvIntervalSeconds }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + const patch: Partial> = {} + if (typeof name === "string") { + const trimmed = name.trim() + if (!trimmed) throw new ConvexError("Nome do dashboard inválido") + patch.name = trimmed + } + if (typeof description !== "undefined") { + patch.description = sanitizeTitle(description) + } + if (typeof aspectRatio === "string") { + patch.aspectRatio = aspectRatio.trim() || "16:9" + } + if (typeof theme === "string") { + patch.theme = theme.trim() || "system" + } + if (typeof readySelector === "string") { + patch.readySelector = readySelector.trim() || "[data-dashboard-ready=true]" + } + if (typeof tvIntervalSeconds === "number" && Number.isFinite(tvIntervalSeconds) && tvIntervalSeconds > 0) { + patch.tvIntervalSeconds = Math.max(5, Math.round(tvIntervalSeconds)) + } + patch.updatedAt = Date.now() + patch.updatedBy = actorId + await ctx.db.patch(dashboardId, patch) + return { ok: true } + }, +}) + +export const updateFilters = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + filters: v.any(), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, filters }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + await ctx.db.patch(dashboardId, { + filters, + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const updateSections = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + sections: v.array( + v.object({ + id: v.string(), + title: v.optional(v.string()), + description: v.optional(v.string()), + widgetKeys: v.array(v.string()), + durationSeconds: v.optional(v.number()), + }), + ), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, sections }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + const normalized = sections.map((section) => ({ + ...section, + title: sanitizeTitle(section.title), + description: sanitizeTitle(section.description), + durationSeconds: + typeof section.durationSeconds === "number" && Number.isFinite(section.durationSeconds) + ? Math.max(5, Math.round(section.durationSeconds)) + : undefined, + })) + await ctx.db.patch(dashboardId, { + sections: normalized, + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const updateLayout = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + layout: v.array(gridItemValidator), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, layout }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const widgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const byKey = new Map>() + widgets.forEach((widget) => byKey.set(widget.widgetKey, widget)) + + const now = Date.now() + await ctx.db.patch(dashboardId, { + layout, + updatedAt: now, + updatedBy: actorId, + }) + + for (let index = 0; index < layout.length; index++) { + const item = layout[index] + const widget = byKey.get(item.i) + if (!widget) { + throw new ConvexError(`Widget ${item.i} não encontrado neste dashboard`) + } + await ctx.db.patch(widget._id, { + layout: { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + minW: item.minW, + minH: item.minH, + static: item.static, + }, + order: normalizeOrder(index), + updatedAt: now, + updatedBy: actorId, + }) + } + + return { ok: true } + }, +}) + +export const addWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + type: v.string(), + title: v.optional(v.string()), + config: v.optional(v.any()), + layout: v.optional(widgetLayoutValidator), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, type, title, config, layout }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + assertWidgetType(type) + const widgetKey = generateWidgetKey(dashboardId) + const now = Date.now() + const existingWidgets = await ctx.db + .query("dashboardWidgets") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const widgetId = await ctx.db.insert("dashboardWidgets", { + tenantId, + dashboardId, + widgetKey, + title: sanitizeTitle(title), + type, + config: normalizeWidgetConfig(type, config), + layout: layout ?? undefined, + order: existingWidgets.length, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isHidden: false, + }) + + const nextLayout = [...(dashboard.layout ?? [])] + if (layout) { + nextLayout.push({ i: widgetKey, ...layout }) + } else { + const baseY = Math.max(0, nextLayout.length * 4) + nextLayout.push({ i: widgetKey, x: 0, y: baseY, w: 6, h: 6 }) + } + + await ctx.db.patch(dashboardId, { + layout: nextLayout, + updatedAt: now, + updatedBy: actorId, + }) + + return { id: widgetId, widgetKey } + }, +}) + +export const updateWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + title: v.optional(v.string()), + type: v.optional(v.string()), + config: v.optional(v.any()), + layout: v.optional(widgetLayoutValidator), + hidden: v.optional(v.boolean()), + order: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, widgetId, title, type, config, layout, hidden, order }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + + const patch: Partial> = {} + if (typeof title !== "undefined") { + patch.title = sanitizeTitle(title) + } + if (typeof type === "string") { + assertWidgetType(type) + patch.type = type + patch.config = normalizeWidgetConfig(type, config ?? widget.config) + } else if (typeof config !== "undefined") { + patch.config = config + } + if (typeof layout !== "undefined") { + patch.layout = layout + } + if (typeof hidden === "boolean") { + patch.isHidden = hidden + } + if (typeof order === "number" && Number.isFinite(order)) { + patch.order = Math.max(0, Math.round(order)) + } + patch.updatedAt = Date.now() + patch.updatedBy = actorId + + await ctx.db.patch(widgetId, patch) + return { ok: true } + }, +}) + +export const duplicateWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + }, + handler: async (ctx, { tenantId, actorId, widgetId }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + const dashboard = await ctx.db.get(widget.dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + assertWidgetType(widget.type) + const now = Date.now() + const widgetKey = generateWidgetKey(widget.dashboardId) + const newWidgetId = await ctx.db.insert("dashboardWidgets", { + tenantId, + dashboardId: widget.dashboardId, + widgetKey, + title: sanitizeTitle(widget.title), + type: widget.type, + config: widget.config, + layout: widget.layout ?? undefined, + order: widget.order + 1, + createdBy: actorId, + updatedBy: actorId, + createdAt: now, + updatedAt: now, + isHidden: widget.isHidden ?? false, + }) + + const duplicateLayout = widget.layout + ? { + x: widget.layout.x, + y: (widget.layout.y ?? 0) + (widget.layout.h ?? 6) + 1, + w: widget.layout.w, + h: widget.layout.h ?? 6, + minW: widget.layout.minW, + minH: widget.layout.minH, + static: widget.layout.static, + } + : { x: 0, y: Math.max(0, (dashboard.layout?.length ?? 0) * 4), w: 6, h: 6 } + const nextLayout = [...(dashboard.layout ?? []), { i: widgetKey, ...duplicateLayout }] + await ctx.db.patch(dashboard._id, { + layout: nextLayout, + updatedAt: now, + updatedBy: actorId, + }) + return { id: newWidgetId, widgetKey } + }, +}) + +export const removeWidget = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + widgetId: v.id("dashboardWidgets"), + }, + handler: async (ctx, { tenantId, actorId, widgetId }) => { + await requireStaff(ctx, actorId, tenantId) + const widget = await ctx.db.get(widgetId) + if (!widget || widget.tenantId !== tenantId) { + throw new ConvexError("Widget não encontrado") + } + const dashboard = await ctx.db.get(widget.dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + await ctx.db.delete(widgetId) + const filteredLayout = (dashboard.layout ?? []).filter((item) => item.i !== widget.widgetKey) + await ctx.db.patch(dashboard._id, { + layout: filteredLayout, + updatedAt: Date.now(), + updatedBy: actorId, + }) + + return { ok: true } + }, +}) + +export const archive = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + archived: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, archived }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + await ctx.db.patch(dashboardId, { + isArchived: archived ?? !(dashboard.isArchived ?? false), + updatedAt: Date.now(), + updatedBy: actorId, + }) + return { ok: true } + }, +}) + +export const upsertShare = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + audience: v.union(v.literal("private"), v.literal("tenant"), v.literal("public-link")), + canEdit: v.boolean(), + expiresAt: v.optional(v.union(v.number(), v.null())), + token: v.optional(v.union(v.string(), v.null())), + }, + handler: async (ctx, { tenantId, actorId, dashboardId, audience, canEdit, expiresAt, token }) => { + await requireStaff(ctx, actorId, tenantId) + const dashboard = await ctx.db.get(dashboardId) + if (!dashboard || dashboard.tenantId !== tenantId) { + throw new ConvexError("Dashboard não encontrado") + } + + const existingShares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + const now = Date.now() + let shareDoc = existingShares.find((share) => share.audience === audience) + const normalizedExpiresAt = + typeof expiresAt === "number" && Number.isFinite(expiresAt) ? Math.max(now, Math.round(expiresAt)) : undefined + const normalizedToken = typeof token === "string" && token.trim().length > 0 ? token.trim() : undefined + + if (!shareDoc) { + const generatedToken = audience === "public-link" ? normalizedToken ?? cryptoToken() : undefined + await ctx.db.insert("dashboardShares", { + tenantId, + dashboardId, + audience, + token: generatedToken, + canEdit, + expiresAt: normalizedExpiresAt, + createdBy: actorId, + createdAt: now, + lastAccessAt: undefined, + }) + } else { + await ctx.db.patch(shareDoc._id, { + canEdit, + token: audience === "public-link" ? normalizedToken ?? shareDoc.token ?? cryptoToken() : undefined, + expiresAt: normalizedExpiresAt, + lastAccessAt: shareDoc.lastAccessAt, + }) + } + + await ctx.db.patch(dashboardId, { updatedAt: now, updatedBy: actorId }) + return { ok: true } + }, +}) + +export const revokeShareToken = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + dashboardId: v.id("dashboards"), + }, + handler: async (ctx, { tenantId, actorId, dashboardId }) => { + await requireStaff(ctx, actorId, tenantId) + const shares = await ctx.db + .query("dashboardShares") + .withIndex("by_dashboard", (q) => q.eq("dashboardId", dashboardId)) + .collect() + + for (const share of shares) { + if (share.audience === "public-link") { + await ctx.db.patch(share._id, { + token: cryptoToken(), + lastAccessAt: undefined, + }) + } + } + await ctx.db.patch(dashboardId, { updatedAt: Date.now(), updatedBy: actorId }) + return { ok: true } + }, +}) + +function cryptoToken() { + return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6) +} diff --git a/convex/metrics.ts b/convex/metrics.ts new file mode 100644 index 0000000..27f90f8 --- /dev/null +++ b/convex/metrics.ts @@ -0,0 +1,469 @@ +import { v } from "convex/values" + +import type { Id } from "./_generated/dataModel" +import { query } from "./_generated/server" +import { + OPEN_STATUSES, + ONE_DAY_MS, + fetchScopedTickets, + fetchScopedTicketsByCreatedRange, + fetchScopedTicketsByResolvedRange, + normalizeStatus, +} from "./reports" +import { requireStaff } from "./rbac" + +type Viewer = Awaited> + +type MetricResolverInput = { + tenantId: string + viewer: Viewer + viewerId: Id<"users"> + params?: Record | undefined +} + +type MetricRunPayload = { + meta: { kind: string; key: string } & Record + data: unknown +} + +type MetricResolver = (ctx: Parameters[0], input: MetricResolverInput) => Promise + +function parseRange(params?: Record): number { + const value = params?.range + if (typeof value === "string") { + const normalized = value.toLowerCase() + if (normalized === "7d") return 7 + if (normalized === "90d") return 90 + } + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.min(365, Math.max(1, Math.round(value))) + } + return 30 +} + +function parseLimit(params?: Record, fallback = 20) { + const value = params?.limit + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.min(200, Math.round(value)) + } + return fallback +} + +function parseCompanyId(params?: Record): Id<"companies"> | undefined { + const value = params?.companyId + if (typeof value === "string" && value.length > 0) { + return value as Id<"companies"> + } + return undefined +} + +function parseQueueIds(params?: Record): string[] | undefined { + const value = params?.queueIds ?? params?.queueId + if (Array.isArray(value)) { + const clean = value + .map((entry) => (typeof entry === "string" ? entry.trim() : null)) + .filter((entry): entry is string => Boolean(entry && entry.length > 0)) + return clean.length > 0 ? clean : undefined + } + if (typeof value === "string") { + const trimmed = value.trim() + return trimmed.length > 0 ? [trimmed] : undefined + } + return undefined +} + +function filterTicketsByQueue | null }>( + tickets: T[], + queueIds?: string[], +): T[] { + if (!queueIds || queueIds.length === 0) { + return tickets + } + const normalized = new Set(queueIds.map((id) => id.trim())) + const includesNull = normalized.has("sem-fila") || normalized.has("null") + return tickets.filter((ticket) => { + if (!ticket.queueId) { + return includesNull + } + return normalized.has(String(ticket.queueId)) + }) +} + +const metricResolvers: Record = { + "tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + const queueIds = parseQueueIds(params) + 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 openedTickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + queueIds, + ) + const resolvedTickets = filterTicketsByQueue( + await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + queueIds, + ) + + const opened: Record = {} + const resolved: Record = {} + + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + opened[key] = 0 + resolved[key] = 0 + } + + for (const ticket of openedTickets) { + if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { + const key = formatDateKey(ticket.createdAt) + opened[key] = (opened[key] ?? 0) + 1 + } + } + + for (const ticket of resolvedTickets) { + if (typeof ticket.resolvedAt !== "number") continue + if (ticket.resolvedAt < startMs || ticket.resolvedAt >= endMs) continue + const key = formatDateKey(ticket.resolvedAt) + resolved[key] = (resolved[key] ?? 0) + 1 + } + + const series = [] + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) + } + + return { + meta: { kind: "series", key: "tickets.opened_resolved_by_day", rangeDays }, + data: series, + } + }, + "tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => { + const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const now = Date.now() + let total = 0 + let atRisk = 0 + + for (const ticket of tickets) { + const status = normalizeStatus(ticket.status) + if (!OPEN_STATUSES.has(status)) continue + total += 1 + if (ticket.dueAt && ticket.dueAt < now) { + atRisk += 1 + } + } + + return { + meta: { kind: "single", key: "tickets.waiting_action_now", unit: "tickets" }, + data: { value: total, atRisk }, + } + }, + "tickets.waiting_action_last_7d": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = 7 + 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 tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + + const daily: Record = {} + for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { + const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) + const key = formatDateKey(d.getTime()) + daily[key] = { total: 0, atRisk: 0 } + } + + for (const ticket of tickets) { + if (ticket.createdAt < startMs) continue + const key = formatDateKey(ticket.createdAt) + const bucket = daily[key] + if (!bucket) continue + if (OPEN_STATUSES.has(normalizeStatus(ticket.status))) { + bucket.total += 1 + if (ticket.dueAt && ticket.dueAt < Date.now()) { + bucket.atRisk += 1 + } + } + } + + const values = Object.values(daily) + const total = values.reduce((sum, item) => sum + item.total, 0) + const atRisk = values.reduce((sum, item) => sum + item.atRisk, 0) + + return { + meta: { kind: "single", key: "tickets.waiting_action_last_7d", aggregation: "sum", rangeDays }, + data: { value: total, atRisk }, + } + }, + "tickets.open_by_priority": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + 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 tickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + parseQueueIds(params), + ) + + const counts: Record = {} + for (const ticket of tickets) { + if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue + const key = (ticket.priority ?? "MEDIUM").toUpperCase() + counts[key] = (counts[key] ?? 0) + 1 + } + + const data = Object.entries(counts).map(([priority, total]) => ({ priority, total })) + data.sort((a, b) => b.total - a.total) + + return { + meta: { kind: "collection", key: "tickets.open_by_priority", rangeDays }, + data, + } + }, + "tickets.open_by_queue": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + 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 queueFilter = parseQueueIds(params) + const tickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), + parseQueueIds(params), + ) + + const queueCounts = new Map() + for (const ticket of tickets) { + if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue + const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" + if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { + continue + } + queueCounts.set(queueKey, (queueCounts.get(queueKey) ?? 0) + 1) + } + + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const data = Array.from(queueCounts.entries()).map(([queueId, total]) => { + const queue = queues.find((q) => String(q._id) === queueId) + return { + queueId, + name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", + total, + } + }) + + data.sort((a, b) => b.total - a.total) + + return { + meta: { kind: "collection", key: "tickets.open_by_queue", rangeDays }, + data, + } + }, + "tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + 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 queueFilter = parseQueueIds(params) + const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const now = Date.now() + const stats = new Map() + + for (const ticket of tickets) { + const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" + if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { + continue + } + const current = stats.get(queueKey) ?? { total: 0, compliant: 0 } + current.total += 1 + const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null + let compliant = false + if (dueAt) { + if (resolvedAt) { + compliant = resolvedAt <= dueAt + } else { + compliant = dueAt >= now + } + } else { + compliant = resolvedAt !== null + } + if (compliant) { + current.compliant += 1 + } + stats.set(queueKey, current) + } + + const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const data = Array.from(stats.entries()).map(([queueId, value]) => { + const queue = queues.find((q) => String(q._id) === queueId) + const compliance = value.total > 0 ? value.compliant / value.total : 0 + return { + queueId, + name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", + total: value.total, + compliance, + } + }) + + data.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0)) + + return { + meta: { kind: "collection", key: "tickets.sla_compliance_by_queue", rangeDays }, + data, + } + }, + "tickets.sla_rate": async (ctx, { tenantId, viewer, params }) => { + const rangeDays = parseRange(params) + const companyId = parseCompanyId(params) + 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 tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + + const total = tickets.length + const resolved = tickets.filter((t) => normalizeStatus(t.status) === "RESOLVED").length + const rate = total > 0 ? resolved / total : 0 + + return { + meta: { kind: "single", key: "tickets.sla_rate", rangeDays, unit: "ratio" }, + data: { value: rate, total, resolved }, + } + }, + "tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => { + const limit = parseLimit(params, 20) + const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const awaiting = tickets + .filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, limit) + .map((ticket) => ({ + id: ticket._id, + reference: ticket.reference ?? null, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority, + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + queueId: ticket.queueId ?? null, + })) + + return { + meta: { kind: "table", key: "tickets.awaiting_table", limit }, + data: awaiting, + } + }, + "devices.health_summary": async (ctx, { tenantId, params }) => { + const limit = parseLimit(params, 10) + const machines = await ctx.db.query("machines").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() + const now = Date.now() + const summary = machines + .map((machine) => { + const lastHeartbeatAt = machine.lastHeartbeatAt ?? null + const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null + const status = deriveMachineStatus(machine, now) + const cpu = clampPercent(machine.cpuUsagePercent) + const memory = clampPercent(machine.memoryUsedPercent) + const disk = clampPercent(machine.diskUsedPercent ?? machine.diskUsagePercent) + const alerts = Array.isArray(machine.postureAlerts) ? machine.postureAlerts.length : machine.postureAlertsCount ?? 0 + const attention = + (cpu ?? 0) > 85 || + (memory ?? 0) > 90 || + (disk ?? 0) > 90 || + (minutesSinceHeartbeat ?? Infinity) > 120 || + alerts > 0 + return { + id: machine._id, + hostname: machine.hostname ?? machine.computerName ?? "Dispositivo sem nome", + status, + cpuUsagePercent: cpu, + memoryUsedPercent: memory, + diskUsedPercent: disk, + lastHeartbeatAt, + minutesSinceHeartbeat, + alerts, + attention, + } + }) + .sort((a, b) => { + if (a.attention === b.attention) { + return (b.cpuUsagePercent ?? 0) - (a.cpuUsagePercent ?? 0) + } + return a.attention ? -1 : 1 + }) + .slice(0, limit) + + return { + meta: { kind: "collection", key: "devices.health_summary", limit }, + data: summary, + } + }, +} + +export const run = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + metricKey: v.string(), + params: v.optional(v.any()), + }, + handler: async (ctx, { tenantId, viewerId, metricKey, params }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId) + const resolver = metricResolvers[metricKey] + if (!resolver) { + return { + meta: { kind: "error", key: metricKey, message: "Métrica não suportada" }, + data: null, + } + } + const payload = await resolver(ctx, { + tenantId, + viewer, + viewerId, + params: params && typeof params === "object" ? (params as Record) : undefined, + }) + return payload + }, +}) + +function formatDateKey(timestamp: number) { + const d = new Date(timestamp) + const year = d.getUTCFullYear() + const month = `${d.getUTCMonth() + 1}`.padStart(2, "0") + const day = `${d.getUTCDate()}`.padStart(2, "0") + return `${year}-${month}-${day}` +} + +function deriveMachineStatus(machine: Record, now: number) { + const lastHeartbeatAt = typeof machine.lastHeartbeatAt === "number" ? machine.lastHeartbeatAt : null + if (!lastHeartbeatAt) return "unknown" + const diffMinutes = (now - lastHeartbeatAt) / 60000 + if (diffMinutes <= 10) return "online" + if (diffMinutes <= 120) return "stale" + return "offline" +} + +function clampPercent(value: unknown) { + if (typeof value !== "number" || !Number.isFinite(value)) return null + if (value < 0) return 0 + if (value > 100) return 100 + return Math.round(value * 10) / 10 +} diff --git a/convex/reports.ts b/convex/reports.ts index ce9e031..989923c 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -6,9 +6,9 @@ import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"; -type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; +export type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; type QueryFilterBuilder = { lt: (field: unknown, value: number) => unknown; field: (name: string) => unknown }; -const STATUS_NORMALIZE_MAP: Record = { +export const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", @@ -19,7 +19,7 @@ const STATUS_NORMALIZE_MAP: Record = { CLOSED: "RESOLVED", }; -function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { +export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; @@ -30,8 +30,8 @@ function average(values: number[]) { return values.reduce((sum, value) => sum + value, 0) / values.length; } -const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); -const ONE_DAY_MS = 24 * 60 * 60 * 1000; +export const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); +export const ONE_DAY_MS = 24 * 60 * 60 * 1000; function percentageChange(current: number, previous: number) { if (previous === 0) { @@ -88,14 +88,14 @@ function isNotNull(value: T | null): value is T { return value !== null; } -async function fetchTickets(ctx: QueryCtx, tenantId: string) { +export async function fetchTickets(ctx: QueryCtx, tenantId: string) { return ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); } -async function fetchScopedTickets( +export async function fetchScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, @@ -114,7 +114,7 @@ async function fetchScopedTickets( return fetchTickets(ctx, tenantId); } -async function fetchScopedTicketsByCreatedRange( +export async function fetchScopedTicketsByCreatedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, @@ -179,7 +179,7 @@ async function fetchScopedTicketsByCreatedRange( ); } -async function fetchScopedTicketsByResolvedRange( +export async function fetchScopedTicketsByResolvedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, diff --git a/convex/schema.ts b/convex/schema.ts index ef473e2..61c2ebd 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,6 +1,35 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +const gridLayoutItem = v.object({ + i: v.string(), + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}); + +const widgetLayout = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + minW: v.optional(v.number()), + minH: v.optional(v.number()), + static: v.optional(v.boolean()), +}); + +const tvSection = v.object({ + id: v.string(), + title: v.optional(v.string()), + description: v.optional(v.string()), + widgetKeys: v.array(v.string()), + durationSeconds: v.optional(v.number()), +}); + export default defineSchema({ users: defineTable({ tenantId: v.string(), @@ -77,6 +106,77 @@ export default defineSchema({ .index("by_tenant_created", ["tenantId", "createdAt"]) .index("by_tenant", ["tenantId"]), + dashboards: defineTable({ + tenantId: v.string(), + name: v.string(), + description: v.optional(v.string()), + aspectRatio: v.optional(v.string()), + theme: v.optional(v.string()), + filters: v.optional(v.any()), + layout: v.optional(v.array(gridLayoutItem)), + sections: v.optional(v.array(tvSection)), + tvIntervalSeconds: v.optional(v.number()), + readySelector: v.optional(v.string()), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + isArchived: v.optional(v.boolean()), + }) + .index("by_tenant", ["tenantId"]) + .index("by_tenant_created", ["tenantId", "createdAt"]), + + dashboardWidgets: defineTable({ + tenantId: v.string(), + dashboardId: v.id("dashboards"), + widgetKey: v.string(), + title: v.optional(v.string()), + type: v.string(), + config: v.any(), + layout: v.optional(widgetLayout), + order: v.number(), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + isHidden: v.optional(v.boolean()), + }) + .index("by_dashboard", ["dashboardId"]) + .index("by_dashboard_order", ["dashboardId", "order"]) + .index("by_dashboard_key", ["dashboardId", "widgetKey"]) + .index("by_tenant", ["tenantId"]), + + metricDefinitions: defineTable({ + tenantId: v.string(), + key: v.string(), + name: v.string(), + description: v.optional(v.string()), + version: v.number(), + definition: v.optional(v.any()), + createdBy: v.id("users"), + updatedBy: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + tags: v.optional(v.array(v.string())), + }) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant", ["tenantId"]), + + dashboardShares: defineTable({ + tenantId: v.string(), + dashboardId: v.id("dashboards"), + audience: v.string(), + token: v.optional(v.string()), + expiresAt: v.optional(v.number()), + canEdit: v.boolean(), + createdBy: v.id("users"), + createdAt: v.number(), + lastAccessAt: v.optional(v.number()), + }) + .index("by_dashboard", ["dashboardId"]) + .index("by_token", ["token"]) + .index("by_tenant", ["tenantId"]), + queues: defineTable({ tenantId: v.string(), name: v.string(), diff --git a/src/app/api/export/pdf/route.ts b/src/app/api/export/pdf/route.ts new file mode 100644 index 0000000..e37cc8e --- /dev/null +++ b/src/app/api/export/pdf/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server" + +export const runtime = "nodejs" + +type ExportRequest = { + url?: string + width?: number + height?: number + format?: "pdf" | "png" + waitForSelector?: string +} + +export async function POST(request: Request) { + let payload: ExportRequest + try { + payload = await request.json() + } catch (error) { + return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) + } + + if (!payload.url) { + return NextResponse.json({ error: "URL obrigatória" }, { status: 400 }) + } + + const { chromium } = await import("playwright") + const width = payload.width ?? 1920 + const height = payload.height ?? 1080 + const format = payload.format ?? "pdf" + const waitForSelector = payload.waitForSelector ?? "[data-dashboard-ready='true']" + + let browser: Awaited> | null = null + + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width, height } }) + await page.goto(payload.url, { waitUntil: "networkidle" }) + if (waitForSelector) { + try { + await page.waitForSelector(waitForSelector, { timeout: 15000 }) + } catch (error) { + console.warn("waitForSelector timeout", error) + } + } + + if (format === "pdf") { + const pdf = await page.pdf({ + width: `${width}px`, + height: `${height}px`, + printBackground: true, + pageRanges: "1", + }) + return new NextResponse(pdf, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": "attachment; filename=dashboard.pdf", + }, + }) + } + + const screenshot = await page.screenshot({ type: "png", fullPage: true }) + return new NextResponse(screenshot, { + status: 200, + headers: { + "Content-Type": "image/png", + "Content-Disposition": "attachment; filename=dashboard.png", + }, + }) + } catch (error) { + console.error("Failed to export dashboard", error) + return NextResponse.json({ error: "Falha ao exportar o dashboard" }, { status: 500 }) + } finally { + if (browser) { + await browser.close() + } + } +} diff --git a/src/app/dashboards/[id]/page.tsx b/src/app/dashboards/[id]/page.tsx new file mode 100644 index 0000000..8ba7866 --- /dev/null +++ b/src/app/dashboards/[id]/page.tsx @@ -0,0 +1,35 @@ +import { AppShell } from "@/components/app-shell" +import { DashboardBuilder } from "@/components/dashboards/dashboard-builder" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" + +type PageProps = { + params: { id: string } + searchParams: { [key: string]: string | string[] | undefined } +} + +export const dynamic = "force-dynamic" + +export default async function DashboardDetailPage({ params, searchParams }: PageProps) { + await requireStaffSession() + const tvMode = searchParams?.tv === "1" + + return ( + + } + > +
+ +
+
+ ) +} diff --git a/src/app/dashboards/[id]/print/page.tsx b/src/app/dashboards/[id]/print/page.tsx new file mode 100644 index 0000000..c86642c --- /dev/null +++ b/src/app/dashboards/[id]/print/page.tsx @@ -0,0 +1,17 @@ +import { DashboardBuilder } from "@/components/dashboards/dashboard-builder" +import { requireStaffSession } from "@/lib/auth-server" + +type PageProps = { + params: { id: string } +} + +export const dynamic = "force-dynamic" + +export default async function DashboardPrintPage({ params }: PageProps) { + await requireStaffSession() + return ( +
+ +
+ ) +} diff --git a/src/app/dashboards/page.tsx b/src/app/dashboards/page.tsx new file mode 100644 index 0000000..fdec6e9 --- /dev/null +++ b/src/app/dashboards/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { DashboardListView } from "@/components/dashboards/dashboard-list" +import { SiteHeader } from "@/components/site-header" +import { requireStaffSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function DashboardsPage() { + await requireStaffSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index d462728..9d3f27f 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import * as React from "react" import { LayoutDashboard, + LayoutTemplate, LifeBuoy, Ticket, PlayCircle, @@ -83,6 +84,7 @@ const navigation: NavigationGroup[] = [ title: "Relatórios", requiredRole: "staff", items: [ + { title: "Dashboards", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" }, { title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx new file mode 100644 index 0000000..9bb1840 --- /dev/null +++ b/src/components/dashboards/dashboard-builder.tsx @@ -0,0 +1,1439 @@ +"use client" + +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { useMutation, useQuery } from "convex/react" +import { useRouter, useSearchParams } from "next/navigation" +import { format } from "date-fns" +import { ptBR } from "date-fns/locale" +import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import { cn } from "@/lib/utils" +import { ReportCanvas } from "@/components/dashboards/report-canvas" +import { + DashboardFilters, + DashboardWidgetRecord, + WidgetConfig, + WidgetRenderer, + useMetricData, +} from "@/components/dashboards/widget-renderer" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Checkbox } from "@/components/ui/checkbox" +import { + SearchableCombobox, + type SearchableComboboxOption, +} from "@/components/ui/searchable-combobox" +import { toast } from "sonner" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Copy, + Download, + Edit3, + LayoutTemplate, + MonitorPlay, + Plus, + Sparkles, + Table2, + Trash2, +} from "lucide-react" + +const GRID_COLUMNS = 12 +const DEFAULT_ROW_HEIGHT = 80 +const MAX_ROWS = 32 + +type DashboardRecord = { + id: Id<"dashboards"> + tenantId: string + name: string + description?: string | null + aspectRatio?: string | null + theme?: string | null + filters?: Record | null + layout?: Array | null + sections?: Array | null + tvIntervalSeconds?: number | null + readySelector?: string | null + createdAt: number + updatedAt: number +} + +type DashboardSection = { + id: string + title?: string | null + description?: string | null + widgetKeys: string[] + durationSeconds?: number | null +} + +type DashboardShareRecord = { + id: Id<"dashboardShares"> + audience: string + token: string | null + expiresAt: number | null + canEdit: boolean + createdBy: Id<"users"> + createdAt: number + lastAccessAt: number | null +} + +type LayoutItemFromServer = { + i: string + x: number + y: number + w: number + h: number + minW?: number + minH?: number + static?: boolean +} + +type LayoutStateItem = { + i: string + w: number + h: number + minW?: number + minH?: number + static?: boolean +} + +type PackedLayoutItem = LayoutStateItem & { + x: number + y: number +} + +type DashboardDetailResult = { + dashboard: DashboardRecord + widgets: DashboardWidgetRecord[] + shares: DashboardShareRecord[] +} + +type DashboardBuilderProps = { + dashboardId: string + editable?: boolean + mode?: "edit" | "view" | "tv" | "print" +} + +const DEFAULT_FILTERS: DashboardFilters = { + range: "30d", + companyId: null, + queueId: null, +} + +const numberFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 }) + +const WIDGET_LIBRARY: Array<{ + type: string + 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." }, +] + +const widgetSizePresets: Record< + string, + { default: { w: number; h: number }; min: { 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 } }, +} + +function getWidgetSize(type: string) { + const preset = widgetSizePresets[type] ?? widgetSizePresets.text + return { ...preset.default, minW: preset.min.w, minH: preset.min.h } +} + +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(handle) + }, [value, delay]) + return debounced +} + +function getWidgetConfigForWidget(widget: DashboardWidgetRecord | null): WidgetConfig | null { + if (!widget) return null + const raw = widget.config + if (raw && typeof raw === "object") { + return { type: widget.type, ...raw } as WidgetConfig + } + return { type: widget.type, title: widget.title ?? undefined } +} + +function mergeFilterParams( + base: Record | undefined, + filters: DashboardFilters, +) { + const merged: Record = { ...(base ?? {}) } + if (filters.range) { + if (filters.range === "custom") { + if (filters.from) merged.from = filters.from + if (filters.to) merged.to = filters.to + } else { + merged.range = filters.range + delete merged.from + delete merged.to + } + } + if (filters.companyId) { + merged.companyId = filters.companyId + } else { + delete merged.companyId + } + if (filters.queueId) { + merged.queueId = filters.queueId + } else { + delete merged.queueId + } + return merged +} + +function normalizeFilters(raw: unknown): DashboardFilters { + if (!raw || typeof raw !== "object") { + return { ...DEFAULT_FILTERS } + } + const record = raw as Record + const range = typeof record.range === "string" ? (record.range as DashboardFilters["range"]) : DEFAULT_FILTERS.range + const from = typeof record.from === "string" ? record.from : null + const to = typeof record.to === "string" ? record.to : null + const companyId = typeof record.companyId === "string" ? record.companyId : null + const queueId = typeof record.queueId === "string" ? record.queueId : null + return { + range, + from, + to, + companyId, + queueId, + } +} + +function packLayout(items: LayoutStateItem[], columns: number): PackedLayoutItem[] { + const occupied: boolean[][] = [] + const packed: PackedLayoutItem[] = [] + + const ensureRows = (rows: number) => { + while (occupied.length < rows) { + occupied.push(Array.from({ length: columns }, () => false)) + } + } + + const canPlace = (row: number, col: number, w: number, h: number) => { + for (let y = row; y < row + h; y++) { + for (let x = col; x < col + w; x++) { + if (occupied[y]?.[x]) { + return false + } + } + } + return true + } + + const place = (row: number, col: number, w: number, h: number) => { + ensureRows(row + h) + for (let y = row; y < row + h; y++) { + for (let x = col; x < col + w; x++) { + occupied[y][x] = true + } + } + } + + 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)))) + let placed = false + let row = 0 + while (!placed && row < MAX_ROWS * 4) { + ensureRows(row + height) + for (let col = 0; col <= columns - width; col++) { + if (canPlace(row, col, width, height)) { + place(row, col, width, height) + packed.push({ + i: item.i, + w: width, + h: height, + x: col, + y: row, + minW: item.minW, + minH: item.minH, + static: item.static, + }) + placed = true + break + } + } + if (!placed) { + row += 1 + } + } + if (!placed) { + ensureRows(occupied.length + height) + place(occupied.length, 0, width, height) + packed.push({ + i: item.i, + w: width, + h: height, + x: 0, + y: occupied.length - height, + minW: item.minW, + minH: item.minH, + static: item.static, + }) + } + } + + return packed +} + +function buildInitialLayout( + widgets: DashboardWidgetRecord[], + existingLayout?: Array | null, +): LayoutStateItem[] { + const layoutMap = new Map() + existingLayout?.forEach((item) => layoutMap.set(item.i, item)) + return widgets.map((widget) => { + const current = layoutMap.get(widget.widgetKey) + 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, + 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, + static: false, + } + }) +} + +function syncLayoutWithWidgets( + previous: LayoutStateItem[], + widgets: DashboardWidgetRecord[], + layoutFromServer?: Array | null, +): LayoutStateItem[] { + const previousMap = new Map(previous.map((item) => [item.i, item])) + const serverMap = new Map(layoutFromServer?.map((item) => [item.i, item]) ?? []) + return widgets.map((widget) => { + const existing = previousMap.get(widget.widgetKey) + if (existing) { + return existing + } + const server = serverMap.get(widget.widgetKey) + if (server) { + return { + i: widget.widgetKey, + w: server.w, + h: server.h, + minW: server.minW ?? getWidgetSize(widget.type).minW, + minH: server.minH ?? getWidgetSize(widget.type).minH, + static: server.static ?? false, + } + } + return buildInitialLayout([widget], layoutFromServer)[0]! + }) +} + +function layoutItemsEqual(a: LayoutStateItem[], b: LayoutStateItem[]) { + if (a.length !== b.length) return false + for (let idx = 0; idx < a.length; idx++) { + const left = a[idx] + const right = b[idx] + if ( + left.i !== right.i || + left.w !== right.w || + left.h !== right.h || + left.minW !== right.minW || + left.minH !== right.minH + ) { + return false + } + } + return true +} + +function filtersEqual(a: DashboardFilters, b: DashboardFilters) { + return ( + a.range === b.range && + a.from === b.from && + a.to === b.to && + a.companyId === b.companyId && + a.queueId === b.queueId + ) +} + +const widgetConfigSchema = z.object({ + title: z.string().min(1, "Informe um título"), + type: z.string(), + metricKey: z.string().min(1, "Informe a métrica"), + stacked: z.boolean().optional(), + legend: z.boolean().optional(), + rangeOverride: z.string().optional(), + showTooltip: z.boolean().optional(), +}) + +type WidgetConfigFormValues = z.infer + +export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }: DashboardBuilderProps) { + const router = useRouter() + const searchParams = useSearchParams() + const tvQuery = searchParams?.get("tv") + const enforceTv = tvQuery === "1" || mode === "tv" + const { session, convexUserId, isStaff } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const viewerId = convexUserId as Id<"users"> | null + const canEdit = editable && Boolean(viewerId) && isStaff + + const detail = useQuery( + api.dashboards.get, + viewerId + ? ({ + tenantId, + viewerId: viewerId as Id<"users">, + dashboardId: dashboardId as Id<"dashboards">, + } as const) + : "skip", + ) as DashboardDetailResult | undefined + + const [dashboard, setDashboard] = useState(null) + const [widgets, setWidgets] = useState([]) + const [shares, setShares] = useState([]) + const [filters, setFilters] = useState({ ...DEFAULT_FILTERS }) + const filtersHydratingRef = useRef(false) + const [layoutState, setLayoutState] = useState([]) + const layoutRef = useRef([]) + const [readyWidgets, setReadyWidgets] = useState>(new Set()) + const [activeSectionIndex, setActiveSectionIndex] = useState(0) + const [isConfigOpen, setIsConfigOpen] = useState(false) + const [configTarget, setConfigTarget] = useState(null) + const [dataTarget, setDataTarget] = useState(null) + const [isAddingWidget, setIsAddingWidget] = useState(false) + const [isExporting, setIsExporting] = useState(false) + + const updateLayoutMutation = useMutation(api.dashboards.updateLayout) + const updateFiltersMutation = useMutation(api.dashboards.updateFilters) + const addWidgetMutation = useMutation(api.dashboards.addWidget) + const updateWidgetMutation = useMutation(api.dashboards.updateWidget) + const duplicateWidgetMutation = useMutation(api.dashboards.duplicateWidget) + const removeWidgetMutation = useMutation(api.dashboards.removeWidget) + const updateMetadataMutation = useMutation(api.dashboards.updateMetadata) + + useEffect(() => { + if (!detail) return + setDashboard(detail.dashboard) + setWidgets(detail.widgets) + setShares(detail.shares) + const nextFilters = normalizeFilters(detail.dashboard.filters) + filtersHydratingRef.current = true + setFilters(nextFilters) + const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout) + if (!layoutItemsEqual(layoutRef.current, syncedLayout)) { + layoutRef.current = syncedLayout + setLayoutState(syncedLayout) + } + filtersHydratingRef.current = false + }, [detail]) + + useEffect(() => { + layoutRef.current = layoutState + }, [layoutState]) + + const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState]) + + const widgetMap = useMemo(() => { + const map = new Map() + widgets.forEach((widget) => map.set(widget.widgetKey, widget)) + return map + }, [widgets]) + + const sections = useMemo(() => dashboard?.sections ?? [], [dashboard?.sections]) + + useEffect(() => { + if (!enforceTv || sections.length <= 1) return + const intervalSeconds = dashboard?.tvIntervalSeconds && dashboard.tvIntervalSeconds > 0 ? dashboard.tvIntervalSeconds : 30 + const rotation = setInterval(() => { + setActiveSectionIndex((prev) => (prev + 1) % sections.length) + }, intervalSeconds * 1000) + return () => clearInterval(rotation) + }, [enforceTv, sections.length, dashboard?.tvIntervalSeconds]) + + const visibleWidgetKeys = useMemo(() => { + if (!enforceTv || sections.length === 0) return null + const currentSection = sections[Math.min(activeSectionIndex, sections.length - 1)] + return new Set(currentSection?.widgetKeys ?? []) + }, [enforceTv, sections, activeSectionIndex]) + + const canvasItems = packedLayout + .map((item) => { + const widget = widgetMap.get(item.i) + if (!widget) return null + if (visibleWidgetKeys && !visibleWidgetKeys.has(item.i)) return null + return { + key: item.i, + layout: item, + minW: item.minW, + minH: item.minH, + element: ( + { + setConfigTarget(widget) + setIsConfigOpen(true) + }} + onDuplicate={() => handleDuplicateWidget(widget)} + onRemove={() => handleRemoveWidget(widget)} + onViewData={() => setDataTarget(widget)} + onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)} + /> + ), + } + }) + .filter((item): item is { key: string; layout: PackedLayoutItem; minW?: number; minH?: number; element: React.ReactNode } => Boolean(item)) + + const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key)) + + const handleWidgetReady = useCallback((key: string, ready: boolean) => { + setReadyWidgets((prev) => { + const next = new Set(prev) + if (ready) { + next.add(key) + } else { + next.delete(key) + } + return next + }) + }, []) + + useEffect(() => { + const keys = new Set(canvasItems.map((item) => item.key)) + setReadyWidgets((prev) => { + const next = new Set() + keys.forEach((key) => { + if (prev.has(key)) { + next.add(key) + } + }) + return next + }) + }, [canvasItems]) + + const persistLayout = useCallback( + async (nextState: LayoutStateItem[]) => { + if (!canEdit || !viewerId || !dashboard) return + const packed = packLayout(nextState, GRID_COLUMNS) + try { + await updateLayoutMutation({ + tenantId, + actorId: viewerId as Id<"users">, + dashboardId: dashboard.id, + layout: packed, + }) + } catch (error) { + console.error(error) + toast.error("Não foi possível salvar o layout.") + } + }, + [canEdit, viewerId, dashboard, updateLayoutMutation, tenantId], + ) + + 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)) + if (options?.commit) { + persistLayout(next) + } + return next + }) + }, + [persistLayout], + ) + + const handleLayoutReorder = useCallback( + (order: string[]) => { + setLayoutState((prev) => { + const map = new Map(prev.map((item) => [item.i, item])) + const next = order.map((key) => map.get(key)).filter(Boolean) as LayoutStateItem[] + persistLayout(next) + return next + }) + }, + [persistLayout], + ) + + const debouncedFilters = useDebounce(filters, 400) + + useEffect(() => { + if (!canEdit || !viewerId || !dashboard || filtersHydratingRef.current) return + const serverFilters = normalizeFilters(dashboard.filters) + if (filtersEqual(serverFilters, debouncedFilters)) return + updateFiltersMutation({ + tenantId, + actorId: viewerId as Id<"users">, + dashboardId: dashboard.id, + filters: debouncedFilters, + }).catch((error) => { + console.error(error) + toast.error("Não foi possível salvar os filtros.") + }) + }, [debouncedFilters, canEdit, viewerId, dashboard, updateFiltersMutation, tenantId]) + + const handleAddWidget = async (type: string) => { + if (!canEdit || !viewerId || !dashboard) return + setIsAddingWidget(true) + try { + await addWidgetMutation({ + tenantId, + actorId: viewerId as Id<"users">, + dashboardId: dashboard.id, + type, + }) + toast.success("Widget adicionado ao painel!") + } catch (error) { + console.error(error) + toast.error("Não foi possível adicionar o widget.") + } finally { + setIsAddingWidget(false) + } + } + + const handleDuplicateWidget = async (widget: DashboardWidgetRecord) => { + if (!canEdit || !viewerId || !dashboard) return + try { + await duplicateWidgetMutation({ + tenantId, + actorId: viewerId as Id<"users">, + widgetId: widget.id, + }) + toast.success("Widget duplicado com sucesso!") + } catch (error) { + console.error(error) + toast.error("Não foi possível duplicar o widget.") + } + } + + const handleRemoveWidget = async (widget: DashboardWidgetRecord) => { + if (!canEdit || !viewerId || !dashboard) return + try { + await removeWidgetMutation({ + tenantId, + actorId: viewerId as Id<"users">, + widgetId: widget.id, + }) + setLayoutState((prev) => prev.filter((item) => item.i !== widget.widgetKey)) + toast.success("Widget removido do painel.") + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o widget.") + } + } + + 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 = { + ...currentConfig, + type: values.type, + title: values.title, + dataSource: { + ...(currentConfig.dataSource as Record | undefined), + 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, + }, + } + try { + await updateWidgetMutation({ + tenantId, + actorId: viewerId as Id<"users">, + widgetId: configTarget.id, + type: values.type, + title: values.title, + config: nextConfig, + }) + toast.success("Widget atualizado.") + setIsConfigOpen(false) + setConfigTarget(null) + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar o widget.") + } + } + + const handleUpdateMetadata = async (payload: { name?: string; description?: string | null }) => { + if (!dashboard || !viewerId || !canEdit) return + try { + await updateMetadataMutation({ + tenantId, + actorId: viewerId as Id<"users">, + dashboardId: dashboard.id, + name: payload.name, + description: payload.description ?? undefined, + }) + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar os metadados.") + } + } + + const handleExport = async (format: "pdf" | "png") => { + if (!dashboard) return + setIsExporting(true) + try { + const response = await fetch("/api/export/pdf", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: `${window.location.origin}/dashboards/${dashboard.id}/print`, + format, + width: 1920, + height: 1080, + waitForSelector: dashboard.readySelector ?? "[data-dashboard-ready='true']", + }), + }) + if (!response.ok) { + throw new Error("Export request failed") + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${dashboard.name ?? "dashboard"}.${format === "pdf" ? "pdf" : "png"}` + link.click() + URL.revokeObjectURL(url) + toast.success("Exportação gerada com sucesso!") + } catch (error) { + console.error(error) + toast.error("Não foi possível exportar o dashboard.") + } finally { + setIsExporting(false) + } + } + + const handleFiltersChange = (next: DashboardFilters) => { + setFilters(next) + } + + if (!detail || !dashboard) { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+
+ ) + } + + const visibleCount = canvasItems.length + + return ( +
+ + + + + {enforceTv && sections.length > 0 ? ( + + ) : null} + + {visibleCount === 0 ? ( + + + + + Comece adicionando widgets + + + KPIs, gráficos ou tabelas podem ser combinados para contar histórias relevantes para a operação. + + + + {canEdit ? ( + + ) : ( +

Nenhum widget visível para esta seção.

+ )} +
+
+ ) : null} + + + + { + setIsConfigOpen(open) + if (!open) setConfigTarget(null) + }} + widget={configTarget} + onSubmit={handleUpdateWidgetConfig} + /> + + { + if (!open) setDataTarget(null) + }} + widget={dataTarget} + filters={filters} + /> +
+ ) +} + +function BuilderHeader({ + dashboard, + canEdit, + onAddWidget, + onExport, + isAddingWidget, + isExporting, + onMetadataChange, + enforceTv, + activeSectionIndex, + totalSections, +}: { + dashboard: DashboardRecord + canEdit: boolean + onAddWidget: (type: string) => void + onExport: (format: "pdf" | "png") => Promise + isAddingWidget: boolean + isExporting: boolean + onMetadataChange: (payload: { name?: string; description?: string | null }) => void + enforceTv: boolean + activeSectionIndex: number + totalSections: number +}) { + const [name, setName] = useState(dashboard.name) + const [description, setDescription] = useState(dashboard.description ?? "") + const initialNameRef = useRef(dashboard.name) + const initialDescriptionRef = useRef(dashboard.description ?? "") + + useEffect(() => { + setName(dashboard.name) + setDescription(dashboard.description ?? "") + initialNameRef.current = dashboard.name + initialDescriptionRef.current = dashboard.description ?? "" + }, [dashboard.name, dashboard.description]) + + const handleBlurName = () => { + if (!canEdit) return + if (name.trim() && name.trim() !== initialNameRef.current.trim()) { + onMetadataChange({ name: name.trim() }) + initialNameRef.current = name.trim() + } else { + setName(initialNameRef.current) + } + } + + const handleBlurDescription = () => { + if (!canEdit) return + if (description.trim() !== initialDescriptionRef.current.trim()) { + onMetadataChange({ description: description.trim() || null }) + initialDescriptionRef.current = description.trim() + } + } + + 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}

+ ) : null} + + )} +
+ + {dashboard.aspectRatio ?? "16:9"} + + + Tema {dashboard.theme ?? "system"} + + {enforceTv && totalSections > 0 ? ( + + Slide {activeSectionIndex + 1} de {totalSections} + + ) : null} +
+
+
+ {canEdit ? ( + + ) : null} + + + + + + Exportar dashboard + + onExport("pdf")}> + Exportar como PDF + + onExport("png")}> + Exportar como PNG + + + +
+
+ ) +} + +function WidgetPicker({ onSelect, disabled }: { onSelect: (type: string) => void; disabled?: boolean }) { + return ( + + + + + + Componentes disponíveis + + {WIDGET_LIBRARY.map((item) => ( + onSelect(item.type)} + className="flex items-start gap-3" + > + +
+ {item.title} + {item.description} +
+
+ ))} +
+
+ ) +} + +function DashboardFilterBar({ + filters, + onChange, +}: { + filters: DashboardFilters + onChange: (filters: DashboardFilters) => void +}) { + const { session, convexUserId, isStaff } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const viewerId = convexUserId as Id<"users"> | null + + const companies = useQuery( + api.companies.list, + isStaff && viewerId + ? ({ + tenantId, + viewerId, + } as const) + : "skip", + ) as Array<{ id: Id<"companies">; name: string }> | undefined + + const companyOptions: SearchableComboboxOption[] = useMemo(() => { + const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }] + if (!companies || companies.length === 0) return base + const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) + return [ + base[0], + ...sorted.map((company) => ({ + value: company.id, + label: company.name, + })), + ] + }, [companies]) + + return ( + + +
+ + Período + + +
+
+ + Empresa + + + onChange({ ...filters, companyId: value === "all" ? null : value }) + } + options={companyOptions} + placeholder="Selecionar empresa" + className="min-w-[220px]" + /> +
+
+
+ ) +} + +function BuilderWidgetCard({ + widget, + filters, + mode, + editable, + onEdit, + onDuplicate, + onRemove, + onViewData, + onReadyChange, +}: { + widget: DashboardWidgetRecord + filters: DashboardFilters + mode: "edit" | "view" | "tv" | "print" + editable: boolean + onEdit: () => void + onDuplicate: () => void + onRemove: () => void + onViewData: () => void + onReadyChange: (ready: boolean) => void +}) { + return ( +
+ {editable && mode !== "tv" && mode !== "print" ? ( +
+ + + + +
+ ) : null} + +
+ ) +} + +function WidgetConfigDialog({ + open, + onOpenChange, + widget, + onSubmit, +}: { + open: boolean + onOpenChange: (open: boolean) => void + widget: DashboardWidgetRecord | null + onSubmit: (values: WidgetConfigFormValues) => Promise +}) { + const form = useForm({ + resolver: zodResolver(widgetConfigSchema), + defaultValues: { + title: widget?.title ?? widget?.config?.title ?? "", + type: widget?.type ?? "kpi", + metricKey: widget?.config?.dataSource?.metricKey ?? "", + stacked: Boolean((widget?.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), + legend: Boolean((widget?.config as { options?: { legend?: boolean } })?.options?.legend ?? true), + rangeOverride: typeof (widget?.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range === "string" + ? ((widget?.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range as string) + : "", + showTooltip: Boolean((widget?.config as { options?: { tooltip?: boolean } })?.options?.tooltip ?? true), + }, + }) + + useEffect(() => { + if (!widget) return + form.reset({ + title: widget.title ?? (widget.config as { title?: string })?.title ?? "", + type: widget.type, + metricKey: (widget.config as { dataSource?: { metricKey?: string } })?.dataSource?.metricKey ?? "", + stacked: Boolean((widget.config as { encoding?: { stacked?: boolean } })?.encoding?.stacked ?? false), + legend: Boolean((widget.config as { options?: { legend?: boolean } })?.options?.legend ?? true), + rangeOverride: + typeof (widget.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range === "string" + ? ((widget.config as { dataSource?: { params?: Record } })?.dataSource?.params?.range as string) + : "", + showTooltip: Boolean((widget.config as { options?: { tooltip?: boolean } })?.options?.tooltip ?? true), + }) + }, [widget, form]) + + const handleSubmit = form.handleSubmit(async (values) => { + await onSubmit(values) + form.reset() + }) + + return ( + + + + Configurar widget + Personalize a fonte de dados, título e opções de exibição. + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ form.setValue("legend", checked)} + /> + form.setValue("stacked", checked)} + /> + form.setValue("showTooltip", checked)} + /> +
+
+ + +
+
+ + + +
+
+
+ ) +} + +function SwitchField({ + label, + description, + checked, + onCheckedChange, +}: { + label: string + description?: string + checked: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( +
+
+

{label}

+ {description ?

{description}

: null} +
+ onCheckedChange(value === true)} /> +
+ ) +} + +function WidgetDataSheet({ + open, + onOpenChange, + widget, + filters, +}: { + open: boolean + onOpenChange: (open: boolean) => void + widget: DashboardWidgetRecord | null + filters: DashboardFilters +}) { + const config = getWidgetConfigForWidget(widget) + const metric = useMetricData({ + metricKey: config?.dataSource?.metricKey, + params: mergeFilterParams(config?.dataSource?.params, filters), + enabled: open && Boolean(config?.dataSource?.metricKey), + }) + + const rows = Array.isArray(metric.data) ? (metric.data as Array>) : [] + const columns = rows.length > 0 ? Object.keys(rows[0]!) : [] + + return ( + + + + Dados do widget {widget?.title ?? widget?.widgetKey} + + {metric.isLoading ? ( +
+ {Array.from({ length: 10 }).map((_, index) => ( + + ))} +
+ ) : rows.length === 0 ? ( +

Sem dados disponíveis para os filtros atuais.

+ ) : ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.slice(0, 200).map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ {renderTableCellValue(row[column])} +
+
+ )} +
+
+ ) +} + +function renderTableCellValue(value: unknown) { + if (typeof value === "number") { + return numberFormatter.format(value) + } + if (typeof value === "string") { + if (/^\d{4}-\d{2}-\d{2}T/.test(value) || /^\d+$/.test(value)) { + const date = new Date(value) + if (!Number.isNaN(date.getTime())) { + return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR }) + } + } + return value + } + if (value === null || value === undefined) { + return "—" + } + if (typeof value === "boolean") { + return value ? "Sim" : "Não" + } + if (Array.isArray(value)) { + return value.join(", ") + } + if (typeof value === "object") { + try { + return JSON.stringify(value) + } catch { + return "—" + } + } + return String(value) +} + +function TvSectionIndicator({ + sections, + activeIndex, + onChange, +}: { + sections: DashboardSection[] + activeIndex: number + onChange: (index: number) => void +}) { + if (sections.length === 0) return null + return ( +
+
+ + + Seção {activeIndex + 1} de {sections.length}: {sections[activeIndex]?.title ?? "Sem título"} + +
+
+ {sections.map((section, index) => ( +
+
+ ) +} diff --git a/src/components/dashboards/dashboard-list.tsx b/src/components/dashboards/dashboard-list.tsx new file mode 100644 index 0000000..e1d0258 --- /dev/null +++ b/src/components/dashboards/dashboard-list.tsx @@ -0,0 +1,273 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useMutation, useQuery } from "convex/react" +import { formatDistanceToNow } from "date-fns" +import { ptBR } from "date-fns/locale" +import { Plus, Sparkles } from "lucide-react" +import type { Id } from "@/convex/_generated/dataModel" + +import { api } from "@/convex/_generated/api" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" + +type DashboardSummary = { + id: Id<"dashboards"> + tenantId: string + name: string + description: string | null + aspectRatio: string + theme: string + filters: Record + layout: Array<{ i: string; x: number; y: number; w: number; h: number }> + sections: Array<{ + id: string + title?: string + description?: string + widgetKeys: string[] + durationSeconds?: number + }> + tvIntervalSeconds: number + readySelector: string | null + createdBy: Id<"users"> + updatedBy: Id<"users"> | null + createdAt: number + updatedAt: number + isArchived: boolean + widgetsCount: number +} + +type CreateDashboardDialogProps = { + onCreate: (name: string, description: string | null) => Promise + isLoading: boolean +} + +function CreateDashboardDialog({ onCreate, isLoading }: CreateDashboardDialogProps) { + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [description, setDescription] = useState("") + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + const trimmedName = name.trim() + if (!trimmedName) { + toast.error("Informe um nome para o dashboard.") + return + } + await onCreate(trimmedName, description.trim() ? description.trim() : null) + setOpen(false) + setName("") + setDescription("") + } + + return ( + <> + + + + + Criar dashboard + +
+
+ + setName(event.target.value)} + placeholder="Ex.: Operações - Visão Geral" + required + /> +
+
+ + setDescription(event.target.value)} + placeholder="Contextualize para a equipe" + /> +
+ + + + +
+
+
+ + ) +} + +export function DashboardListView() { + const router = useRouter() + const { session, convexUserId, isStaff } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const createDashboard = useMutation(api.dashboards.create) + const [isCreating, setIsCreating] = useState(false) + + const dashboards = useQuery( + api.dashboards.list, + isStaff && convexUserId + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + } as const) + : "skip" + ) as DashboardSummary[] | undefined + + async function handleCreate(name: string, description: string | null) { + if (!convexUserId) return + setIsCreating(true) + try { + const result = await createDashboard({ + tenantId, + actorId: convexUserId as Id<"users">, + name, + description: description ?? undefined, + }) + toast.success("Dashboard criado com sucesso!") + router.push(`/dashboards/${result.id}`) + } catch (error) { + console.error(error) + toast.error("Não foi possível criar o dashboard.") + } finally { + setIsCreating(false) + } + } + + if (!isStaff) { + return ( + + + Acesso restrito + Somente a equipe interna pode visualizar e montar dashboards. + + + ) + } + + if (!dashboards) { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + + + + + + + + ))} +
+ ) + } + + const activeDashboards = dashboards.filter((dashboard) => !dashboard.isArchived) + + return ( +
+
+
+

Dashboards personalizados

+

+ Combine KPIs, gráficos, tabelas e texto em painéis dinâmicos com filtros globais. +

+
+ +
+ + {activeDashboards.length === 0 ? ( + + +
+ + + Crie o seu primeiro dashboard + + + Monte painéis por cliente, fila ou operação e compartilhe com a equipe. + +
+ +
+ +

• Arraste e redimensione widgets livremente no canvas.

+

• Salve filtros padrão por dashboard e gere exportações em PDF/PNG.

+

• Ative o modo TV ou compartilhe via link público com token rotativo.

+
+
+ ) : ( +
+ {activeDashboards.map((dashboard) => { + const updatedAt = formatDistanceToNow(dashboard.updatedAt, { + addSuffix: true, + locale: ptBR, + }) + return ( + + + + {dashboard.name} + + {dashboard.widgetsCount} widget{dashboard.widgetsCount === 1 ? "" : "s"} + + + {dashboard.description ? ( + {dashboard.description} + ) : null} + + +

+ Última atualização{" "} + {updatedAt} +

+

Formato {dashboard.aspectRatio} · Tema {dashboard.theme}

+
+ + + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/src/components/dashboards/report-canvas.tsx b/src/components/dashboards/report-canvas.tsx new file mode 100644 index 0000000..fe1f76f --- /dev/null +++ b/src/components/dashboards/report-canvas.tsx @@ -0,0 +1,275 @@ +"use client" + +import type { ReactNode } from "react" +import { useEffect, useMemo, useRef, useState } from "react" +import { + DndContext, + DragEndEvent, + MouseSensor, + PointerSensor, + TouchSensor, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core" +import { CSS } from "@dnd-kit/utilities" +import { + SortableContext, + arrayMove, + useSortable, +} from "@dnd-kit/sortable" +import { cn } from "@/lib/utils" + +type PackedLayoutItem = { + i: string + x: number + y: number + w: number + h: number + minW?: number + minH?: number + static?: boolean +} + +type CanvasItem = { + key: string + element: ReactNode + layout: PackedLayoutItem + minW?: number + minH?: number + static?: boolean +} + +type ReportCanvasProps = { + items: CanvasItem[] + editable?: boolean + columns?: number + rowHeight?: number + gap?: number + ready?: boolean + onReorder?: (nextOrder: string[]) => void + onResize?: (key: string, size: { w: number; h: number }, options?: { commit?: boolean }) => void +} + +type InternalResizeState = { + key: string + originX: number + originY: number + initialWidth: number + initialHeight: number + minW: number + minH: number +} + +const DEFAULT_COLUMNS = 12 +const DEFAULT_ROW_HEIGHT = 80 +const MAX_ROWS = 24 + +export function ReportCanvas({ + items, + editable = false, + columns = DEFAULT_COLUMNS, + rowHeight = DEFAULT_ROW_HEIGHT, + gap = 16, + ready = false, + onReorder, + onResize, +}: ReportCanvasProps) { + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + const [resizing, setResizing] = useState(null) + const lastResizeSizeRef = useRef<{ w: number; h: number } | null>(null) + + useEffect(() => { + const element = containerRef.current + if (!element) return + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentRect) { + setContainerWidth(entry.contentRect.width) + } + } + }) + observer.observe(element) + return () => { + observer.disconnect() + } + }, []) + + const columnWidth = containerWidth > 0 ? containerWidth / columns : 0 + + useEffect(() => { + if (!resizing) return + function handlePointerMove(event: PointerEvent) { + if (!resizing) return + if (!columnWidth) return + const deltaX = event.clientX - resizing.originX + const deltaY = event.clientY - resizing.originY + const deltaCols = Math.round(deltaX / columnWidth) + const deltaRows = Math.round(deltaY / rowHeight) + let nextW = resizing.initialWidth + deltaCols + let nextH = resizing.initialHeight + deltaRows + nextW = Math.min(columns, Math.max(resizing.minW, nextW)) + nextH = Math.min(MAX_ROWS, Math.max(resizing.minH, nextH)) + const previous = lastResizeSizeRef.current + if (!previous || previous.w !== nextW || previous.h !== nextH) { + lastResizeSizeRef.current = { w: nextW, h: nextH } + onResize?.(resizing.key, { w: nextW, h: nextH }) + } + } + function handlePointerUp() { + if (resizing) { + const finalSize = lastResizeSizeRef.current ?? { w: resizing.initialWidth, h: resizing.initialHeight } + onResize?.(resizing.key, finalSize, { commit: true }) + } + setResizing(null) + lastResizeSizeRef.current = null + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + } + window.addEventListener("pointermove", handlePointerMove) + window.addEventListener("pointerup", handlePointerUp) + return () => { + window.removeEventListener("pointermove", handlePointerMove) + window.removeEventListener("pointerup", handlePointerUp) + } + }, [columnWidth, onResize, resizing, rowHeight, columns]) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 120, tolerance: 8 } }), + ) + + const sortableItems = useMemo(() => items.map((item) => item.key), [items]) + + const handleDragEnd = (event: DragEndEvent) => { + if (!editable) return + const { active, over } = event + if (!active?.id || !over?.id) return + if (active.id === over.id) return + const currentOrder = sortableItems + const oldIndex = currentOrder.indexOf(String(active.id)) + const newIndex = currentOrder.indexOf(String(over.id)) + if (oldIndex === -1 || newIndex === -1) return + const nextOrder = arrayMove(currentOrder, oldIndex, newIndex) + onReorder?.(nextOrder) + } + + const handleResizePointerDown = ( + event: React.PointerEvent, + item: CanvasItem, + ) => { + if (!editable || item.static) return + event.preventDefault() + event.stopPropagation() + const layout = item.layout + const minW = item.minW ?? layout.minW ?? 2 + const minH = item.minH ?? layout.minH ?? 2 + setResizing({ + key: item.key, + originX: event.clientX, + originY: event.clientY, + initialWidth: layout.w, + initialHeight: layout.h, + minW, + minH, + }) + } + + const content = ( +
+ {items.map((item) => { + const layout = item.layout + const gridStyle: React.CSSProperties = { + gridColumn: `${layout.x + 1} / span ${layout.w}`, + gridRow: `${layout.y + 1} / span ${layout.h}`, + } + return ( + +
+ {item.element} + {editable && !item.static ? ( +
handleResizePointerDown(event, item)} + /> + ) : null} +
+ + ) + })} +
+ ) + + if (!editable) { + return content + } + + return ( + + {content} + + ) +} + +type SortableTileProps = { + id: string + children: ReactNode + style?: React.CSSProperties + className?: string + disabled?: boolean +} + +function SortableTile({ id, children, style, className, disabled }: SortableTileProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled, + }) + const combinedStyle: React.CSSProperties = { + ...style, + transform: transform ? CSS.Transform.toString(transform) : undefined, + transition, + zIndex: isDragging ? 5 : undefined, + } + + return ( +
+ {children} +
+ ) +} diff --git a/src/components/dashboards/widget-renderer.tsx b/src/components/dashboards/widget-renderer.tsx new file mode 100644 index 0000000..70b831a --- /dev/null +++ b/src/components/dashboards/widget-renderer.tsx @@ -0,0 +1,865 @@ +"use client" + +import { useEffect, useMemo } from "react" +import { useQuery } from "convex/react" +import { format } from "date-fns" +import { ptBR } from "date-fns/locale" +import type { Id } from "@/convex/_generated/dataModel" +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Label, + Line, + LineChart, + Pie, + PieChart, + PolarAngleAxis, + PolarGrid, + PolarRadiusAxis, + Radar, + RadarChart, + RadialBar, + RadialBarChart, + Tooltip as RechartsTooltip, + XAxis, + YAxis, +} from "recharts" +import sanitizeHtml from "sanitize-html" + +import { api } from "@/convex/_generated/api" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" +import { cn } from "@/lib/utils" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +const numberFormatter = new Intl.NumberFormat("pt-BR", { 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)"] + +export type DashboardFilters = { + range?: "7d" | "30d" | "90d" | "custom" + from?: string | null + to?: string | null + companyId?: string | null + queueId?: string | null +} + +export type WidgetConfig = { + type?: string + title?: string + description?: string + dataSource?: { + metricKey?: string + params?: Record + } + encoding?: { + x?: string + y?: Array<{ field: string; label?: string }> + category?: string + value?: string + stacked?: boolean + angle?: string + radius?: string + } + options?: Record + columns?: Array<{ field: string; label: string }> + content?: string +} + +export type DashboardWidgetRecord = { + id: Id<"dashboardWidgets"> + widgetKey: string + type: string + title: string | null + config: WidgetConfig | Record +} + +type WidgetRendererProps = { + widget: DashboardWidgetRecord + filters: DashboardFilters + mode?: "edit" | "view" | "tv" | "print" + onReadyChange?: (ready: boolean) => void +} + +type UseMetricDataArgs = { + metricKey?: string | null + params?: Record + enabled?: boolean +} + +type MetricResult = { + data: unknown + meta: { kind: string; [key: string]: unknown } | null + isLoading: boolean + isError: boolean +} + +function normalizeParams(raw?: Record) { + if (!raw) return undefined + const next: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (value === undefined) continue + if (value === null) continue + if (typeof value === "string" && value.trim().length === 0) continue + if (Array.isArray(value)) { + const filtered = value.filter((entry) => entry !== undefined && entry !== null) + if (filtered.length === 0) continue + next[key] = filtered + continue + } + next[key] = value + } + return Object.keys(next).length > 0 ? next : undefined +} + +function mergeFilterParams(base: Record | undefined, filters: DashboardFilters) { + if (!filters) return base + const merged: Record = { ...(base ?? {}) } + if (filters.range) { + if (filters.range === "custom") { + if (filters.from) merged.from = filters.from + if (filters.to) merged.to = filters.to + delete merged.range + } else { + merged.range = filters.range + delete merged.from + delete merged.to + } + } + if (filters.companyId && filters.companyId !== "all") { + merged.companyId = filters.companyId + } else if (filters.companyId === "all") { + delete merged.companyId + } + if (filters.queueId) { + merged.queueId = filters.queueId + } + return merged +} + +function useMetricData({ metricKey, params, enabled = true }: UseMetricDataArgs): MetricResult { + const { session, convexUserId, isStaff } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + const shouldFetch = Boolean(enabled && metricKey && isStaff && convexUserId) + const normalized = useMemo(() => normalizeParams(params), [params]) + const result = useQuery( + api.metrics.run, + shouldFetch + ? ({ + tenantId, + viewerId: convexUserId as Id<"users">, + metricKey: metricKey as string, + params: normalized, + } as const) + : "skip", + ) as { data: unknown; meta: { kind: string; [key: string]: unknown } } | undefined + const isLoading = shouldFetch && result === undefined + const meta = result?.meta ?? null + const isError = Boolean(meta && meta.kind === "error") + return { + data: result?.data, + meta, + isLoading, + isError, + } +} + +function normalizeWidgetConfig(widget: DashboardWidgetRecord): WidgetConfig { + const raw = widget.config + if (raw && typeof raw === "object") { + return { type: widget.type, ...raw } as WidgetConfig + } + return { type: widget.type, title: widget.title ?? undefined } +} + +function needsMetricData(type: string) { + return type !== "text" +} + +function formatDateLabel(value: unknown) { + if (typeof value === "number") { + return format(new Date(value), "dd MMM", { locale: ptBR }) + } + if (typeof value === "string") { + const trimmed = value.trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + const date = new Date(trimmed + "T00:00:00Z") + if (!Number.isNaN(date.getTime())) { + return format(date, "dd MMM", { locale: ptBR }) + } + } + return trimmed + } + return String(value ?? "") +} + +function parseNumeric(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return null +} + +export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }: WidgetRendererProps) { + const config = normalizeWidgetConfig(widget) + const widgetType = (config.type ?? widget.type ?? "text").toLowerCase() + const title = config.title ?? widget.title ?? "Widget" + const description = config.description + const mergedParams = useMemo(() => mergeFilterParams(config.dataSource?.params, filters), [config.dataSource?.params, filters]) + const metric = useMetricData({ + metricKey: config.dataSource?.metricKey, + params: mergedParams, + enabled: needsMetricData(widgetType), + }) + const trendMetric = useMetricData({ + metricKey: typeof config.options?.trend === "string" ? (config.options.trend as string) : undefined, + params: mergedParams, + enabled: widgetType === "kpi" && Boolean(config.options?.trend), + }) + + useEffect(() => { + if (!needsMetricData(widgetType)) { + onReadyChange?.(true) + return + } + if (widgetType === "kpi" && config.options?.trend) { + onReadyChange?.(!metric.isLoading && !trendMetric.isLoading && !metric.isError && !trendMetric.isError) + } else { + onReadyChange?.(!metric.isLoading && !metric.isError) + } + }, [ + widgetType, + config.options?.trend, + metric.isLoading, + metric.isError, + trendMetric.isLoading, + trendMetric.isError, + onReadyChange, + ]) + + const isLoading = metric.isLoading || (widgetType === "kpi" && Boolean(config.options?.trend) && trendMetric.isLoading) + const isError = metric.isError + const resolvedTitle = mode === "tv" ? title.toUpperCase() : title + + if (isError) { + return ( + + + {resolvedTitle} + {description ? {description} : null} + + + Não foi possível carregar esta métrica. Verifique a configuração. + + + ) + } + + switch (widgetType) { + case "kpi": + return renderKpi({ + title: resolvedTitle, + description, + metric, + trend: trendMetric, + mode, + }) + case "bar": + return renderBarChart({ + title: resolvedTitle, + description, + metric, + config, + }) + case "line": + return renderLineChart({ + title: resolvedTitle, + description, + metric, + config, + }) + case "area": + return renderAreaChart({ + title: resolvedTitle, + description, + metric, + config, + }) + case "pie": + return renderPieChart({ + title: resolvedTitle, + description, + metric, + config, + }) + case "radar": + return renderRadarChart({ + title: resolvedTitle, + description, + metric, + config, + }) + case "gauge": + return renderGauge({ + title: resolvedTitle, + description, + metric, + config, + }) + case "table": + return renderTable({ + title: resolvedTitle, + description, + metric, + config, + isLoading, + }) + case "text": + default: + return renderText({ + title: resolvedTitle, + description, + content: config.content ?? "", + }) + } +} + +type WidgetCardProps = { + title: string + description?: string + children: React.ReactNode + isLoading?: boolean +} + +function WidgetCard({ title, description, children, isLoading }: WidgetCardProps) { + return ( + + + {title} + {description ? {description} : null} + + + {isLoading ? : children} + + + ) +} + +function renderKpi({ + title, + description, + metric, + trend, + mode, +}: { + title: string + description?: string + metric: MetricResult + trend: MetricResult + mode: "edit" | "view" | "tv" | "print" +}) { + const data = metric.data as { value?: number; atRisk?: number } | null + const value = parseNumeric(data?.value) ?? 0 + const atRisk = parseNumeric(data?.atRisk) ?? 0 + const trendValue = parseNumeric((trend.data as { value?: number } | null)?.value) + const delta = trendValue !== null ? value - trendValue : null + const isTv = mode === "tv" + return ( + + + + {description ?? "Indicador chave"} + + {title} + + +
{numberFormatter.format(value)}
+
+ 0 ? "destructive" : "outline"} className="rounded-full px-3 py-1 text-xs"> + {atRisk > 0 ? `${numberFormatter.format(atRisk)} em risco` : "Todos no prazo"} + + {delta !== null ? ( + = 0 ? "text-emerald-600" : "text-destructive")}> + {delta >= 0 ? "+" : ""} + {numberFormatter.format(delta)} vs período anterior + + ) : null} +
+
+
+ ) +} + +function renderBarChart({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const xKey = config.encoding?.x ?? "date" + const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : [] + const chartData = Array.isArray(metric.data) ? (metric.data as Array>) : [] + const chartConfig = series.reduce>((acc, serie, index) => { + acc[serie.field] = { + label: serie.label ?? serie.field, + color: CHART_COLORS[index % CHART_COLORS.length], + } + return acc + }, {}) + + return ( + + {chartData.length === 0 ? ( + + ) : ( + + + + + + } /> + {series.map((serie, index) => ( + + ))} + + + )} + + ) +} + +function renderLineChart({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const xKey = config.encoding?.x ?? "date" + const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : [] + const chartData = Array.isArray(metric.data) ? (metric.data as Array>) : [] + const chartConfig = series.reduce>((acc, serie, index) => { + acc[serie.field] = { + label: serie.label ?? serie.field, + color: CHART_COLORS[index % CHART_COLORS.length], + } + return acc + }, {}) + + return ( + + {chartData.length === 0 ? ( + + ) : ( + + + + + + } /> + } /> + {series.map((serie, index) => ( + + ))} + + + )} + + ) +} + +function renderAreaChart({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const xKey = config.encoding?.x ?? "date" + const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : [] + const chartData = Array.isArray(metric.data) ? (metric.data as Array>) : [] + const stacked = Boolean(config.encoding?.stacked) + const chartConfig = series.reduce>((acc, serie, index) => { + acc[serie.field] = { + label: serie.label ?? serie.field, + color: CHART_COLORS[index % CHART_COLORS.length], + } + return acc + }, {}) + + return ( + + {chartData.length === 0 ? ( + + ) : ( + + + + {series.map((serie, index) => ( + + + + + ))} + + + + + } /> + } /> + {series.map((serie, index) => ( + + ))} + + + )} + + ) +} + +function renderPieChart({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const categoryKey = config.encoding?.category ?? "name" + const valueKey = config.encoding?.value ?? "value" + const chartData = Array.isArray(metric.data) ? (metric.data as Array>) : [] + return ( + + {chartData.length === 0 ? ( + + ) : ( + >((acc, item, index) => { + const key = String(item[categoryKey] ?? index) + acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] } + return acc + }, {}) as ChartConfig} + className="mx-auto aspect-square max-h-[240px]" + > + + } /> + + {chartData.map((entry, index) => ( + + ))} + + + + )} + + ) +} + +function renderRadarChart({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const angleKey = config.encoding?.angle ?? "label" + const radiusKey = config.encoding?.radius ?? "value" + const chartData = Array.isArray(metric.data) ? (metric.data as Array>) : [] + return ( + + {chartData.length === 0 ? ( + + ) : ( + + + + + + } /> + + + + )} + + ) +} + +function renderGauge({ + title, + description, + metric, + config, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig +}) { + const raw = metric.data as { value?: number; total?: number; resolved?: number } | null + const value = parseNumeric(raw?.value) ?? 0 + const display = Math.max(0, Math.min(1, value)) + return ( + + + + + percentFormatter.format(Number(val ?? 0))} />} + /> + + + + ) +} + +function renderTable({ + title, + description, + metric, + config, + isLoading, +}: { + title: string + description?: string + metric: MetricResult + config: WidgetConfig + isLoading: boolean +}) { + const columns = Array.isArray(config.columns) && config.columns.length > 0 + ? config.columns + : [ + { field: "subject", label: "Assunto" }, + { field: "status", label: "Status" }, + { field: "updatedAt", label: "Atualizado em" }, + ] + const rows = Array.isArray(metric.data) ? (metric.data as Array>) : [] + return ( + + {rows.length === 0 ? ( + + ) : ( +
+ + + + {columns.map((column) => ( + {column.label} + ))} + + + + {rows.map((row, index) => ( + + {columns.map((column) => ( + + {renderTableCellValue(row[column.field as keyof typeof row])} + + ))} + + ))} + +
+
+ )} +
+ ) +} + +function renderTableCellValue(value: unknown) { + if (typeof value === "number") { + return numberFormatter.format(value) + } + if (typeof value === "string") { + if (/^\d{4}-\d{2}-\d{2}T/.test(value) || /^\d+$/.test(value)) { + const date = new Date(value) + if (!Number.isNaN(date.getTime())) { + return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR }) + } + } + return value + } + if (value === null || value === undefined) { + return "—" + } + if (typeof value === "boolean") { + return value ? "Sim" : "Não" + } + if (Array.isArray(value)) { + return value.join(", ") + } + if (typeof value === "object") { + try { + return JSON.stringify(value) + } catch { + return "—" + } + } + return String(value) +} + +function renderText({ + title, + description, + content, +}: { + title: string + description?: string + content: string +}) { + const sanitized = sanitizeHtml(content, { + allowedTags: ["b", "i", "strong", "em", "u", "p", "br", "ul", "ol", "li", "span"], + allowedAttributes: { span: ["style"] }, + }) + return ( + + + {title} + {description ? {description} : null} + + +
Adicione conteúdo informativo para contextualizar os dados.

" }} + /> + + + ) +} + +function EmptyState() { + return ( +
+ Sem dados para os filtros selecionados. +
+ ) +} + +export { useMetricData }