2012 lines
69 KiB
TypeScript
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 há 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>
|
|
)
|
|
}
|