feat: padroniza tickets recentes nos dashboards

This commit is contained in:
Esdras Renan 2025-11-07 14:22:14 -03:00
parent 4655c7570a
commit 4079f67fcb
7 changed files with 184 additions and 35 deletions

View file

@ -160,10 +160,9 @@ function normalizeWidgetConfig(type: WidgetType, config: unknown) {
title: "Tickets recentes",
dataSource: { metricKey: "tickets.awaiting_table", params: { limit: 20 } },
columns: [
{ field: "reference", label: "Ref." },
{ field: "reference", label: "Referência" },
{ field: "subject", label: "Assunto" },
{ field: "status", label: "Status" },
{ field: "priority", label: "Prioridade" },
{ field: "updatedAt", label: "Atualizado em" },
],
options: { downloadCSV: true },

View file

@ -17,14 +17,9 @@ import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-ed
import { Spinner } from "@/components/ui/spinner"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
PriorityIcon,
priorityBadgeClass,
priorityItemClass,
priorityStyles,
priorityTriggerClass,
} from "@/components/tickets/priority-select"
import { PriorityIcon, priorityBadgeClass, priorityItemClass, priorityTriggerClass } from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { priorityStyles } from "@/lib/ticket-priority-style"
type CustomerOption = {
id: string

View file

@ -33,6 +33,8 @@ import sanitizeHtml from "sanitize-html"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { getTicketPriorityLabel } from "@/lib/ticket-priority-style"
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
import { cn } from "@/lib/utils"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import {
@ -69,6 +71,9 @@ const CHART_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var
const DEFAULT_CHART_HEIGHT = 320
// Em modo apresentação o card já define a altura disponível; evitar valores fixos previne cortes.
const PRESENTATION_CHART_HEIGHT = 0
const TIMESTAMP_THRESHOLD = 1_000_000_000
const STATUS_CHIP_BASE_CLASS = "inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition"
const KNOWN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"])
export type DashboardFilters = {
range?: "7d" | "30d" | "90d" | "custom"
@ -100,6 +105,8 @@ export type WidgetConfig = {
content?: string
}
type TableColumnConfig = { field: string; label: string }
export type DashboardWidgetRecord = {
id: Id<"dashboardWidgets">
widgetKey: string
@ -955,13 +962,16 @@ function renderTable({
isLoading: boolean
mode: WidgetRendererProps["mode"]
}) {
const columns = Array.isArray(config.columns) && config.columns.length > 0
? config.columns
const columns: TableColumnConfig[] = Array.isArray(config.columns) && config.columns.length > 0
? (config.columns as TableColumnConfig[])
: [
{ field: "subject", label: "Assunto" },
{ field: "status", label: "Status" },
{ field: "updatedAt", label: "Atualizado em" },
]
const columnsToRender = isRecentTicketsTable(config)
? columns.filter((column) => !isPriorityColumn(column))
: columns
const rows = Array.isArray(metric.data) ? (metric.data as Array<Record<string, unknown>>) : []
const isPresentation = mode === "tv" || mode === "print"
const containerClass = cn(
@ -982,7 +992,7 @@ function renderTable({
<Table className="min-w-full table-fixed">
<TableHeader>
<TableRow>
{columns.map((column) => (
{columnsToRender.map((column) => (
<TableHead key={column.field} className="whitespace-nowrap">
{column.label}
</TableHead>
@ -992,11 +1002,11 @@ function renderTable({
<TableBody>
{rows.map((row, index) => (
<TableRow key={index} className="border-b border-border/60 transition hover:bg-muted/40">
{columns.map((column) => {
{columnsToRender.map((column) => {
const cellValue = row[column.field as keyof typeof row]
return (
<TableCell key={column.field} className="whitespace-normal break-words">
{renderTableCellValue(cellValue)}
{renderTableCellValue(cellValue, column)}
</TableCell>
)
})}
@ -1036,22 +1046,38 @@ function renderQueueSummary({
)
}
function renderTableCellValue(value: unknown) {
function renderTableCellValue(value: unknown, column?: TableColumnConfig) {
if (value === null || value === undefined) {
return "—"
}
const enriched = renderTicketTableCell(value, column)
if (enriched) {
return enriched
}
if (isStatusColumn(column)) {
return getTicketStatusLabel(value as string)
}
if (isPriorityColumn(column)) {
return getTicketPriorityLabel(value as string)
}
if (value instanceof Date) {
const formatted = tryFormatDate(value)
if (formatted) return formatted
}
if (typeof value === "number") {
if (isLikelyTimestampNumber(value, column)) {
const formatted = tryFormatDate(value)
if (formatted) return formatted
}
return numberFormatter.format(value)
}
if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}T/.test(value) || /^\d+$/.test(value)) {
const date = new Date(value)
if (!Number.isNaN(date.getTime())) {
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
if (isLikelyTimestampString(value, column)) {
const formatted = tryFormatDate(value)
if (formatted) return formatted
}
return value
}
if (value === null || value === undefined) {
return "—"
}
if (typeof value === "boolean") {
return value ? "Sim" : "Não"
}
@ -1068,6 +1094,102 @@ function renderTableCellValue(value: unknown) {
return String(value)
}
function renderTicketTableCell(value: unknown, column?: TableColumnConfig) {
if (!column) return null
if (isReferenceColumn(column)) {
const reference = typeof value === "number" || typeof value === "string" ? String(value) : null
return (
<Badge className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-neutral-700">
{reference ? `#${reference}` : "—"}
</Badge>
)
}
if (isStatusColumn(column) && isTicketStatusValue(value)) {
return (
<span className={cn(STATUS_CHIP_BASE_CLASS, getTicketStatusChipClass(String(value)))}>
{getTicketStatusLabel(String(value))}
</span>
)
}
return null
}
function tryFormatDate(value: number | string | Date): string | null {
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return null
}
return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
function isReferenceColumn(column?: TableColumnConfig) {
if (!column) return false
const field = column.field.toLowerCase()
const label = column.label.toLowerCase()
return field === "reference" || field === "ref" || label.includes("ref")
}
function isRecentTicketsTable(config: WidgetConfig) {
return config?.dataSource?.metricKey === "tickets.awaiting_table"
}
function isStatusColumn(column?: TableColumnConfig) {
if (!column) return false
const field = column.field.toLowerCase()
const label = column.label.toLowerCase()
return field === "status" || label.includes("status") || label.includes("situação")
}
function isPriorityColumn(column?: TableColumnConfig) {
if (!column) return false
const field = column.field.toLowerCase()
const label = column.label.toLowerCase()
return field === "priority" || label.includes("prioridade")
}
function isTicketStatusValue(value: unknown): value is string {
if (typeof value !== "string") return false
return KNOWN_TICKET_STATUSES.has(value.toUpperCase())
}
function hasDateHint(column?: TableColumnConfig) {
if (!column) return false
const field = column.field.toLowerCase()
const label = column.label.toLowerCase()
return (
field.endsWith("at") ||
field.includes("date") ||
field.includes("time") ||
field.includes("due") ||
label.includes("data") ||
label.includes("hora") ||
label.includes("atualiz") ||
label.includes("criad") ||
label.includes("resolvido") ||
label.includes("prazo")
)
}
function isLikelyTimestampNumber(value: number, column?: TableColumnConfig) {
if (!Number.isFinite(value) || value <= 0) return false
return value >= TIMESTAMP_THRESHOLD || hasDateHint(column)
}
function isLikelyTimestampString(value: string, column?: TableColumnConfig) {
if (/^\d{4}-\d{2}-\d{2}T/.test(value) || /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return true
}
if (/^\d+$/.test(value)) {
if (value.length >= 10) return true
return hasDateHint(column)
}
if (hasDateHint(column)) {
const parsed = Date.parse(value)
return Number.isFinite(parsed)
}
return false
}
function renderText({
title,
description,

View file

@ -21,7 +21,7 @@ import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select"
import { PriorityIcon } from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@ -30,6 +30,7 @@ import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { cn } from "@/lib/utils"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react"

View file

@ -1,16 +1,17 @@
import { type TicketPriority } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select"
import { PriorityIcon } from "@/components/tickets/priority-select"
import { getTicketPriorityMeta } from "@/lib/ticket-priority-style"
const baseClass = "inline-flex h-7 items-center gap-2 rounded-full px-3 text-xs font-semibold"
export function TicketPriorityPill({ priority, className }: { priority: TicketPriority; className?: string }) {
const styles = priorityStyles[priority]
const styles = getTicketPriorityMeta(priority)
return (
<Badge className={cn(baseClass, styles?.badgeClass, className)}>
<Badge className={cn(baseClass, styles.badgeClass, className)}>
<PriorityIcon value={priority} />
{styles?.label ?? priority}
{styles.label}
</Badge>
)
}

View file

@ -6,19 +6,13 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "bg-[#fbd9dd] text-[#8b0f1c]" },
}
export const priorityTriggerClass =
"h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
@ -97,3 +91,5 @@ export function PrioritySelect({
</Select>
)
}
export { priorityStyles }

View file

@ -0,0 +1,35 @@
import type { TicketPriority } from "@/lib/schemas/ticket"
export type TicketPriorityStyle = {
label: string
badgeClass: string
}
const FALLBACK_PRIORITY: TicketPriority = "MEDIUM"
export const priorityStyles: Record<TicketPriority, TicketPriorityStyle> = {
LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "bg-[#fbd9dd] text-[#8b0f1c]" },
}
function resolvePriority(priority: TicketPriority | string | null | undefined): TicketPriorityStyle {
if (!priority) {
return priorityStyles[FALLBACK_PRIORITY]
}
const normalized = priority.toString().trim().toUpperCase() as TicketPriority
return priorityStyles[normalized] ?? priorityStyles[FALLBACK_PRIORITY]
}
export function getTicketPriorityMeta(priority: TicketPriority | string | null | undefined): TicketPriorityStyle {
return resolvePriority(priority)
}
export function getTicketPriorityLabel(priority: TicketPriority | string | null | undefined): string {
return resolvePriority(priority).label
}
export function getTicketPriorityBadgeClass(priority: TicketPriority | string | null | undefined): string {
return resolvePriority(priority).badgeClass
}