feat: add queue summary widget and layout fixes
This commit is contained in:
parent
f7976e2c39
commit
a542846313
12 changed files with 350 additions and 45 deletions
|
|
@ -13,6 +13,7 @@ const WIDGET_TYPES = [
|
||||||
"radar",
|
"radar",
|
||||||
"gauge",
|
"gauge",
|
||||||
"table",
|
"table",
|
||||||
|
"queue-summary",
|
||||||
"text",
|
"text",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|
@ -145,6 +146,12 @@ function normalizeWidgetConfig(type: WidgetType, config: unknown) {
|
||||||
],
|
],
|
||||||
options: { downloadCSV: true },
|
options: { downloadCSV: true },
|
||||||
}
|
}
|
||||||
|
case "queue-summary":
|
||||||
|
return {
|
||||||
|
type: "queue-summary",
|
||||||
|
title: "Resumo por fila",
|
||||||
|
dataSource: { metricKey: "queues.summary_cards" },
|
||||||
|
}
|
||||||
case "text":
|
case "text":
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,24 @@ function filterTicketsByQueue<T extends { queueId?: Id<"queues"> | null }>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QUEUE_RENAME_LOOKUP: Record<string, string> = {
|
||||||
|
"Suporte N1": "Chamados",
|
||||||
|
"suporte-n1": "Chamados",
|
||||||
|
chamados: "Chamados",
|
||||||
|
"Suporte N2": "Laboratório",
|
||||||
|
"suporte-n2": "Laboratório",
|
||||||
|
laboratorio: "Laboratório",
|
||||||
|
Laboratorio: "Laboratório",
|
||||||
|
visitas: "Visitas",
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameQueueName(value: string) {
|
||||||
|
const direct = QUEUE_RENAME_LOOKUP[value]
|
||||||
|
if (direct) return direct
|
||||||
|
const normalizedKey = value.replace(/\s+/g, "-").toLowerCase()
|
||||||
|
return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value
|
||||||
|
}
|
||||||
|
|
||||||
type AgentStatsRaw = {
|
type AgentStatsRaw = {
|
||||||
agentId: Id<"users">
|
agentId: Id<"users">
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -441,6 +459,97 @@ const metricResolvers: Record<string, MetricResolver> = {
|
||||||
data,
|
data,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"queues.summary_cards": async (ctx, { tenantId, viewer, params }) => {
|
||||||
|
const queueFilter = parseQueueIds(params)
|
||||||
|
const filterHas = queueFilter && queueFilter.length > 0
|
||||||
|
const normalizeKey = (id: Id<"queues"> | null) => (id ? String(id) : "sem-fila")
|
||||||
|
|
||||||
|
const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect()
|
||||||
|
const queueNameMap = new Map<string, string>()
|
||||||
|
queues.forEach((queue) => {
|
||||||
|
const key = String(queue._id)
|
||||||
|
queueNameMap.set(key, renameQueueName(queue.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const stats = new Map<
|
||||||
|
string,
|
||||||
|
{ id: string; name: string; pending: number; inProgress: number; paused: number; breached: number }
|
||||||
|
>()
|
||||||
|
|
||||||
|
const ensureEntry = (key: string, fallbackName?: string) => {
|
||||||
|
if (!stats.has(key)) {
|
||||||
|
const resolvedName =
|
||||||
|
queueNameMap.get(key) ??
|
||||||
|
(key === "sem-fila" ? "Sem fila" : fallbackName ?? "Fila desconhecida")
|
||||||
|
stats.set(key, {
|
||||||
|
id: key,
|
||||||
|
name: resolvedName,
|
||||||
|
pending: 0,
|
||||||
|
inProgress: 0,
|
||||||
|
paused: 0,
|
||||||
|
breached: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stats.get(key)!
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const queue of queues) {
|
||||||
|
const key = String(queue._id)
|
||||||
|
if (filterHas && queueFilter && !queueFilter.includes(key)) continue
|
||||||
|
ensureEntry(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||||
|
for (const ticket of scopedTickets) {
|
||||||
|
const key = normalizeKey(ticket.queueId ?? null)
|
||||||
|
if (filterHas && queueFilter && !queueFilter.includes(key)) continue
|
||||||
|
const entry = ensureEntry(key)
|
||||||
|
const status = normalizeStatus(ticket.status)
|
||||||
|
if (status === "PENDING") {
|
||||||
|
entry.pending += 1
|
||||||
|
} else if (status === "AWAITING_ATTENDANCE") {
|
||||||
|
entry.inProgress += 1
|
||||||
|
} else if (status === "PAUSED") {
|
||||||
|
entry.paused += 1
|
||||||
|
}
|
||||||
|
if (status !== "RESOLVED") {
|
||||||
|
const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null
|
||||||
|
if (dueAt && dueAt < now) {
|
||||||
|
entry.breached += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(filterHas && queueFilter && !queueFilter.includes("sem-fila"))) {
|
||||||
|
ensureEntry("sem-fila", "Sem fila")
|
||||||
|
} else if (filterHas) {
|
||||||
|
stats.delete("sem-fila")
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Array.from(stats.values()).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
pending: item.pending,
|
||||||
|
inProgress: item.inProgress,
|
||||||
|
paused: item.paused,
|
||||||
|
breached: item.breached,
|
||||||
|
}))
|
||||||
|
|
||||||
|
data.sort((a, b) => {
|
||||||
|
const totalA = a.pending + a.inProgress + a.paused
|
||||||
|
const totalB = b.pending + b.inProgress + b.paused
|
||||||
|
if (totalA === totalB) {
|
||||||
|
return a.name.localeCompare(b.name, "pt-BR")
|
||||||
|
}
|
||||||
|
return totalB - totalA
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: { kind: "collection", key: "queues.summary_cards" },
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
},
|
||||||
"tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => {
|
"tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => {
|
||||||
const rangeDays = parseRange(params)
|
const rangeDays = parseRange(params)
|
||||||
const companyId = parseCompanyId(params)
|
const companyId = parseCompanyId(params)
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,29 @@ function areColumnConfigsEqual(a: DeviceInventoryColumnConfig[], b: DeviceInvent
|
||||||
return a.every((col, idx) => col.key === b[idx]?.key && (col.label ?? "") === (b[idx]?.label ?? ""))
|
return a.every((col, idx) => col.key === b[idx]?.key && (col.label ?? "") === (b[idx]?.label ?? ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDeviceCustomFieldDisplay(entry?: { value?: unknown; displayValue?: string }): string {
|
||||||
|
if (!entry) return "—"
|
||||||
|
if (typeof entry.displayValue === "string" && entry.displayValue.trim().length > 0) {
|
||||||
|
return entry.displayValue
|
||||||
|
}
|
||||||
|
const raw = entry.value
|
||||||
|
if (raw === null || raw === undefined) return "—"
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const values = raw
|
||||||
|
.map((item) => (item === null || item === undefined ? "" : String(item).trim()))
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
return values.length > 0 ? values.join(", ") : "—"
|
||||||
|
}
|
||||||
|
if (typeof raw === "boolean") {
|
||||||
|
return raw ? "Sim" : "Não"
|
||||||
|
}
|
||||||
|
if (typeof raw === "number") {
|
||||||
|
return Number.isFinite(raw) ? String(raw) : "—"
|
||||||
|
}
|
||||||
|
const asString = String(raw).trim()
|
||||||
|
return asString.length > 0 ? asString : "—"
|
||||||
|
}
|
||||||
|
|
||||||
type DeviceAlertEntry = {
|
type DeviceAlertEntry = {
|
||||||
id: string
|
id: string
|
||||||
kind: string
|
kind: string
|
||||||
|
|
@ -886,7 +909,7 @@ export type DevicesQueryItem = {
|
||||||
lastPostureAt?: number | null
|
lastPostureAt?: number | null
|
||||||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||||
remoteAccessEntries: DeviceRemoteAccessEntry[]
|
remoteAccessEntries: DeviceRemoteAccessEntry[]
|
||||||
customFields?: Array<{ fieldKey: string; label: string; type?: string; value: unknown; displayValue?: string }>
|
customFields?: Array<{ fieldId?: string; fieldKey: string; label: string; type?: string; value: unknown; displayValue?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDeviceItem(raw: Record<string, unknown>): DevicesQueryItem {
|
export function normalizeDeviceItem(raw: Record<string, unknown>): DevicesQueryItem {
|
||||||
|
|
@ -3421,6 +3444,49 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
[deviceFieldDefs]
|
[deviceFieldDefs]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const displayCustomFields = useMemo(() => {
|
||||||
|
const definitions = deviceFieldDefs ?? []
|
||||||
|
const values = device?.customFields ?? []
|
||||||
|
const result: Array<{ key: string; label: string; value: string }> = []
|
||||||
|
const valueMap = new Map<string, (typeof values)[number]>()
|
||||||
|
|
||||||
|
values.forEach((field) => {
|
||||||
|
if (field.fieldId) {
|
||||||
|
valueMap.set(String(field.fieldId), field)
|
||||||
|
}
|
||||||
|
if (field.fieldKey) {
|
||||||
|
valueMap.set(field.fieldKey, field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const used = new Set<string>()
|
||||||
|
definitions.forEach((definition) => {
|
||||||
|
const idKey = String(definition.id)
|
||||||
|
const valueEntry = valueMap.get(idKey) ?? valueMap.get(definition.key)
|
||||||
|
used.add(idKey)
|
||||||
|
result.push({
|
||||||
|
key: idKey,
|
||||||
|
label: definition.label,
|
||||||
|
value: formatDeviceCustomFieldDisplay(valueEntry),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
values.forEach((field) => {
|
||||||
|
const idKey = field.fieldId ? String(field.fieldId) : undefined
|
||||||
|
const keyKey = field.fieldKey ?? field.label
|
||||||
|
const compositeKey = idKey ?? keyKey
|
||||||
|
if (!compositeKey || used.has(compositeKey)) return
|
||||||
|
used.add(compositeKey)
|
||||||
|
result.push({
|
||||||
|
key: compositeKey,
|
||||||
|
label: field.label,
|
||||||
|
value: formatDeviceCustomFieldDisplay(field),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [deviceFieldDefs, device?.customFields])
|
||||||
|
|
||||||
const handleSaveCustomFields = useCallback(async () => {
|
const handleSaveCustomFields = useCallback(async () => {
|
||||||
if (!device || !convexUserId) return
|
if (!device || !convexUserId) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -3554,10 +3620,10 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
|
||||||
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
|
||||||
{(device.customFields ?? []).length}
|
{displayCustomFields.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{(!device.customFields || device.customFields.length === 0) ? (
|
{displayCustomFields.length === 0 ? (
|
||||||
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
|
<p className="text-xs text-neutral-500">Nenhum campo personalizado definido para este dispositivo.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3575,12 +3641,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{device.customFields && device.customFields.length > 0 ? (
|
{displayCustomFields.length > 0 ? (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{(device.customFields ?? []).map((f, idx) => (
|
{displayCustomFields.map((field) => (
|
||||||
<div key={`${f.fieldKey}-${idx}`} className="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
<div key={field.key} className="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{f.label}</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{field.label}</p>
|
||||||
<p className="mt-1 text-neutral-800">{(f.displayValue ?? f.value ?? "—") as string}</p>
|
<p className="mt-1 text-neutral-800">{field.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -194,13 +194,16 @@ export function DeviceCustomFieldManager({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{triggerButton}
|
{triggerButton}
|
||||||
<Dialog open={open} onOpenChange={(value) => {
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => {
|
||||||
setOpen(value)
|
setOpen(value)
|
||||||
if (!value) {
|
if (!value) {
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
}}>
|
}}
|
||||||
<DialogContent className="max-w-3xl space-y-5">
|
>
|
||||||
|
<DialogContent className="max-w-4xl space-y-5">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Campos personalizados de dispositivos</DialogTitle>
|
<DialogTitle>Campos personalizados de dispositivos</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|
@ -209,7 +212,7 @@ export function DeviceCustomFieldManager({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] xl:grid-cols-[1.6fr_1fr]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Campos cadastrados</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Campos cadastrados</h3>
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,7 @@ const WIDGET_LIBRARY: Array<{
|
||||||
{ type: "radar", title: "Gráfico radar", description: "Comparação radial entre dimensões de performance." },
|
{ 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: "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: "table", title: "Tabela dinâmica", description: "Lista tabular com cabeçalhos personalizáveis e ordenação." },
|
||||||
|
{ type: "queue-summary", title: "Resumo por fila", description: "Cards com pendências, andamento e SLA por fila." },
|
||||||
{ type: "text", title: "Bloco de texto", description: "Destaques, insights ou instruções em rich-text." },
|
{ type: "text", title: "Bloco de texto", description: "Destaques, insights ou instruções em rich-text." },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -230,6 +231,7 @@ const widgetSizePresets: Record<
|
||||||
radar: { 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 } },
|
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 } },
|
table: { default: { w: 8, h: 8 }, min: { w: 6, h: 5 }, max: { w: 12, h: 12 } },
|
||||||
|
"queue-summary": { default: { w: 12, h: 6 }, min: { w: 8, h: 4 }, max: { w: 12, h: 9 } },
|
||||||
text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } },
|
text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export type DashboardMetricDefinition = {
|
||||||
| "radar"
|
| "radar"
|
||||||
| "gauge"
|
| "gauge"
|
||||||
| "table"
|
| "table"
|
||||||
|
| "queue-summary"
|
||||||
| "text"
|
| "text"
|
||||||
keywords?: string[]
|
keywords?: string[]
|
||||||
encoding?: MetricEncoding
|
encoding?: MetricEncoding
|
||||||
|
|
@ -116,6 +117,15 @@ export const DASHBOARD_METRIC_DEFINITIONS: DashboardMetricDefinition[] = [
|
||||||
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
|
options: { legend: false, tooltip: true, indicator: "dot", valueFormatter: "percent" },
|
||||||
keywords: ["sla", "fila", "percentual", "qualidade"],
|
keywords: ["sla", "fila", "percentual", "qualidade"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "queues.summary_cards",
|
||||||
|
name: "Resumo por fila (cards)",
|
||||||
|
description: "Resumo visual com pendências, andamento e violações de SLA por fila.",
|
||||||
|
defaultTitle: "Resumo das filas",
|
||||||
|
recommendedWidget: "queue-summary",
|
||||||
|
keywords: ["fila", "cards", "pendências", "sla"],
|
||||||
|
audience: "admin",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "tickets.sla_rate",
|
key: "tickets.sla_rate",
|
||||||
name: "Taxa geral de cumprimento de SLA",
|
name: "Taxa geral de cumprimento de SLA",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -59,12 +60,14 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
|
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
|
||||||
|
|
||||||
const numberFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 })
|
const numberFormatter = new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 1 })
|
||||||
const percentFormatter = new Intl.NumberFormat("pt-BR", { style: "percent", maximumFractionDigits: 1 })
|
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 CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]
|
||||||
const DEFAULT_CHART_HEIGHT = 320
|
const DEFAULT_CHART_HEIGHT = 320
|
||||||
|
const PRESENTATION_CHART_HEIGHT = 420
|
||||||
|
|
||||||
export type DashboardFilters = {
|
export type DashboardFilters = {
|
||||||
range?: "7d" | "30d" | "90d" | "custom"
|
range?: "7d" | "30d" | "90d" | "custom"
|
||||||
|
|
@ -330,6 +333,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "line":
|
case "line":
|
||||||
return renderLineChart({
|
return renderLineChart({
|
||||||
|
|
@ -337,6 +341,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "area":
|
case "area":
|
||||||
return renderAreaChart({
|
return renderAreaChart({
|
||||||
|
|
@ -344,6 +349,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "pie":
|
case "pie":
|
||||||
return renderPieChart({
|
return renderPieChart({
|
||||||
|
|
@ -351,6 +357,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "radar":
|
case "radar":
|
||||||
return renderRadarChart({
|
return renderRadarChart({
|
||||||
|
|
@ -358,6 +365,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "gauge":
|
case "gauge":
|
||||||
return renderGauge({
|
return renderGauge({
|
||||||
|
|
@ -365,6 +373,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
})
|
})
|
||||||
case "table":
|
case "table":
|
||||||
return renderTable({
|
return renderTable({
|
||||||
|
|
@ -373,6 +382,14 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange }
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
case "queue-summary":
|
||||||
|
return renderQueueSummary({
|
||||||
|
title: resolvedTitle,
|
||||||
|
description,
|
||||||
|
metric,
|
||||||
|
isLoading,
|
||||||
})
|
})
|
||||||
case "text":
|
case "text":
|
||||||
default:
|
default:
|
||||||
|
|
@ -472,11 +489,13 @@ function renderBarChart({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const xKey = config.encoding?.x ?? "date"
|
const xKey = config.encoding?.x ?? "date"
|
||||||
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
||||||
|
|
@ -493,6 +512,8 @@ function renderBarChart({
|
||||||
const yAxisTickFormatter =
|
const yAxisTickFormatter =
|
||||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||||
const allowDecimals = valueFormatter === "percent"
|
const allowDecimals = valueFormatter === "percent"
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -502,7 +523,7 @@ function renderBarChart({
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<BarChart data={chartData} accessibilityLayer>
|
<BarChart data={chartData} accessibilityLayer>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
|
|
@ -548,11 +569,13 @@ function renderLineChart({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const xKey = config.encoding?.x ?? "date"
|
const xKey = config.encoding?.x ?? "date"
|
||||||
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
||||||
|
|
@ -569,6 +592,8 @@ function renderLineChart({
|
||||||
const allowDecimals = valueFormatter === "percent"
|
const allowDecimals = valueFormatter === "percent"
|
||||||
const yAxisTickFormatter =
|
const yAxisTickFormatter =
|
||||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -578,7 +603,7 @@ function renderLineChart({
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<LineChart data={chartData} accessibilityLayer>
|
<LineChart data={chartData} accessibilityLayer>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
|
@ -618,11 +643,13 @@ function renderAreaChart({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const xKey = config.encoding?.x ?? "date"
|
const xKey = config.encoding?.x ?? "date"
|
||||||
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
const series = Array.isArray(config.encoding?.y) ? config.encoding?.y ?? [] : []
|
||||||
|
|
@ -640,6 +667,8 @@ function renderAreaChart({
|
||||||
const allowDecimals = valueFormatter === "percent"
|
const allowDecimals = valueFormatter === "percent"
|
||||||
const yAxisTickFormatter =
|
const yAxisTickFormatter =
|
||||||
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
valueFormatter === "percent" ? (value: number) => percentFormatter.format(value) : undefined
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
|
|
@ -649,7 +678,7 @@ function renderAreaChart({
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig as ChartConfig}
|
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<AreaChart data={chartData} accessibilityLayer>
|
<AreaChart data={chartData} accessibilityLayer>
|
||||||
<defs>
|
<defs>
|
||||||
|
|
@ -698,17 +727,21 @@ function renderPieChart({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const categoryKey = config.encoding?.category ?? "name"
|
const categoryKey = config.encoding?.category ?? "name"
|
||||||
const valueKey = config.encoding?.value ?? "value"
|
const valueKey = config.encoding?.value ?? "value"
|
||||||
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
||||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
|
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "dot" })
|
||||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
|
|
@ -721,7 +754,7 @@ function renderPieChart({
|
||||||
return acc
|
return acc
|
||||||
}, {}) as ChartConfig}
|
}, {}) as ChartConfig}
|
||||||
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
{showTooltip ? (
|
{showTooltip ? (
|
||||||
|
|
@ -759,17 +792,21 @@ function renderRadarChart({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const angleKey = config.encoding?.angle ?? "label"
|
const angleKey = config.encoding?.angle ?? "label"
|
||||||
const radiusKey = config.encoding?.radius ?? "value"
|
const radiusKey = config.encoding?.radius ?? "value"
|
||||||
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
const chartData = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
||||||
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
|
const { showLegend, showTooltip, indicator, valueFormatter } = resolveChartOptions(config, { indicator: "line" })
|
||||||
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
const tooltipValueFormatter = (value: unknown) => formatMetricValue(value, valueFormatter)
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
{chartData.length === 0 ? (
|
{chartData.length === 0 ? (
|
||||||
|
|
@ -778,7 +815,7 @@ function renderRadarChart({
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
|
config={{ [radiusKey]: { label: radiusKey, color: "var(--chart-1)" } }}
|
||||||
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<RadarChart data={chartData} accessibilityLayer>
|
<RadarChart data={chartData} accessibilityLayer>
|
||||||
<PolarGrid />
|
<PolarGrid />
|
||||||
|
|
@ -813,21 +850,25 @@ function renderGauge({
|
||||||
description,
|
description,
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const raw = metric.data as { value?: number; total?: number; resolved?: number } | null
|
const raw = metric.data as { value?: number; total?: number; resolved?: number } | null
|
||||||
const value = parseNumeric(raw?.value) ?? 0
|
const value = parseNumeric(raw?.value) ?? 0
|
||||||
const display = Math.max(0, Math.min(1, value))
|
const display = Math.max(0, Math.min(1, value))
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const minHeight = isPresentation ? PRESENTATION_CHART_HEIGHT : DEFAULT_CHART_HEIGHT
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
<WidgetCard title={title} description={description} isLoading={metric.isLoading}>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
|
config={{ value: { label: "SLA", color: "var(--chart-1)" } }}
|
||||||
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"
|
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 }}
|
style={{ minHeight, height: "100%" }}
|
||||||
>
|
>
|
||||||
<RadialBarChart
|
<RadialBarChart
|
||||||
startAngle={180}
|
startAngle={180}
|
||||||
|
|
@ -868,12 +909,14 @@ function renderTable({
|
||||||
metric,
|
metric,
|
||||||
config,
|
config,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
mode,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
metric: MetricResult
|
metric: MetricResult
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
|
mode: WidgetRendererProps["mode"]
|
||||||
}) {
|
}) {
|
||||||
const columns = Array.isArray(config.columns) && config.columns.length > 0
|
const columns = Array.isArray(config.columns) && config.columns.length > 0
|
||||||
? config.columns
|
? config.columns
|
||||||
|
|
@ -883,29 +926,43 @@ function renderTable({
|
||||||
{ field: "updatedAt", label: "Atualizado em" },
|
{ field: "updatedAt", label: "Atualizado em" },
|
||||||
]
|
]
|
||||||
const rows = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
const rows = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
|
||||||
|
const isPresentation = mode === "tv" || mode === "print"
|
||||||
|
const containerClass = cn(
|
||||||
|
"flex h-full flex-col overflow-hidden rounded-xl border border-border/60 bg-white/80",
|
||||||
|
isPresentation ? "min-h-[320px]" : "min-h-[260px]",
|
||||||
|
)
|
||||||
|
const scrollClass = cn(
|
||||||
|
"overflow-x-hidden overflow-y-auto",
|
||||||
|
isPresentation ? "max-h-none" : "max-h-[360px]",
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<WidgetCard title={title} description={description} isLoading={isLoading}>
|
<WidgetCard title={title} description={description} isLoading={isLoading}>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full min-h-[260px] flex-col overflow-hidden rounded-xl border border-border/60 bg-white/80">
|
<div className={containerClass}>
|
||||||
<div className="max-h-[360px] overflow-auto">
|
<div className={scrollClass}>
|
||||||
<Table className="min-w-full">
|
<Table className="min-w-full table-fixed">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableHead key={column.field}>{column.label}</TableHead>
|
<TableHead key={column.field} className="whitespace-nowrap">
|
||||||
|
{column.label}
|
||||||
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
|
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
|
||||||
{columns.map((column) => (
|
{columns.map((column) => {
|
||||||
<TableCell key={column.field}>
|
const cellValue = row[column.field as keyof typeof row]
|
||||||
{renderTableCellValue(row[column.field as keyof typeof row])}
|
return (
|
||||||
|
<TableCell key={column.field} className="whitespace-normal break-words">
|
||||||
|
{renderTableCellValue(cellValue)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -917,6 +974,31 @@ function renderTable({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderQueueSummary({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
metric,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
metric: MetricResult
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
const queues = Array.isArray(metric.data) ? (metric.data as TicketQueueSummary[]) : []
|
||||||
|
return (
|
||||||
|
<WidgetCard title={title} description={description} isLoading={isLoading}>
|
||||||
|
{queues.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="pb-1">
|
||||||
|
<TicketQueueSummaryCards queues={queues} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function renderTableCellValue(value: unknown) {
|
function renderTableCellValue(value: unknown) {
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return numberFormatter.format(value)
|
return numberFormatter.format(value)
|
||||||
|
|
|
||||||
|
|
@ -174,9 +174,10 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
|
||||||
|
|
||||||
type TicketCustomFieldsSectionProps = {
|
type TicketCustomFieldsSectionProps = {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
hidePreview?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
|
export function TicketCustomFieldsSection({ ticket, hidePreview = false }: TicketCustomFieldsSectionProps) {
|
||||||
const { convexUserId, role } = useAuth()
|
const { convexUserId, role } = useAuth()
|
||||||
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
||||||
|
|
||||||
|
|
@ -318,10 +319,14 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{hidePreview ? (
|
||||||
|
<p className="text-xs text-neutral-500">Visualize os valores no resumo principal.</p>
|
||||||
|
) : (
|
||||||
<TicketCustomFieldsList
|
<TicketCustomFieldsList
|
||||||
record={ticket.customFields}
|
record={ticket.customFields}
|
||||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||||
<DialogContent className="max-w-3xl gap-4">
|
<DialogContent className="max-w-3xl gap-4">
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<TicketCustomFieldsSection ticket={ticket} />
|
<TicketCustomFieldsSection ticket={ticket} hidePreview />
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ interface TicketQueueSummaryProps {
|
||||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
const { convexUserId, isStaff } = useAuth()
|
const { convexUserId, isStaff } = useAuth()
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
|
const shouldFetch = Boolean(!queues && enabled)
|
||||||
const fromServer = useQuery(
|
const fromServer = useQuery(
|
||||||
api.queues.summary,
|
api.queues.summary,
|
||||||
enabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
shouldFetch ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as TicketQueueSummary[] | undefined
|
) as TicketQueueSummary[] | undefined
|
||||||
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
|
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
|
||||||
|
|
||||||
if (!queues && fromServer === undefined) {
|
if (!queues && shouldFetch && fromServer === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||||
|
import { mapTicketCustomFields } from "@/lib/ticket-custom-fields"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -213,6 +214,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
queuesEnabled ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
)
|
)
|
||||||
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
|
const queues: TicketQueueSummary[] = Array.isArray(queuesResult) ? queuesResult : []
|
||||||
|
const customFieldEntries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
|
||||||
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
|
||||||
const workSummaryRemote = useQuery(
|
const workSummaryRemote = useQuery(
|
||||||
api.tickets.workSummary,
|
api.tickets.workSummary,
|
||||||
|
|
@ -1583,6 +1585,24 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<span className={sectionLabelClass}>Informações adicionais</span>
|
||||||
|
{customFieldEntries.length > 0 ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{customFieldEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.key}
|
||||||
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{entry.label}</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-neutral-900">{entry.formattedValue}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">Nenhum campo adicional preenchido para este chamado.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
|
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ function ChartContainer({
|
||||||
data-slot="chart"
|
data-slot="chart"
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex h-full w-full justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue