feat: refresh dashboards experience
This commit is contained in:
parent
1900f65e5e
commit
d7d6b748cc
9 changed files with 1626 additions and 281 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -255,12 +255,21 @@ export function DashboardListView() {
|
|||
<CardDescription className="line-clamp-2 text-sm">{dashboard.description}</CardDescription>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Última atualização{" "}
|
||||
<span className="font-medium text-foreground">{updatedAt}</span>
|
||||
</p>
|
||||
<p>Formato {dashboard.aspectRatio} · Tema {dashboard.theme}</p>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-medium text-neutral-700">
|
||||
<span className="inline-flex size-2 rounded-full bg-emerald-500" />
|
||||
Atualizado {updatedAt}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
|
||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||
Formato {dashboard.aspectRatio}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
|
||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||
Tema {dashboard.theme}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button asChild className="w-full">
|
||||
|
|
|
|||
312
src/components/dashboards/metric-catalog.ts
Normal file
312
src/components/dashboards/metric-catalog.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import type { SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||
|
||||
type MetricEncoding = {
|
||||
x?: string
|
||||
y?: Array<{ field: string; label?: string }>
|
||||
category?: string
|
||||
value?: string
|
||||
angle?: string
|
||||
radius?: string
|
||||
}
|
||||
|
||||
type MetricOptions = {
|
||||
legend?: boolean
|
||||
tooltip?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
valueFormatter?: "percent"
|
||||
}
|
||||
|
||||
type MetricAudience = "all" | "agent" | "admin"
|
||||
|
||||
export type DashboardMetricDefinition = {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
defaultTitle: string
|
||||
recommendedWidget:
|
||||
| "kpi"
|
||||
| "bar"
|
||||
| "line"
|
||||
| "area"
|
||||
| "pie"
|
||||
| "radar"
|
||||
| "gauge"
|
||||
| "table"
|
||||
| "text"
|
||||
keywords?: string[]
|
||||
encoding?: MetricEncoding
|
||||
columns?: Array<{ field: string; label: string }>
|
||||
stacked?: boolean
|
||||
options?: MetricOptions
|
||||
audience?: MetricAudience
|
||||
}
|
||||
|
||||
export const DASHBOARD_METRIC_DEFINITIONS: DashboardMetricDefinition[] = [
|
||||
{
|
||||
key: "tickets.opened_resolved_by_day",
|
||||
name: "Tickets abertos x resolvidos por dia",
|
||||
description:
|
||||
"Comparativo diário entre tickets abertos e resolvidos considerando os filtros globais do dashboard.",
|
||||
defaultTitle: "Abertos x resolvidos (últimos dias)",
|
||||
recommendedWidget: "area",
|
||||
encoding: {
|
||||
x: "date",
|
||||
y: [
|
||||
{ field: "opened", label: "Abertos" },
|
||||
{ field: "resolved", label: "Resolvidos" },
|
||||
],
|
||||
},
|
||||
options: { legend: true, tooltip: true, indicator: "line" },
|
||||
keywords: ["linha", "tendência", "evolução", "resolução", "abertos"],
|
||||
},
|
||||
{
|
||||
key: "tickets.waiting_action_now",
|
||||
name: "Tickets aguardando ação (agora)",
|
||||
description: "Total de tickets abertos que estão aguardando ação neste instante e quantos estão fora do SLA.",
|
||||
defaultTitle: "Tickets aguardando ação",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["sla", "pendentes", "agora", "urgente"],
|
||||
},
|
||||
{
|
||||
key: "tickets.waiting_action_last_7d",
|
||||
name: "Tickets aguardando ação (últimos 7 dias)",
|
||||
description:
|
||||
"Volume acumulado de tickets abertos nas últimas 7 noites, com destaque para aqueles em risco de SLA.",
|
||||
defaultTitle: "Pendências nos últimos 7 dias",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["tendência", "acumulado", "últimos 7 dias", "sla"],
|
||||
},
|
||||
{
|
||||
key: "tickets.open_by_priority",
|
||||
name: "Tickets abertos por prioridade",
|
||||
description:
|
||||
"Distribuição de tickets abertos segmentada por prioridade durante o período selecionado.",
|
||||
defaultTitle: "Abertos por prioridade",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "priority",
|
||||
y: [{ field: "total", label: "Tickets" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot" },
|
||||
keywords: ["prioridade", "backup", "distribuição"],
|
||||
},
|
||||
{
|
||||
key: "tickets.open_by_queue",
|
||||
name: "Tickets abertos por fila",
|
||||
description: "Ranking de filas com maior volume de tickets abertos no período filtrado.",
|
||||
defaultTitle: "Abertos por fila",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "name",
|
||||
y: [{ field: "total", label: "Tickets abertos" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot" },
|
||||
keywords: ["fila", "ranking", "operacional"],
|
||||
},
|
||||
{
|
||||
key: "tickets.sla_compliance_by_queue",
|
||||
name: "Cumprimento de SLA por fila",
|
||||
description: "Percentual de atendimento dentro do SLA por fila com detalhamento de volume total.",
|
||||
defaultTitle: "Cumprimento de SLA por fila",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "name",
|
||||
y: [{ field: "compliance", label: "Cumprimento (%)" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
|
||||
keywords: ["sla", "fila", "percentual", "qualidade"],
|
||||
},
|
||||
{
|
||||
key: "tickets.sla_rate",
|
||||
name: "Taxa geral de cumprimento de SLA",
|
||||
description: "Percentual geral de tickets resolvidos dentro do prazo no período filtrado.",
|
||||
defaultTitle: "Cumprimento geral de SLA",
|
||||
recommendedWidget: "gauge",
|
||||
keywords: ["sla", "percentual", "qualidade", "indicador"],
|
||||
},
|
||||
{
|
||||
key: "tickets.awaiting_table",
|
||||
name: "Tickets aguardando com detalhes",
|
||||
description: "Lista dos principais tickets aguardando ação, incluindo prioridade e responsável.",
|
||||
defaultTitle: "Detalhes das pendências",
|
||||
recommendedWidget: "table",
|
||||
columns: [
|
||||
{ field: "subject", label: "Assunto" },
|
||||
{ field: "priority", label: "Prioridade" },
|
||||
{ field: "status", label: "Status" },
|
||||
{ field: "updatedAt", label: "Atualizado em" },
|
||||
],
|
||||
keywords: ["tabela", "lista", "pendências", "detalhes"],
|
||||
},
|
||||
{
|
||||
key: "agents.self_ticket_status",
|
||||
name: "Status dos meus tickets",
|
||||
description: "Distribuição entre tickets abertos, pausados e resolvidos do próprio agente no período.",
|
||||
defaultTitle: "Status dos meus tickets",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "status",
|
||||
y: [{ field: "total", label: "Chamados" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot" },
|
||||
keywords: ["agente", "status", "abertos", "resolvidos"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.self_open_total",
|
||||
name: "Meus tickets abertos",
|
||||
description: "Quantidade atual de tickets abertos atribuídos ao agente.",
|
||||
defaultTitle: "Tickets abertos (meus)",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["agente", "abertos", "backlog"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.self_paused_total",
|
||||
name: "Meus tickets pausados",
|
||||
description: "Quantidade de tickets pausados sob responsabilidade do agente.",
|
||||
defaultTitle: "Tickets pausados (meus)",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["agente", "pausados"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.self_resolved_total",
|
||||
name: "Tickets resolvidos (meus)",
|
||||
description: "Total de tickets resolvidos pelo agente no período selecionado.",
|
||||
defaultTitle: "Resolvidos (meus)",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["agente", "resolvidos", "produtividade"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.self_sla_rate",
|
||||
name: "SLA cumprido (meus tickets)",
|
||||
description: "Percentual de tickets do agente resolvidos dentro do SLA no período.",
|
||||
defaultTitle: "SLA cumprido (meus)",
|
||||
recommendedWidget: "gauge",
|
||||
options: { valueFormatter: "percent" },
|
||||
keywords: ["agente", "sla", "qualidade"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.self_avg_resolution_minutes",
|
||||
name: "Tempo médio de resolução (meus)",
|
||||
description: "Tempo médio de resolução, em minutos, dos tickets atribuídos ao agente no período.",
|
||||
defaultTitle: "Tempo médio de resolução (meus)",
|
||||
recommendedWidget: "kpi",
|
||||
keywords: ["agente", "tempo médio", "resolução"],
|
||||
audience: "agent",
|
||||
},
|
||||
{
|
||||
key: "agents.team_overview",
|
||||
name: "Visão geral por agente",
|
||||
description: "Tabela com tickets abertos, pausados, resolvidos, SLA e tempo médio de resolução por agente.",
|
||||
defaultTitle: "Visão geral dos agentes",
|
||||
recommendedWidget: "table",
|
||||
columns: [
|
||||
{ field: "agentName", label: "Agente" },
|
||||
{ field: "open", label: "Abertos" },
|
||||
{ field: "paused", label: "Pausados" },
|
||||
{ field: "resolved", label: "Resolvidos" },
|
||||
{ field: "slaRate", label: "SLA (%)" },
|
||||
{ field: "avgResolutionMinutes", label: "Tempo médio (min)" },
|
||||
],
|
||||
keywords: ["agentes", "tabela", "produtividade"],
|
||||
audience: "admin",
|
||||
},
|
||||
{
|
||||
key: "agents.team_resolved_total",
|
||||
name: "Tickets resolvidos por agente",
|
||||
description: "Comparativo de tickets resolvidos por cada agente no período.",
|
||||
defaultTitle: "Resolvidos por agente",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "agentName",
|
||||
y: [{ field: "resolved", label: "Tickets resolvidos" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot" },
|
||||
keywords: ["agentes", "resolvidos", "ranking"],
|
||||
audience: "admin",
|
||||
},
|
||||
{
|
||||
key: "agents.team_sla_rate",
|
||||
name: "SLA por agente",
|
||||
description: "Percentual de cumprimento de SLA para cada agente no período.",
|
||||
defaultTitle: "SLA por agente",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "agentName",
|
||||
y: [{ field: "compliance", label: "SLA (%)" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
|
||||
keywords: ["agentes", "sla", "qualidade"],
|
||||
audience: "admin",
|
||||
},
|
||||
{
|
||||
key: "agents.team_avg_resolution_minutes",
|
||||
name: "Tempo médio por agente",
|
||||
description: "Tempo médio de resolução por agente no período.",
|
||||
defaultTitle: "Tempo médio por agente",
|
||||
recommendedWidget: "bar",
|
||||
encoding: {
|
||||
x: "agentName",
|
||||
y: [{ field: "minutes", label: "Minutos" }],
|
||||
},
|
||||
options: { legend: false, tooltip: true, indicator: "dot" },
|
||||
keywords: ["agentes", "tempo médio", "resolução"],
|
||||
audience: "admin",
|
||||
},
|
||||
{
|
||||
key: "devices.health_summary",
|
||||
name: "Saúde dos dispositivos",
|
||||
description: "Lista os dispositivos monitorados com status, consumo de recursos e alertas recentes.",
|
||||
defaultTitle: "Saúde dos dispositivos",
|
||||
recommendedWidget: "table",
|
||||
columns: [
|
||||
{ field: "hostname", label: "Hostname" },
|
||||
{ field: "status", label: "Status" },
|
||||
{ field: "cpuUsagePercent", label: "CPU (%)" },
|
||||
{ field: "memoryUsedPercent", label: "Memória (%)" },
|
||||
{ field: "diskUsedPercent", label: "Disco (%)" },
|
||||
{ field: "alerts", label: "Alertas" },
|
||||
],
|
||||
keywords: ["dispositivos", "hardware", "health"],
|
||||
audience: "admin",
|
||||
},
|
||||
]
|
||||
|
||||
function normalizeRole(role?: string | null) {
|
||||
return role?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
function canUseMetric(definition: DashboardMetricDefinition, role?: string | null) {
|
||||
const audience = definition.audience ?? "all"
|
||||
const normalizedRole = normalizeRole(role)
|
||||
if (audience === "all") return true
|
||||
if (audience === "agent") {
|
||||
return normalizedRole === "agent"
|
||||
}
|
||||
if (audience === "admin") {
|
||||
return normalizedRole === "admin" || normalizedRole === "manager"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function getMetricOptionsForRole(role?: string | null): SearchableComboboxOption[] {
|
||||
return DASHBOARD_METRIC_DEFINITIONS.filter((definition) => canUseMetric(definition, role)).map(
|
||||
(definition) => ({
|
||||
value: definition.key,
|
||||
label: definition.name,
|
||||
description: definition.description,
|
||||
keywords: definition.keywords,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getMetricDefinition(metricKey?: string | null): DashboardMetricDefinition | undefined {
|
||||
if (!metricKey) return undefined
|
||||
const normalized = metricKey.trim().toLowerCase()
|
||||
if (!normalized) return undefined
|
||||
return DASHBOARD_METRIC_DEFINITIONS.find((definition) => definition.key === normalized)
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ type PackedLayoutItem = {
|
|||
h: number
|
||||
minW?: number
|
||||
minH?: number
|
||||
maxW?: number
|
||||
maxH?: number
|
||||
static?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +39,8 @@ type CanvasItem = {
|
|||
layout: PackedLayoutItem
|
||||
minW?: number
|
||||
minH?: number
|
||||
maxW?: number
|
||||
maxH?: number
|
||||
static?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +63,8 @@ type InternalResizeState = {
|
|||
initialHeight: number
|
||||
minW: number
|
||||
minH: number
|
||||
maxW: number
|
||||
maxH: number
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMNS = 12
|
||||
|
|
@ -96,21 +102,31 @@ export function ReportCanvas({
|
|||
}
|
||||
}, [])
|
||||
|
||||
const columnWidth = containerWidth > 0 ? containerWidth / columns : 0
|
||||
const columnWidth = useMemo(() => {
|
||||
if (containerWidth <= 0 || columns <= 0) return 0
|
||||
const totalGap = gap * (columns - 1)
|
||||
const effective = containerWidth - totalGap
|
||||
return effective > 0 ? effective / columns : 0
|
||||
}, [containerWidth, columns, gap])
|
||||
const unitColumnWidth = useMemo(() => {
|
||||
if (columnWidth <= 0) return 0
|
||||
return columnWidth + (columns > 1 ? gap : 0)
|
||||
}, [columnWidth, columns, gap])
|
||||
const unitRowHeight = useMemo(() => rowHeight + gap, [rowHeight, gap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizing) return
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!resizing) return
|
||||
if (!columnWidth) return
|
||||
if (!unitColumnWidth || !unitRowHeight) return
|
||||
const deltaX = event.clientX - resizing.originX
|
||||
const deltaY = event.clientY - resizing.originY
|
||||
const deltaCols = Math.round(deltaX / columnWidth)
|
||||
const deltaRows = Math.round(deltaY / rowHeight)
|
||||
const deltaCols = Math.round(deltaX / unitColumnWidth)
|
||||
const deltaRows = Math.round(deltaY / unitRowHeight)
|
||||
let nextW = resizing.initialWidth + deltaCols
|
||||
let nextH = resizing.initialHeight + deltaRows
|
||||
nextW = Math.min(columns, Math.max(resizing.minW, nextW))
|
||||
nextH = Math.min(MAX_ROWS, Math.max(resizing.minH, nextH))
|
||||
nextW = Math.min(resizing.maxW, Math.max(resizing.minW, nextW))
|
||||
nextH = Math.min(resizing.maxH, Math.max(resizing.minH, nextH))
|
||||
const previous = lastResizeSizeRef.current
|
||||
if (!previous || previous.w !== nextW || previous.h !== nextH) {
|
||||
lastResizeSizeRef.current = { w: nextW, h: nextH }
|
||||
|
|
@ -119,8 +135,15 @@ export function ReportCanvas({
|
|||
}
|
||||
function handlePointerUp() {
|
||||
if (resizing) {
|
||||
const finalSize = lastResizeSizeRef.current ?? { w: resizing.initialWidth, h: resizing.initialHeight }
|
||||
onResize?.(resizing.key, finalSize, { commit: true })
|
||||
const finalSize = lastResizeSizeRef.current ?? {
|
||||
w: resizing.initialWidth,
|
||||
h: resizing.initialHeight,
|
||||
}
|
||||
const clampedFinal = {
|
||||
w: Math.min(resizing.maxW, Math.max(resizing.minW, finalSize.w)),
|
||||
h: Math.min(resizing.maxH, Math.max(resizing.minH, finalSize.h)),
|
||||
}
|
||||
onResize?.(resizing.key, clampedFinal, { commit: true })
|
||||
}
|
||||
setResizing(null)
|
||||
lastResizeSizeRef.current = null
|
||||
|
|
@ -133,7 +156,7 @@ export function ReportCanvas({
|
|||
window.removeEventListener("pointermove", handlePointerMove)
|
||||
window.removeEventListener("pointerup", handlePointerUp)
|
||||
}
|
||||
}, [columnWidth, onResize, resizing, rowHeight, columns])
|
||||
}, [columns, onResize, resizing, unitColumnWidth, unitRowHeight])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
|
|
@ -166,6 +189,8 @@ export function ReportCanvas({
|
|||
const layout = item.layout
|
||||
const minW = item.minW ?? layout.minW ?? 2
|
||||
const minH = item.minH ?? layout.minH ?? 2
|
||||
const maxW = item.maxW ?? layout.maxW ?? columns
|
||||
const maxH = item.maxH ?? layout.maxH ?? MAX_ROWS
|
||||
setResizing({
|
||||
key: item.key,
|
||||
originX: event.clientX,
|
||||
|
|
@ -174,6 +199,8 @@ export function ReportCanvas({
|
|||
initialHeight: layout.h,
|
||||
minW,
|
||||
minH,
|
||||
maxW,
|
||||
maxH,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -210,7 +237,7 @@ export function ReportCanvas({
|
|||
{editable && !item.static ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="absolute bottom-1 right-1 size-4 cursor-se-resize rounded-sm border border-border/60 bg-background/70 shadow ring-1 ring-border/40"
|
||||
className="absolute bottom-2 right-2 size-4 cursor-se-resize rounded-sm border border-border/60 bg-background/80 shadow ring-1 ring-border/40"
|
||||
onPointerDown={(event) => handleResizePointerDown(event, item)}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const numberFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits:
|
|||
const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maximumFractionDigits: 1 })
|
||||
|
||||
const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
|
||||
const DEFAULT_CHART_HEIGHT = 320
|
||||
|
||||
export type DashboardFilters = {
|
||||
range?: "7d" | "30d" | "90d" | "custom"
|
||||
|
|
@ -231,6 +232,34 @@ function parseNumeric(value: unknown): number | null {
|
|||
return null
|
||||
}
|
||||
|
||||
type ValueFormatterKind = "number" | "percent"
|
||||
|
||||
function formatMetricValue(value: unknown, format: ValueFormatterKind = "number") {
|
||||
const numeric = parseNumeric(value)
|
||||
if (numeric === null) return "—"
|
||||
return format === "percent" ? percentFormatter.format(numeric) : numberFormatter.format(numeric)
|
||||
}
|
||||
|
||||
function resolveChartOptions(
|
||||
config: WidgetConfig,
|
||||
defaults: { indicator: "line" | "dot" | "dashed" },
|
||||
): {
|
||||
showLegend: boolean
|
||||
showTooltip: boolean
|
||||
indicator: "line" | "dot" | "dashed"
|
||||
valueFormatter: ValueFormatterKind
|
||||
} {
|
||||
const options = (config.options ?? {}) as Record<string, unknown>
|
||||
const showLegend = options.legend !== false
|
||||
const showTooltip = options.tooltip !== false
|
||||
const indicator =
|
||||
typeof options.indicator === "string" && (options.indicator === "line" || options.indicator === "dot" || options.indicator === "dashed")
|
||||
? (options.indicator as "line" | "dot" | "dashed")
|
||||
: defaults.indicator
|
||||
const valueFormatter: ValueFormatterKind = options.valueFormatter === "percent" ? "percent" : "number"
|
||||
return { showLegend, showTooltip, indicator, valueFormatter }
|
||||
}
|
||||
|
||||
export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }: WidgetRendererProps) {
|
||||
const config = normalizeWidgetConfig(widget)
|
||||
const widgetType = (config.type ?? widget.type ?? "text").toLowerCase()
|
||||
|
|
@ -364,13 +393,17 @@ type WidgetCardProps = {
|
|||
|
||||
function WidgetCard({ title, description, children, isLoading }: WidgetCardProps) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<Card className="group flex h-full flex-col rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-slate-100 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg">
|
||||
<CardHeader className="flex-none pb-3">
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{isLoading ? <Skeleton className="h-[220px] w-full rounded-lg" /> : children}
|
||||
<CardContent className="flex-1 pb-4 pt-0">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-full min-h-[240px] w-full rounded-xl animate-pulse" />
|
||||
) : (
|
||||
<div className="h-full">{children}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
|
@ -396,17 +429,28 @@ function renderKpi({
|
|||
const delta = trendValue !== null ? value - trendValue : null
|
||||
const isTv = mode === "tv"
|
||||
return (
|
||||
<Card className={cn("h-full bg-gradient-to-br", isTv ? "from-primary/5 to-primary/10" : "from-muted/50 to-muted/30")}>
|
||||
<CardHeader className="pb-1">
|
||||
<Card
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-2xl border bg-gradient-to-br shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-xl",
|
||||
isTv ? "border-primary/50 from-primary/10 via-primary/5 to-primary/20" : "border-slate-200 from-white via-white to-slate-100"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="flex-none pb-1">
|
||||
<CardDescription className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{description ?? "Indicador chave"}
|
||||
</CardDescription>
|
||||
<CardTitle className={cn("text-2xl font-semibold", isTv ? "text-4xl" : "")}>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="flex-1 space-y-3">
|
||||
<div className={cn("font-semibold text-4xl", isTv ? "text-6xl" : "text-4xl")}>{numberFormatter.format(value)}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant={atRisk > 0 ? "destructive" : "outline"} className="rounded-full px-3 py-1 text-xs">
|
||||
<Badge
|
||||
variant={atRisk > 0 ? "destructive" : "secondary"}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-xs",
|
||||
atRisk > 0 ? "" : "border-none bg-white/70 text-foreground"
|
||||
)}
|
||||
>
|
||||
{atRisk > 0 ? `${numberFormatter.format(atRisk)} em risco` : "Todos no prazo"}
|
||||
</Badge>
|
||||
{delta !== null ? (
|
||||
|
|
@ -442,14 +486,23 @@ function renderBarChart({
|
|||
}
|
||||
return acc
|
||||
}, {})
|
||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
|
||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||
const yAxisTickFormatter =
|
||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||
const allowDecimals = valueFormatter === "percent"
|
||||
|
||||
return (
|
||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
{chartData.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full">
|
||||
<BarChart data={chartData}>
|
||||
<ChartContainer
|
||||
config={chartConfig as ChartConfig}
|
||||
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<BarChart data={chartData} accessibilityLayer>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
|
|
@ -459,8 +512,18 @@ function renderBarChart({
|
|||
minTickGap={24}
|
||||
tickFormatter={formatDateLabel}
|
||||
/>
|
||||
<YAxis allowDecimals={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
|
||||
{showTooltip ? (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
|
||||
/>
|
||||
) : null}
|
||||
{showLegend ? (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((serie, index) => (
|
||||
<Bar
|
||||
key={serie.field}
|
||||
|
|
@ -499,19 +562,37 @@ function renderLineChart({
|
|||
}
|
||||
return acc
|
||||
}, {})
|
||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
|
||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||
const allowDecimals = valueFormatter === "percent"
|
||||
const yAxisTickFormatter =
|
||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||
|
||||
return (
|
||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
{chartData.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full">
|
||||
<LineChart data={chartData}>
|
||||
<ChartContainer
|
||||
config={chartConfig as ChartConfig}
|
||||
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<LineChart data={chartData} accessibilityLayer>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xKey} tickFormatter={formatDateLabel} />
|
||||
<YAxis allowDecimals />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
|
||||
{showTooltip ? (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
|
||||
/>
|
||||
) : null}
|
||||
{showLegend ? (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((serie, index) => (
|
||||
<Line
|
||||
key={serie.field}
|
||||
|
|
@ -552,14 +633,23 @@ function renderAreaChart({
|
|||
}
|
||||
return acc
|
||||
}, {})
|
||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
|
||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||
const allowDecimals = valueFormatter === "percent"
|
||||
const yAxisTickFormatter =
|
||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||
|
||||
return (
|
||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
{chartData.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ChartContainer config={chartConfig as ChartConfig} className="h-[260px] w-full">
|
||||
<AreaChart data={chartData}>
|
||||
<ChartContainer
|
||||
config={chartConfig as ChartConfig}
|
||||
className="group/chart h-full w-full px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<AreaChart data={chartData} accessibilityLayer>
|
||||
<defs>
|
||||
{series.map((serie, index) => (
|
||||
<linearGradient key={serie.field} id={`fill-${serie.field}`} x1="0" y1="0" x2="0" y2="1">
|
||||
|
|
@ -570,9 +660,18 @@ function renderAreaChart({
|
|||
</defs>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey={xKey} tickFormatter={formatDateLabel} />
|
||||
<YAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<YAxis allowDecimals={allowDecimals} tickFormatter={yAxisTickFormatter} />
|
||||
{showTooltip ? (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
|
||||
/>
|
||||
) : null}
|
||||
{showLegend ? (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
|
||||
/>
|
||||
) : null}
|
||||
{series.map((serie, index) => (
|
||||
<Area
|
||||
key={serie.field}
|
||||
|
|
@ -606,6 +705,8 @@ function renderPieChart({
|
|||
const categoryKey = config.encoding?.category ?? "name"
|
||||
const valueKey = config.encoding?.value ?? "value"
|
||||
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
|
||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||
return (
|
||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
{chartData.length === 0 ? (
|
||||
|
|
@ -617,10 +718,16 @@ function renderPieChart({
|
|||
acc[key] = { label: key, color: CHART_COLORS[index % CHART_COLORS.length] }
|
||||
return acc
|
||||
}, {}) as ChartConfig}
|
||||
className="mx-auto aspect-square max-h-[240px]"
|
||||
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
{showTooltip ? (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
|
||||
/>
|
||||
) : null}
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey={valueKey}
|
||||
|
|
@ -633,6 +740,11 @@ function renderPieChart({
|
|||
<Cell key={index} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
{showLegend ? (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
|
||||
/>
|
||||
) : null}
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
|
|
@ -654,6 +766,8 @@ function renderRadarChart({
|
|||
const angleKey = config.encoding?.angle ?? "label"
|
||||
const radiusKey = config.encoding?.radius ?? "value"
|
||||
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
|
||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||
return (
|
||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
{chartData.length === 0 ? (
|
||||
|
|
@ -661,13 +775,24 @@ function renderRadarChart({
|
|||
) : (
|
||||
<ChartContainer
|
||||
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
|
||||
className="mx-auto aspect-square max-h-[260px]"
|
||||
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<RadarChart data={chartData}>
|
||||
<RadarChart data={chartData} accessibilityLayer>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey={angleKey} />
|
||||
<PolarRadiusAxis />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{showTooltip ? (
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator={indicator} valueFormatter={(value) => tooltipValueFormatter(value)} />}
|
||||
/>
|
||||
) : null}
|
||||
{showLegend ? (
|
||||
<ChartLegend
|
||||
content={<ChartLegendContent className="flex flex-wrap justify-center gap-3 px-2 pb-1" />}
|
||||
/>
|
||||
) : null}
|
||||
<Radar
|
||||
dataKey={radiusKey}
|
||||
stroke="var(--chart-1)"
|
||||
|
|
@ -699,7 +824,8 @@ function renderGauge({
|
|||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||
<ChartContainer
|
||||
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
|
||||
className="mx-auto aspect-square max-h-[240px]"
|
||||
className="group/chart flex h-full w-full items-center justify-center px-2 pb-4 [&_.recharts-legend-wrapper]:px-2 [&_.recharts-legend-wrapper]:pb-1 [&_.recharts-default-legend]:flex [&_.recharts-default-legend]:flex-wrap [&_.recharts-default-legend]:justify-center"
|
||||
style={{ minHeight: DEFAULT_CHART_HEIGHT }}
|
||||
>
|
||||
<RadialBarChart
|
||||
startAngle={180}
|
||||
|
|
@ -708,7 +834,7 @@ function renderGauge({
|
|||
outerRadius={110}
|
||||
data={[{ name: "SLA", value: display }]}
|
||||
>
|
||||
<RadialBar background dataKey="value" cornerRadius={5} fill="var(--color-value)" />
|
||||
<RadialBar background dataKey="value" cornerRadius={5} fill="var(--color-value)" />
|
||||
<RechartsTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel valueFormatter={(val) => percentFormatter.format(Number(val ?? 0))} />}
|
||||
|
|
@ -760,27 +886,29 @@ function renderTable({
|
|||
{rows.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.field}>{column.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
<div className="flex h-full min-h-[260px] flex-col overflow-hidden rounded-xl border border-border/60 bg-white/80">
|
||||
<div className="max-h-[360px] overflow-auto">
|
||||
<Table className="min-w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.field}>
|
||||
{renderTableCellValue(row[column.field as keyof typeof row])}
|
||||
</TableCell>
|
||||
<TableHead key={column.field}>{column.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => (
|
||||
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.field}>
|
||||
{renderTableCellValue(row[column.field as keyof typeof row])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
|
@ -833,16 +961,21 @@ function renderText({
|
|||
allowedAttributes: { span: ["style"] },
|
||||
})
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Card className="group flex h-full flex-col rounded-2xl border border-border/60 bg-gradient-to-br from-white via-white to-slate-100 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg">
|
||||
<CardHeader className="flex-none">
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="prose max-w-none text-sm leading-relaxed text-muted-foreground [&_ul]:list-disc [&_ol]:list-decimal"
|
||||
dangerouslySetInnerHTML={{ __html: sanitized || "<p>Adicione conteúdo informativo para contextualizar os dados.</p>" }}
|
||||
/>
|
||||
<CardContent className="flex-1 pt-0">
|
||||
<div className="prose max-w-none rounded-xl bg-white/70 p-4 text-sm leading-relaxed text-muted-foreground shadow-inner transition group-hover:bg-white [&_ol]:list-decimal [&_ul]:list-disc">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
sanitized ||
|
||||
"<p>Adicione destaques, insights ou instruções para contextualizar os dados apresentados.</p>",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
|
@ -850,8 +983,8 @@ function renderText({
|
|||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-[220px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border/60 text-sm text-muted-foreground">
|
||||
Sem dados para os filtros selecionados.
|
||||
<div className="flex h-full min-h-[160px] w-full flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-100 px-6 text-center text-sm font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-200/70">
|
||||
Sem dados disponíveis para os filtros atuais.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
|
@ -15,7 +15,7 @@ interface SiteHeaderProps {
|
|||
primaryAlignment?: "right" | "center"
|
||||
}
|
||||
|
||||
export function SiteHeader({
|
||||
function SiteHeaderBase({
|
||||
title,
|
||||
lead,
|
||||
primaryAction,
|
||||
|
|
@ -47,7 +47,7 @@ export function SiteHeaderPrimaryButton({
|
|||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
}: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button size="sm" className={cn("w-full sm:w-auto", className)} {...props}>
|
||||
{children}
|
||||
|
|
@ -59,7 +59,7 @@ export function SiteHeaderSecondaryButton({
|
|||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
}: ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button size="sm" variant="outline" className={cn("w-full sm:w-auto", className)} {...props}>
|
||||
{children}
|
||||
|
|
@ -67,6 +67,12 @@ export function SiteHeaderSecondaryButton({
|
|||
)
|
||||
}
|
||||
|
||||
// Backward compatibility: attach as static members (client-only usage)
|
||||
;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton
|
||||
;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton
|
||||
type SiteHeaderComponent = ((props: SiteHeaderProps) => JSX.Element) & {
|
||||
PrimaryButton: typeof SiteHeaderPrimaryButton
|
||||
SecondaryButton: typeof SiteHeaderSecondaryButton
|
||||
}
|
||||
|
||||
export const SiteHeader: SiteHeaderComponent = Object.assign(SiteHeaderBase, {
|
||||
PrimaryButton: SiteHeaderPrimaryButton,
|
||||
SecondaryButton: SiteHeaderSecondaryButton,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ type SearchableComboboxProps = {
|
|||
clearLabel?: string
|
||||
renderValue?: (option: SearchableComboboxOption | null) => React.ReactNode
|
||||
renderOption?: (option: SearchableComboboxOption, active: boolean) => React.ReactNode
|
||||
contentClassName?: string
|
||||
scrollClassName?: string
|
||||
scrollProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function SearchableCombobox({
|
||||
|
|
@ -46,6 +49,9 @@ export function SearchableCombobox({
|
|||
clearLabel = "Limpar seleção",
|
||||
renderValue,
|
||||
renderOption,
|
||||
contentClassName,
|
||||
scrollClassName,
|
||||
scrollProps,
|
||||
}: SearchableComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
|
@ -103,7 +109,7 @@ export function SearchableCombobox({
|
|||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="z-50 w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<PopoverContent className={cn("z-50 w-[var(--radix-popover-trigger-width)] max-w-[480px] p-0", contentClassName)}>
|
||||
<div className="border-b border-border/80 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
|
|
@ -132,7 +138,10 @@ export function SearchableCombobox({
|
|||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
<ScrollArea className="max-h-60">
|
||||
<ScrollArea
|
||||
className={cn("max-h-60", scrollClassName)}
|
||||
{...scrollProps}
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground">{emptyText}</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -101,10 +101,24 @@ function isMissingProvisioningCodeColumn(error: unknown): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
function isMissingCompanyTable(error: unknown): boolean {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2021") return true
|
||||
const message = error.message.toLowerCase()
|
||||
if (message.includes("table") && message.includes("company") && message.includes("does not exist")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function safeCompanyFindMany(args: Prisma.CompanyFindManyArgs): Promise<Company[]> {
|
||||
try {
|
||||
return await prisma.company.findMany(args)
|
||||
} catch (error) {
|
||||
if (isMissingCompanyTable(error)) {
|
||||
return []
|
||||
}
|
||||
if (!isMissingProvisioningCodeColumn(error)) {
|
||||
throw error
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue