"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 | 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 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(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 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 | null, ): LayoutStateItem[] { const layoutMap = new Map() 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 | 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(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 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(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((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() 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 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: ( { 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() 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) : {} const preset = getMetricDefinition(values.metricKey) const baseDataSource = (currentConfig.dataSource as Record | undefined) ?? {} const baseParams = (baseDataSource.params as Record | undefined) ?? {} const params: Record = { ...baseParams } if (values.rangeOverride && values.rangeOverride.trim().length > 0) { params.range = values.rangeOverride.trim() } else if ("range" in params) { delete params.range } const baseEncoding = (currentConfig.encoding as Record | undefined) ?? {} const mergedEncoding: Record = { ...baseEncoding } if (preset?.encoding) { Object.assign(mergedEncoding, preset.encoding) } mergedEncoding.stacked = values.stacked ?? preset?.stacked ?? false const baseOptions = (currentConfig.options as Record | undefined) ?? {} const mergedOptions: Record = { ...baseOptions, ...(preset?.options ?? {}), legend: values.legend ?? true, tooltip: values.showTooltip ?? true, } if (preset?.options?.indicator) { mergedOptions.indicator = preset.options.indicator } if (preset?.options?.valueFormatter) { mergedOptions.valueFormatter = preset.options.valueFormatter } const nextConfig: Record = { ...currentConfig, type: values.type, title: values.title, dataSource: { ...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 (
{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} {canEdit && !enforceTv ? ( ) : null} { setIsConfigOpen(open) if (!open) setConfigTarget(null) }} widget={configTarget} metricOptions={metricOptions} onSubmit={handleUpdateWidgetConfig} /> { if (!open) setDataTarget(null) }} widget={dataTarget} filters={filters} />
) } 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 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 (
{isEditingHeader ? (
setDraftName(event.target.value)} placeholder="Nome do dashboard" className="h-10 w-full max-w-xl rounded-lg border border-slate-300 px-3 text-lg font-semibold text-neutral-900" />