938 lines
28 KiB
TypeScript
938 lines
28 KiB
TypeScript
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",
|
|
"queue-summary",
|
|
"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 normalizeQueueSummaryConfig(config: unknown) {
|
|
return {
|
|
type: "queue-summary",
|
|
title: "Resumo das filas",
|
|
dataSource: { metricKey: "queues.summary_cards" },
|
|
...(typeof config === "object" && config ? (config as Record<string, unknown>) : {}),
|
|
}
|
|
}
|
|
|
|
function queueSummaryLayout(widgetKey: string) {
|
|
return {
|
|
i: widgetKey,
|
|
x: 0,
|
|
y: 0,
|
|
w: 12,
|
|
h: 6,
|
|
minW: 8,
|
|
minH: 4,
|
|
static: false,
|
|
}
|
|
}
|
|
|
|
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 "queue-summary":
|
|
return {
|
|
type: "queue-summary",
|
|
title: "Resumo por fila",
|
|
dataSource: { metricKey: "queues.summary_cards" },
|
|
}
|
|
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<Doc<"dashboards">> = {}
|
|
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<string, Doc<"dashboardWidgets">>()
|
|
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<Doc<"dashboardWidgets">> = {}
|
|
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 ensureQueueSummaryWidget = 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 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 now = Date.now()
|
|
let queueWidget = widgets.find((widget) => widget.type === "queue-summary")
|
|
let changed = false
|
|
|
|
if (!queueWidget) {
|
|
// Shift existing widgets to make room at the top.
|
|
await Promise.all(
|
|
widgets.map((widget) =>
|
|
ctx.db.patch(widget._id, {
|
|
order: widget.order + 1,
|
|
updatedAt: now,
|
|
updatedBy: actorId,
|
|
}),
|
|
),
|
|
)
|
|
const widgetKey = generateWidgetKey(dashboardId)
|
|
const config = normalizeQueueSummaryConfig(undefined)
|
|
const layoutWithKey = queueSummaryLayout(widgetKey)
|
|
const widgetLayout = { ...layoutWithKey }
|
|
delete (widgetLayout as { i?: string }).i
|
|
const widgetId = await ctx.db.insert("dashboardWidgets", {
|
|
tenantId,
|
|
dashboardId,
|
|
widgetKey,
|
|
title: config.title,
|
|
type: "queue-summary",
|
|
config,
|
|
layout: widgetLayout,
|
|
order: 0,
|
|
createdBy: actorId,
|
|
updatedBy: actorId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
isHidden: false,
|
|
})
|
|
const createdWidget = await ctx.db.get(widgetId)
|
|
if (!createdWidget) {
|
|
throw new ConvexError("Falha ao criar widget de resumo por fila.")
|
|
}
|
|
queueWidget = createdWidget
|
|
widgets.unshift(queueWidget)
|
|
changed = true
|
|
} else {
|
|
// Ensure the existing widget is first and has the expected config.
|
|
const desiredConfig = normalizeQueueSummaryConfig(queueWidget.config)
|
|
if (JSON.stringify(queueWidget.config) !== JSON.stringify(desiredConfig)) {
|
|
await ctx.db.patch(queueWidget._id, { config: desiredConfig, updatedAt: now, updatedBy: actorId })
|
|
queueWidget = { ...queueWidget, config: desiredConfig }
|
|
changed = true
|
|
}
|
|
if (queueWidget.order !== 0) {
|
|
let nextOrder = 1
|
|
for (const widget of widgets) {
|
|
if (widget._id === queueWidget._id) continue
|
|
if (widget.order !== nextOrder) {
|
|
await ctx.db.patch(widget._id, { order: nextOrder, updatedAt: now, updatedBy: actorId })
|
|
}
|
|
nextOrder += 1
|
|
}
|
|
await ctx.db.patch(queueWidget._id, { order: 0, updatedAt: now, updatedBy: actorId })
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
if (!queueWidget) {
|
|
throw new ConvexError("Não foi possível garantir o widget de resumo por fila.")
|
|
}
|
|
|
|
const widgetKey = queueWidget.widgetKey
|
|
|
|
// Normalize dashboard layout (queue summary first).
|
|
const currentLayout = Array.isArray(dashboard.layout) ? dashboard.layout : []
|
|
const filteredLayout = currentLayout.filter((item) => item.i !== widgetKey)
|
|
const normalizedLayout = [queueSummaryLayout(widgetKey), ...filteredLayout]
|
|
|
|
let dashboardPatch: Partial<Doc<"dashboards">> = {}
|
|
if (JSON.stringify(currentLayout) !== JSON.stringify(normalizedLayout)) {
|
|
dashboardPatch.layout = normalizedLayout
|
|
changed = true
|
|
}
|
|
|
|
// Ensure sections used in TV playlists include the widget on the first slide.
|
|
if (Array.isArray(dashboard.sections) && dashboard.sections.length > 0) {
|
|
const updatedSections = dashboard.sections.map((section, index) => {
|
|
const keys = Array.isArray(section.widgetKeys) ? section.widgetKeys : []
|
|
const without = keys.filter((key) => key !== widgetKey)
|
|
if (index === 0) {
|
|
const nextKeys = [widgetKey, ...without]
|
|
if (JSON.stringify(nextKeys) !== JSON.stringify(keys)) {
|
|
changed = true
|
|
return { ...section, widgetKeys: nextKeys }
|
|
}
|
|
return section
|
|
}
|
|
if (without.length !== keys.length) {
|
|
changed = true
|
|
return { ...section, widgetKeys: without }
|
|
}
|
|
return section
|
|
})
|
|
if (changed && JSON.stringify(updatedSections) !== JSON.stringify(dashboard.sections)) {
|
|
dashboardPatch.sections = updatedSections
|
|
}
|
|
}
|
|
|
|
if (Object.keys(dashboardPatch).length > 0) {
|
|
dashboardPatch.updatedAt = now
|
|
dashboardPatch.updatedBy = actorId
|
|
await ctx.db.patch(dashboardId, dashboardPatch)
|
|
} else if (changed) {
|
|
await ctx.db.patch(dashboardId, { updatedAt: now, updatedBy: actorId })
|
|
}
|
|
|
|
if (!changed) {
|
|
// Nothing changed; still return success.
|
|
return { ensured: false, widgetKey }
|
|
}
|
|
|
|
return { ensured: true, widgetKey }
|
|
},
|
|
})
|
|
|
|
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)
|
|
}
|