sistema-de-chamados/src/components/dashboards/dashboard-builder.tsx
2025-11-06 01:46:16 -03:00

2012 lines
69 KiB
TypeScript

"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 { Textarea } from "@/components/ui/textarea"
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 {
Check,
Copy,
Download,
Edit3,
LayoutTemplate,
Maximize2,
Minimize2,
MonitorPlay,
PauseCircle,
PlayCircle,
Plus,
Sparkles,
Table2,
Trash2,
} from "lucide-react"
import { IconPencil } from "@tabler/icons-react"
import { getMetricDefinition, getMetricOptionsForRole } from "@/components/dashboards/metric-catalog"
const GRID_COLUMNS = 12
const DEFAULT_ROW_HEIGHT = 80
const MAX_ROWS = 32
type DashboardRecord = {
id: Id<"dashboards">
tenantId: string
name: string
description?: string | null
aspectRatio?: string | null
theme?: string | null
filters?: Record<string, unknown> | null
layout?: Array<LayoutItemFromServer> | null
sections?: Array<DashboardSection> | 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
maxW?: number
maxH?: number
static?: boolean
}
type LayoutStateItem = {
i: string
w: number
h: number
minW?: number
minH?: number
maxW?: number
maxH?: number
static?: boolean
}
type PackedLayoutItem = LayoutStateItem & {
x: number
y: number
}
type CanvasRenderableItem = {
key: string
layout: PackedLayoutItem
minW?: number
minH?: number
maxW?: number
maxH?: number
element: React.ReactNode
}
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: "Indicador KPI", description: "Valor agregado com destaque visual e variação opcional." },
{ type: "bar", title: "Gráfico de barras", description: "Comparação entre categorias ou períodos em colunas verticais." },
{ type: "line", title: "Gráfico de linhas", description: "Evolução temporal contínua para séries múltiplas." },
{ type: "area", title: "Gráfico de área", description: "Tendência temporal com preenchimento suave e suporte a empilhamento." },
{ type: "pie", title: "Gráfico de pizza/donut", description: "Participação percentual por categoria com rótulos opcionais." },
{ type: "radar", title: "Gráfico radar", description: "Comparação radial entre dimensões de performance." },
{ type: "gauge", title: "Indicador radial", description: "Mede um percentual (0-100%) em formato de gauge." },
{ type: "table", title: "Tabela dinâmica", description: "Lista tabular com cabeçalhos personalizáveis e ordenação." },
{ type: "text", title: "Bloco de texto", description: "Destaques, insights ou instruções em rich-text." },
]
const WIDGET_TYPE_LABELS = Object.fromEntries(WIDGET_LIBRARY.map((item) => [item.type, item.title])) as Record<
string,
string
>
const widgetSizePresets: Record<
string,
{
default: { w: number; h: number }
min: { w: number; h: number }
max: { w: number; h: number }
}
> = {
kpi: { default: { w: 4, h: 4 }, min: { w: 3, h: 3 }, max: { w: 6, h: 6 } },
bar: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } },
line: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } },
area: { default: { w: 6, h: 6 }, min: { w: 4, h: 4 }, max: { w: 10, h: 10 } },
pie: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 }, max: { w: 8, h: 9 } },
radar: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 }, max: { w: 8, h: 9 } },
gauge: { default: { w: 4, h: 5 }, min: { w: 3, h: 4 }, max: { w: 6, h: 7 } },
table: { default: { w: 8, h: 8 }, min: { w: 6, h: 5 }, max: { w: 12, h: 12 } },
text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } },
}
function getWidgetSize(type: string) {
const preset = widgetSizePresets[type] ?? widgetSizePresets.text
return {
...preset.default,
minW: preset.min.w,
minH: preset.min.h,
maxW: preset.max.w,
maxH: preset.max.h,
}
}
function useDebounce<T>(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<string, unknown> | undefined,
filters: DashboardFilters,
) {
const merged: Record<string, unknown> = { ...(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<string, unknown>
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 maxAllowedWidth = Math.min(columns, item.maxW ?? columns)
const maxAllowedHeight = Math.min(MAX_ROWS, item.maxH ?? MAX_ROWS)
const width = Math.max(
1,
Math.min(maxAllowedWidth, Math.max(item.minW ?? 2, Math.round(item.w))),
)
const height = Math.max(
1,
Math.min(maxAllowedHeight, Math.max(item.minH ?? 2, Math.round(item.h))),
)
let placed = false
let row = 0
while (!placed && row < MAX_ROWS * 4) {
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,
maxW: item.maxW,
maxH: item.maxH,
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,
maxW: item.maxW,
maxH: item.maxH,
static: item.static,
})
}
}
return packed
}
function buildInitialLayout(
widgets: DashboardWidgetRecord[],
existingLayout?: Array<LayoutItemFromServer> | null,
): LayoutStateItem[] {
const layoutMap = new Map<string, LayoutItemFromServer>()
existingLayout?.forEach((item) => layoutMap.set(item.i, item))
return widgets.map((widget) => {
const current = layoutMap.get(widget.widgetKey)
const defaults = getWidgetSize(widget.type)
if (current) {
return {
i: widget.widgetKey,
w: current.w,
h: current.h,
minW: current.minW ?? defaults.minW,
minH: current.minH ?? defaults.minH,
maxW: current.maxW ?? defaults.maxW,
maxH: current.maxH ?? defaults.maxH,
static: current.static ?? false,
}
}
return {
i: widget.widgetKey,
w: defaults.w,
h: defaults.h,
minW: defaults.minW,
minH: defaults.minH,
maxW: defaults.maxW,
maxH: defaults.maxH,
static: false,
}
})
}
function syncLayoutWithWidgets(
previous: LayoutStateItem[],
widgets: DashboardWidgetRecord[],
layoutFromServer?: Array<LayoutItemFromServer> | 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)
const defaults = getWidgetSize(widget.type)
if (existing) {
return {
...existing,
minW: existing.minW ?? defaults.minW,
minH: existing.minH ?? defaults.minH,
maxW: existing.maxW ?? defaults.maxW,
maxH: existing.maxH ?? defaults.maxH,
}
}
const server = serverMap.get(widget.widgetKey)
if (server) {
return {
i: widget.widgetKey,
w: server.w,
h: server.h,
minW: server.minW ?? defaults.minW,
minH: server.minH ?? defaults.minH,
maxW: server.maxW ?? defaults.maxW,
maxH: server.maxH ?? defaults.maxH,
static: server.static ?? false,
}
}
return {
i: widget.widgetKey,
w: defaults.w,
h: defaults.h,
minW: defaults.minW,
minH: defaults.minH,
maxW: defaults.maxW,
maxH: defaults.maxH,
static: false,
}
})
}
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
)
}
function deepEqual<T>(a: T, b: T) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
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<typeof widgetConfigSchema>
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 hasDashboardId = typeof dashboardId === "string" && dashboardId.length > 0
const userRole = session?.user.role ?? null
const detail = useQuery(
api.dashboards.get,
viewerId && hasDashboardId
? ({
tenantId,
viewerId: viewerId as Id<"users">,
dashboardId: dashboardId as Id<"dashboards">,
} as const)
: "skip",
) as DashboardDetailResult | undefined
const [dashboard, setDashboard] = useState<DashboardRecord | null>(null)
const [widgets, setWidgets] = useState<DashboardWidgetRecord[]>([])
const [shares, setShares] = useState<DashboardShareRecord[]>([])
const [filters, setFilters] = useState<DashboardFilters>({ ...DEFAULT_FILTERS })
const filtersHydratingRef = useRef(false)
const [layoutState, setLayoutState] = useState<LayoutStateItem[]>([])
const layoutRef = useRef<LayoutStateItem[]>([])
const [readyWidgets, setReadyWidgets] = useState<Set<string>>(new Set())
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
const [isConfigOpen, setIsConfigOpen] = useState(false)
const [configTarget, setConfigTarget] = useState<DashboardWidgetRecord | null>(null)
const [dataTarget, setDataTarget] = useState<DashboardWidgetRecord | null>(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((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard))
setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets))
setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares))
const nextFilters = normalizeFilters(detail.dashboard.filters)
if (!filtersEqual(filters, nextFilters)) {
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, filters])
useEffect(() => {
layoutRef.current = layoutState
}, [layoutState])
const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState])
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
const widgetMap = useMemo(() => {
const map = new Map<string, DashboardWidgetRecord>()
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])
// Ready handlers (stable per widget) defined before usage in canvasItems
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
setReadyWidgets((prev) => {
const currentlyReady = prev.has(key)
if (currentlyReady === ready) return prev
const next = new Set(prev)
if (ready) next.add(key)
else next.delete(key)
return next
})
}, [])
const readyHandlersRef = useRef(new Map<string, (ready: boolean) => void>())
const getReadyHandler = useCallback(
(key: string) => {
const cached = readyHandlersRef.current.get(key)
if (cached) return cached
const handler = (ready: boolean) => handleWidgetReady(key, ready)
readyHandlersRef.current.set(key, handler)
return handler
},
[handleWidgetReady],
)
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,
element: (
<BuilderWidgetCard
key={widget.widgetKey}
widget={widget}
filters={filters}
mode={enforceTv ? "tv" : mode}
editable={canEdit}
onEdit={() => {
setConfigTarget(widget)
setIsConfigOpen(true)
}}
onDuplicate={() => handleDuplicateWidget(widget)}
onRemove={() => handleRemoveWidget(widget)}
onViewData={() => setDataTarget(widget)}
onReadyChange={getReadyHandler(widget.widgetKey)}
/>
),
...(item.minW !== undefined ? { minW: item.minW } : {}),
...(item.minH !== undefined ? { minH: item.minH } : {}),
...(item.maxW !== undefined ? { maxW: item.maxW } : {}),
...(item.maxH !== undefined ? { maxH: item.maxH } : {}),
} satisfies CanvasRenderableItem
})
.filter(Boolean) as CanvasRenderableItem[]
const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key))
useEffect(() => {
const keys = new Set(canvasItems.map((item) => item.key))
setReadyWidgets((prev) => {
const next = new Set<string>()
keys.forEach((key) => {
if (prev.has(key)) {
next.add(key)
}
})
if (next.size === prev.size) {
let differs = false
for (const key of next) {
if (!prev.has(key)) {
differs = true
break
}
}
if (!differs) {
return prev
}
}
return next
})
}, [canvasItems])
const persistLayout = useCallback(
async (nextState: LayoutStateItem[]) => {
if (!canEdit || !viewerId || !dashboard) return
const packed = packLayout(nextState, GRID_COLUMNS)
const sanitized = packed.map(({ maxW, maxH, ...rest }) => rest)
try {
await updateLayoutMutation({
tenantId,
actorId: viewerId as Id<"users">,
dashboardId: dashboard.id,
layout: sanitized,
})
} 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) => {
if (item.i !== key) return item
const minW = item.minW ?? 1
const minH = item.minH ?? 1
const maxW = item.maxW ?? GRID_COLUMNS
const maxH = item.maxH ?? MAX_ROWS
return {
...item,
w: Math.min(maxW, Math.max(minW, size.w)),
h: Math.min(maxH, Math.max(minH, size.h)),
}
})
if (options?.commit) {
persistLayout(next)
}
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<string, unknown>)
: {}
const preset = getMetricDefinition(values.metricKey)
const baseDataSource = (currentConfig.dataSource as Record<string, unknown> | undefined) ?? {}
const baseParams = (baseDataSource.params as Record<string, unknown> | undefined) ?? {}
const params: Record<string, unknown> = { ...baseParams }
if (values.rangeOverride && values.rangeOverride.trim().length > 0) {
params.range = values.rangeOverride.trim()
} else if ("range" in params) {
delete params.range
}
const baseEncoding = (currentConfig.encoding as Record<string, unknown> | undefined) ?? {}
const mergedEncoding: Record<string, unknown> = { ...baseEncoding }
if (preset?.encoding) {
Object.assign(mergedEncoding, preset.encoding)
}
mergedEncoding.stacked = values.stacked ?? preset?.stacked ?? false
const baseOptions = (currentConfig.options as Record<string, unknown> | undefined) ?? {}
const mergedOptions: Record<string, unknown> = {
...baseOptions,
...(preset?.options ?? {}),
legend: values.legend ?? true,
tooltip: values.showTooltip ?? true,
}
if (preset?.options?.indicator) {
mergedOptions.indicator = preset.options.indicator
}
if (preset?.options?.valueFormatter) {
mergedOptions.valueFormatter = preset.options.valueFormatter
}
const nextConfig: Record<string, unknown> = {
...currentConfig,
type: values.type,
title: values.title,
dataSource: {
...baseDataSource,
metricKey: values.metricKey,
params,
},
encoding: mergedEncoding,
options: mergedOptions,
}
if (preset?.columns) {
nextConfig.columns = preset.columns
}
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; tvIntervalSeconds?: number }) => {
if (!dashboard || !viewerId || !canEdit) return
try {
await updateMetadataMutation({
tenantId,
actorId: viewerId as Id<"users">,
dashboardId: dashboard.id,
name: payload.name,
description: payload.description ?? undefined,
tvIntervalSeconds: payload.tvIntervalSeconds,
})
} 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) {
let msg = "Export request failed"
try {
const data = await response.json()
if (data?.hint) msg = `${msg}: ${data.hint}`
else if (data?.error) msg = `${msg}: ${data.error}`
} catch {}
throw new Error(msg)
}
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)
const message = error instanceof Error ? error.message : String(error)
toast.error(message.includes("Playwright browsers not installed") || message.includes("npx playwright install")
? "Exportação indisponível em DEV: rode `npx playwright install` para habilitar."
: "Não foi possível exportar o dashboard.")
} finally {
setIsExporting(false)
}
}
const handleToggleTvMode = useCallback(() => {
if (!dashboardId) return
const baseRoute = `/dashboards/${dashboardId}`
const params = new URLSearchParams(searchParams ? searchParams.toString() : "")
if (enforceTv) {
params.delete("tv")
const query = params.toString()
router.push(query ? `${baseRoute}?${query}` : baseRoute)
} else {
params.set("tv", "1")
router.push(`${baseRoute}?${params.toString()}`)
}
}, [dashboardId, enforceTv, router, searchParams])
const handleFiltersChange = (next: DashboardFilters) => {
setFilters(next)
}
if (!detail || !dashboard) {
return (
<div className="space-y-6">
<Skeleton className="h-12 w-2/3 rounded-xl" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-[280px] rounded-2xl" />
))}
</div>
</div>
)
}
const visibleCount = canvasItems.length
return (
<div className="flex flex-col gap-6">
<BuilderHeader
dashboard={dashboard}
canEdit={canEdit}
onAddWidget={handleAddWidget}
onExport={handleExport}
isAddingWidget={isAddingWidget}
isExporting={isExporting}
onMetadataChange={handleUpdateMetadata}
enforceTv={enforceTv}
activeSectionIndex={activeSectionIndex}
totalSections={sections.length}
onToggleTvMode={handleToggleTvMode}
totalWidgets={widgets.length}
/>
<DashboardFilterBar filters={filters} onChange={handleFiltersChange} />
{enforceTv && sections.length > 0 ? (
<TvSectionIndicator
sections={sections}
activeIndex={activeSectionIndex}
onChange={setActiveSectionIndex}
/>
) : null}
{visibleCount === 0 ? (
<Card className="border-dashed border-muted-foreground/40 bg-muted/10 py-12 text-center">
<CardHeader>
<CardTitle className="flex items-center justify-center gap-2 text-lg font-semibold">
<Sparkles className="size-4 text-primary" />
Comece adicionando widgets
</CardTitle>
<CardDescription>
KPIs, gráficos ou tabelas podem ser combinados para contar histórias relevantes para a operação.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
{canEdit ? (
<Button onClick={() => handleAddWidget("kpi")} disabled={isAddingWidget}>
<Plus className="mr-2 size-4" />
Adicionar primeiro widget
</Button>
) : (
<p className="text-sm text-muted-foreground">Nenhum widget visível para esta seção.</p>
)}
</CardContent>
</Card>
) : null}
<ReportCanvas
items={canvasItems}
editable={canEdit && !enforceTv && mode !== "print"}
columns={GRID_COLUMNS}
rowHeight={DEFAULT_ROW_HEIGHT}
gap={20}
ready={allWidgetsReady}
onResize={handleLayoutResize}
onReorder={handleLayoutReorder}
/>
{canEdit && !enforceTv ? (
<button
type="button"
onClick={() => handleAddWidget("kpi")}
className="flex h-28 w-full items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-slate-100/40 text-sm font-medium text-slate-800 transition hover:border-slate-400 hover:bg-slate-200/60"
>
<Plus className="mr-2 size-4" />
Adicionar novo espaço de widget
</button>
) : null}
<WidgetConfigDialog
open={isConfigOpen}
onOpenChange={(open) => {
setIsConfigOpen(open)
if (!open) setConfigTarget(null)
}}
widget={configTarget}
metricOptions={metricOptions}
onSubmit={handleUpdateWidgetConfig}
/>
<WidgetDataSheet
open={Boolean(dataTarget)}
onOpenChange={(open) => {
if (!open) setDataTarget(null)
}}
widget={dataTarget}
filters={filters}
/>
</div>
)
}
function BuilderHeader({
dashboard,
canEdit,
onAddWidget,
onExport,
isAddingWidget,
isExporting,
onMetadataChange,
enforceTv,
activeSectionIndex,
totalSections,
onToggleTvMode,
totalWidgets,
}: {
dashboard: DashboardRecord
canEdit: boolean
onAddWidget: (type: string) => void
onExport: (format: "pdf" | "png") => Promise<void>
isAddingWidget: boolean
isExporting: boolean
onMetadataChange: (payload: { name?: string; description?: string | null; tvIntervalSeconds?: number }) => void
enforceTv: boolean
activeSectionIndex: number
totalSections: number
onToggleTvMode: () => void
totalWidgets: number
}) {
const [name, setName] = useState(dashboard.name)
const [description, setDescription] = useState(dashboard.description ?? "")
const [isEditingHeader, setIsEditingHeader] = useState(false)
const [draftName, setDraftName] = useState(dashboard.name)
const [draftDescription, setDraftDescription] = useState(dashboard.description ?? "")
const [isSavingHeader, setIsSavingHeader] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
useEffect(() => {
setName(dashboard.name)
setDescription(dashboard.description ?? "")
if (!isEditingHeader) {
setDraftName(dashboard.name)
setDraftDescription(dashboard.description ?? "")
}
}, [dashboard.name, dashboard.description, isEditingHeader])
useEffect(() => {
if (typeof document === "undefined") return
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement))
}
document.addEventListener("fullscreenchange", handleFullscreenChange)
handleFullscreenChange()
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange)
}
}, [])
const toggleFullscreen = useCallback(() => {
if (typeof document === "undefined") return
if (document.fullscreenElement) {
const exit = document.exitFullscreen
if (typeof exit === "function") {
exit.call(document).catch(() => undefined)
}
return
}
const element = document.documentElement
if (element && typeof element.requestFullscreen === "function") {
element.requestFullscreen().catch(() => undefined)
}
}, [])
const rotationInterval = Math.max(5, dashboard.tvIntervalSeconds ?? 30)
const handleStartEditHeader = () => {
setDraftName(name)
setDraftDescription(description ?? "")
setIsEditingHeader(true)
}
const handleCancelEditHeader = () => {
setDraftName(name)
setDraftDescription(description ?? "")
setIsEditingHeader(false)
}
const handleSaveHeader = async () => {
if (!canEdit) return
const trimmedName = draftName.trim()
if (!trimmedName) {
toast.error("Informe um nome para o dashboard.")
return
}
const trimmedDescription = draftDescription.trim()
const nextDescription = trimmedDescription.length > 0 ? trimmedDescription : null
const didChangeName = trimmedName !== name
const didChangeDescription = nextDescription !== (description ?? null)
if (!didChangeName && !didChangeDescription) {
setIsEditingHeader(false)
return
}
setIsSavingHeader(true)
try {
await onMetadataChange({
name: trimmedName,
description: nextDescription,
})
setName(trimmedName)
setDescription(nextDescription ?? "")
setIsEditingHeader(false)
toast.success("Informações atualizadas.")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o dashboard.")
} finally {
setIsSavingHeader(false)
}
}
const isTvMode = enforceTv
const hasSections = totalSections > 0
return (
<Card className="rounded-2xl border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 shadow-sm">
<CardContent className="flex flex-col gap-6 p-6 md:p-7">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-col gap-3">
{isEditingHeader ? (
<div className="space-y-3">
<Input
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
placeholder="Nome do dashboard"
className="h-10 w-full max-w-xl rounded-lg border border-slate-300 px-3 text-lg font-semibold text-neutral-900"
/>
<Textarea
value={draftDescription}
onChange={(event) => setDraftDescription(event.target.value)}
placeholder="Adicionar uma descrição opcional"
rows={3}
className="w-full max-w-xl resize-none overflow-hidden rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
/>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{name}</h1>
{canEdit ? (
<Button
size="icon"
variant="outline"
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
onClick={handleStartEditHeader}
aria-label="Editar título e descrição"
>
<IconPencil className="size-5" />
</Button>
) : null}
</div>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Formato {dashboard.aspectRatio ?? "16:9"}
</Badge>
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
Tema {dashboard.theme ?? "system"}
</Badge>
<Badge
variant="outline"
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
>
<span className="h-2 w-2 rounded-full bg-neutral-400" />
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
</Badge>
{isTvMode && hasSections ? (
<Badge className="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-primary">
<MonitorPlay className="size-3.5" />
Slide {activeSectionIndex + 1} de {totalSections}
</Badge>
) : null}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
{isEditingHeader ? (
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-sm font-semibold text-neutral-700"
onClick={handleCancelEditHeader}
disabled={isSavingHeader}
>
Cancelar
</Button>
<Button
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/85"
onClick={handleSaveHeader}
disabled={isSavingHeader}
>
{isSavingHeader ? "Salvando..." : "Salvar"}
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{canEdit ? <WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} /> : null}
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={toggleFullscreen}
>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={isTvMode ? "secondary" : "default"}
size="sm"
className="gap-2"
>
{isTvMode ? <PauseCircle className="size-4" /> : <PlayCircle className="size-4" />}
{isTvMode ? "Modo apresentação ativo" : "Modo apresentação"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Modo apresentação</DropdownMenuLabel>
<DropdownMenuItem onSelect={onToggleTvMode}>
{isTvMode ? "Encerrar modo apresentação" : "Iniciar modo apresentação"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Tempo por seção</DropdownMenuLabel>
{[10, 20, 30, 60, 90].map((seconds) => (
<DropdownMenuItem
key={seconds}
disabled={!canEdit}
className="flex items-center justify-between gap-2"
onSelect={() => {
if (!canEdit) return
onMetadataChange({ tvIntervalSeconds: seconds })
}}
>
<span>{seconds} segundos</span>
{rotationInterval === seconds ? <Check className="size-3.5" /> : null}
</DropdownMenuItem>
))}
{canEdit ? null : (
<DropdownMenuItem disabled>
Apenas edição permite ajustar o tempo
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Download className="size-4" />
Exportar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Exportar dashboard</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("pdf")}>
Exportar como PDF
</DropdownMenuItem>
<DropdownMenuItem disabled={isExporting} onSelect={() => onExport("png")}>
Exportar como PNG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
)
}
function WidgetPicker({ onSelect, disabled }: { onSelect: (type: string) => void; disabled?: boolean }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="gap-2" disabled={disabled}>
<Plus className="size-4" />
Adicionar bloco
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuLabel>Blocos disponíveis</DropdownMenuLabel>
<DropdownMenuSeparator />
{WIDGET_LIBRARY.map((item) => (
<DropdownMenuItem
key={item.type}
onSelect={() => onSelect(item.type)}
className="flex items-start gap-3"
>
<LayoutTemplate className="mt-1 size-4 text-neutral-900" />
<div className="flex flex-col">
<span className="font-medium text-foreground">{item.title}</span>
<span className="text-xs text-muted-foreground">{item.description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
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 queues = useQuery(
api.queues.list,
isStaff && viewerId
? ({
tenantId,
viewerId,
} as const)
: "skip",
) as Array<{ id: Id<"queues">; 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])
const queueOptions: SearchableComboboxOption[] = useMemo(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as filas" }]
if (!queues || queues.length === 0) return base
const sorted = [...queues].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((queue) => ({
value: queue.id,
label: queue.name,
})),
]
}, [queues])
return (
<Card className="rounded-2xl border border-border/60 bg-gradient-to-br from-white/90 via-white to-primary/5 shadow-sm">
<CardContent className="flex flex-wrap items-end gap-4 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Período
</span>
<Select
value={filters.range ?? "30d"}
onValueChange={(value) => onChange({ ...filters, range: value as DashboardFilters["range"] })}
>
<SelectTrigger className="h-[46px] w-44 rounded-full border border-slate-300 bg-white px-4 text-sm font-medium text-neutral-800 shadow-sm focus-visible:ring-0 data-[state=open]:border-neutral-600">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Empresa
</span>
<SearchableCombobox
value={filters.companyId ?? "all"}
onValueChange={(value) => onChange({ ...filters, companyId: value === "all" ? null : value })}
options={companyOptions}
placeholder="Todas as empresas"
className="min-w-[220px]"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Fila
</span>
<SearchableCombobox
value={filters.queueId ?? "all"}
onValueChange={(value) => onChange({ ...filters, queueId: value === "all" ? null : value })}
options={queueOptions}
placeholder="Todas as filas"
className="min-w-[220px]"
/>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto mt-2 text-muted-foreground hover:text-foreground"
onClick={() => onChange({ ...DEFAULT_FILTERS })}
>
Limpar filtros
</Button>
</CardContent>
</Card>
)
}
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 (
<div className="group relative h-full">
{editable && mode !== "tv" && mode !== "print" ? (
<div className="absolute inset-x-3 top-3 z-10 flex items-center justify-end gap-1 opacity-0 transition group-hover:opacity-100">
<Button
size="icon"
variant="outline"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-slate-300"
onClick={onEdit}
>
<IconPencil className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-slate-300"
onClick={onDuplicate}
>
<Copy className="size-3.5" />
</Button>
<Button
size="icon"
variant="outline"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-700 shadow-sm transition hover:border-slate-400 hover:bg-slate-100 focus-visible:ring-2 focus-visible:ring-slate-300"
onClick={onViewData}
>
<Table2 className="size-3.5" />
</Button>
<Button
size="icon"
variant="outline"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-white text-rose-600 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 hover:text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200"
onClick={onRemove}
>
<Trash2 className="size-3.5" />
</Button>
</div>
) : null}
<WidgetRenderer widget={widget} filters={filters} mode={mode} onReadyChange={onReadyChange} />
</div>
)
}
function WidgetConfigDialog({
open,
onOpenChange,
widget,
metricOptions,
onSubmit,
}: {
open: boolean
onOpenChange: (open: boolean) => void
widget: DashboardWidgetRecord | null
metricOptions: SearchableComboboxOption[]
onSubmit: (values: WidgetConfigFormValues) => Promise<void>
}) {
const normalizedConfig = getWidgetConfigForWidget(widget)
const form = useForm<WidgetConfigFormValues>({
resolver: zodResolver(widgetConfigSchema),
defaultValues: {
title: normalizedConfig?.title ?? widget?.title ?? "",
type: normalizedConfig?.type ?? widget?.type ?? "kpi",
metricKey: normalizedConfig?.dataSource?.metricKey ?? "",
stacked: Boolean(normalizedConfig?.encoding && "stacked" in normalizedConfig.encoding ? normalizedConfig.encoding.stacked : false),
legend: Boolean(normalizedConfig?.options && "legend" in normalizedConfig.options ? normalizedConfig.options.legend : true),
rangeOverride:
typeof normalizedConfig?.dataSource?.params?.range === "string"
? (normalizedConfig.dataSource?.params?.range as string)
: "",
showTooltip: Boolean(normalizedConfig?.options && "tooltip" in normalizedConfig.options ? normalizedConfig.options.tooltip : true),
},
})
useEffect(() => {
form.register("metricKey")
}, [form])
const metricKeyValue = form.watch("metricKey")
const widgetTypeValue = form.watch("type")
const legendValue = form.watch("legend")
const stackedValue = form.watch("stacked")
const showTooltipValue = form.watch("showTooltip")
const selectedMetric = useMemo(() => getMetricDefinition(metricKeyValue), [metricKeyValue])
const metricOptionValue = selectedMetric ? selectedMetric.key : null
const recommendedWidgetTitle = selectedMetric ? WIDGET_TYPE_LABELS[selectedMetric.recommendedWidget] ?? selectedMetric.recommendedWidget : null
const lastAppliedMetricRef = useRef<string | null>(null)
const lastTitleFromPresetRef = useRef<string | null>(null)
useEffect(() => {
if (!widget) return
const config = getWidgetConfigForWidget(widget)
form.reset({
title: config?.title ?? widget.title ?? "",
type: config?.type ?? widget.type,
metricKey: config?.dataSource?.metricKey ?? "",
stacked: Boolean(config?.encoding && "stacked" in config.encoding ? config.encoding.stacked : false),
legend: Boolean(config?.options && "legend" in config.options ? config.options.legend : true),
rangeOverride:
typeof config?.dataSource?.params?.range === "string"
? (config.dataSource?.params?.range as string)
: "",
showTooltip: Boolean(config?.options && "tooltip" in config.options ? config.options.tooltip : true),
})
lastAppliedMetricRef.current =
typeof config?.dataSource?.metricKey === "string" ? (config.dataSource?.metricKey as string) : null
lastTitleFromPresetRef.current = config?.title ?? widget.title ?? null
}, [widget, form])
useEffect(() => {
if (!selectedMetric) {
lastAppliedMetricRef.current = null
return
}
const normalizedKey = selectedMetric.key
if (lastAppliedMetricRef.current === normalizedKey) {
return
}
form.setValue("type", selectedMetric.recommendedWidget, { shouldDirty: true })
if (typeof selectedMetric.stacked === "boolean") {
form.setValue("stacked", selectedMetric.stacked, { shouldDirty: true })
}
if (selectedMetric.options?.legend !== undefined) {
form.setValue("legend", selectedMetric.options.legend, { shouldDirty: true })
}
if (selectedMetric.options?.tooltip !== undefined) {
form.setValue("showTooltip", selectedMetric.options.tooltip, { shouldDirty: true })
}
const currentTitle = form.getValues("title")
if (
!currentTitle ||
currentTitle.trim().length === 0 ||
currentTitle === (lastTitleFromPresetRef.current ?? "")
) {
form.setValue("title", selectedMetric.defaultTitle, { shouldDirty: true })
lastTitleFromPresetRef.current = selectedMetric.defaultTitle
}
lastAppliedMetricRef.current = normalizedKey
}, [form, selectedMetric])
useEffect(() => {
const supportsStacked = ["bar", "area"].includes(widgetTypeValue)
if (!supportsStacked && stackedValue) {
form.setValue("stacked", false, { shouldDirty: true })
}
const supportsLegendLocal = ["bar", "line", "area", "pie", "radar"].includes(widgetTypeValue)
if (!supportsLegendLocal && legendValue) {
form.setValue("legend", false, { shouldDirty: true })
}
const supportsTooltipLocal = ["bar", "line", "area", "pie", "radar"].includes(widgetTypeValue)
if (!supportsTooltipLocal && showTooltipValue) {
form.setValue("showTooltip", false, { shouldDirty: true })
}
}, [form, legendValue, showTooltipValue, stackedValue, widgetTypeValue])
useEffect(() => {
if (!selectedMetric) {
lastAppliedMetricRef.current = null
}
}, [selectedMetric])
const supportsLegend = ["bar", "line", "area", "pie", "radar"].includes(widgetTypeValue)
const supportsStacked = ["bar", "area"].includes(widgetTypeValue)
const supportsTooltip = ["bar", "line", "area", "pie", "radar"].includes(widgetTypeValue)
const handleMetricOptionChange = (value: string | null) => {
if (!value) {
form.setValue("metricKey", "", { shouldDirty: true })
lastAppliedMetricRef.current = null
return
}
form.setValue("metricKey", value, { shouldDirty: true, shouldValidate: true })
}
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(values)
form.reset()
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Configurar widget</DialogTitle>
<DialogDescription>Personalize título, métrica e comportamento visual do bloco.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Título</label>
<Input {...form.register("title")} placeholder="Ex.: Tickets aguardando ação" />
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Tipo</label>
<Select value={form.watch("type")} onValueChange={(value) => form.setValue("type", value)}>
<SelectTrigger className="h-10 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WIDGET_LIBRARY.map((item) => (
<SelectItem key={item.type} value={item.type}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Métrica (fonte de dados)</label>
<SearchableCombobox
value={metricOptionValue}
onValueChange={handleMetricOptionChange}
options={metricOptions}
placeholder="Selecione uma métrica"
searchPlaceholder="Pesquisar por nome ou palavra-chave"
emptyText="Nenhuma métrica encontrada."
allowClear
className="rounded-full"
contentClassName="w-[420px]"
scrollClassName="max-h-72"
scrollProps={{
onWheel: (event) => {
event.currentTarget.scrollTop += event.deltaY
event.preventDefault()
event.stopPropagation()
},
}}
disabled={metricOptions.length === 0}
renderValue={(option) =>
option?.label ?? (metricKeyValue ? `Chave: ${metricKeyValue}` : undefined)
}
/>
{selectedMetric ? (
<div className="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs leading-relaxed text-muted-foreground">
<div className="flex flex-wrap items-center justify-between gap-2 text-foreground">
<span className="font-semibold">{selectedMetric.name}</span>
{recommendedWidgetTitle ? (
<Badge variant="outline" className="rounded-full border-border/60 bg-white px-3 py-0.5 text-[11px] font-semibold">
{recommendedWidgetTitle}
</Badge>
) : null}
</div>
<p className="mt-2 text-xs text-muted-foreground">{selectedMetric.description}</p>
</div>
) : metricOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">
Não métricas disponíveis para o seu perfil de acesso.
</p>
) : (
<p className="text-xs text-muted-foreground">
Selecione uma métrica para aplicar presets automáticos de layout e visualização.
</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
{supportsLegend ? (
<SwitchField
label="Exibir legenda"
description="Mostra os nomes das séries no gráfico."
checked={Boolean(legendValue)}
onCheckedChange={(checked) => form.setValue("legend", checked)}
/>
) : null}
{supportsStacked ? (
<SwitchField
label="Empilhar séries"
description="Acumula os valores das séries no eixo Y."
checked={Boolean(stackedValue)}
onCheckedChange={(checked) => form.setValue("stacked", checked)}
/>
) : null}
{supportsTooltip ? (
<SwitchField
label="Tooltip interativo"
description="Mantém os detalhes ao passar o cursor sobre os pontos."
checked={Boolean(showTooltipValue)}
onCheckedChange={(checked) => form.setValue("showTooltip", checked)}
/>
) : null}
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Período específico (opcional)</label>
<Input
{...form.register("rangeOverride")}
placeholder="Ex.: 7d, 30d, 90d"
/>
</div>
</div>
<DialogFooter>
<Button type="submit">
Salvar widget
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function SwitchField({
label,
description,
checked,
onCheckedChange,
disabled = false,
}: {
label: string
description?: string
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
}) {
return (
<div
className={cn(
"flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-gradient-to-r from-white via-white to-primary/5 px-3 py-3 transition",
disabled ? "cursor-not-allowed opacity-50" : "hover:border-primary/40 hover:shadow-sm",
)}
>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{label}</p>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<Checkbox
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
disabled={disabled}
className="size-5 rounded-md border-primary/50 data-[state=checked]:bg-primary"
/>
</div>
)
}
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<Record<string, unknown>>) : []
const columns = rows.length > 0 ? Object.keys(rows[0]!) : []
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="max-h-[80vh] overflow-y-auto bg-gradient-to-br from-background via-background to-primary/10 px-6 pb-10 pt-12 sm:px-10">
<SheetHeader>
<SheetTitle>Dados do widget {widget?.title ?? widget?.widgetKey}</SheetTitle>
</SheetHeader>
{metric.isLoading ? (
<div className="grid gap-2 pt-6">
{Array.from({ length: 10 }).map((_, index) => (
<Skeleton key={index} className="h-8 w-full rounded-sm" />
))}
</div>
) : rows.length === 0 ? (
<div className="mt-6 rounded-2xl border border-dashed border-primary/40 bg-primary/5 p-8 text-center text-sm font-medium text-primary/80">
Sem dados disponíveis para os filtros atuais.
</div>
) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-border/60 bg-white/95 shadow-sm">
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted/60">
<tr>
{columns.map((column) => (
<th key={column} className="px-4 py-2 text-left font-medium text-foreground">
{column}
</th>
))}
</tr>
</thead>
<tbody>
{rows.slice(0, 400).map((row, index) => (
<tr key={index} className="border-b border-border/40 transition hover:bg-muted/40">
{columns.map((column) => (
<td key={column} className="px-4 py-2 text-muted-foreground">
{renderTableCellValue(row[column])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
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 (
<div className="flex items-center justify-between rounded-xl border border-primary/30 bg-primary/5 px-4 py-2 text-sm text-primary">
<div className="flex items-center gap-2">
<MonitorPlay className="size-4" />
<span>
Seção {activeIndex + 1} de {sections.length}: {sections[activeIndex]?.title ?? "Sem título"}
</span>
</div>
<div className="flex items-center gap-1">
{sections.map((section, index) => (
<button
key={section.id}
onClick={() => onChange(index)}
className={cn(
"h-2.5 w-2.5 rounded-full transition",
index === activeIndex ? "bg-primary" : "bg-primary/30 hover:bg-primary/60",
)}
/>
))}
</div>
</div>
)
}