From 4079f67fcbe2f9169e0b332d193c5fad4065d10f Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 7 Nov 2025 14:22:14 -0300 Subject: [PATCH] feat: padroniza tickets recentes nos dashboards --- convex/dashboards.ts | 3 +- src/app/tickets/new/page.tsx | 9 +- src/components/dashboards/widget-renderer.tsx | 150 ++++++++++++++++-- src/components/tickets/new-ticket-dialog.tsx | 3 +- src/components/tickets/priority-pill.tsx | 9 +- src/components/tickets/priority-select.tsx | 10 +- src/lib/ticket-priority-style.ts | 35 ++++ 7 files changed, 184 insertions(+), 35 deletions(-) create mode 100644 src/lib/ticket-priority-style.ts diff --git a/convex/dashboards.ts b/convex/dashboards.ts index bfde24c..9569e5e 100644 --- a/convex/dashboards.ts +++ b/convex/dashboards.ts @@ -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 }, diff --git a/src/app/tickets/new/page.tsx b/src/app/tickets/new/page.tsx index c69804f..3f4cd62 100644 --- a/src/app/tickets/new/page.tsx +++ b/src/app/tickets/new/page.tsx @@ -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 diff --git a/src/components/dashboards/widget-renderer.tsx b/src/components/dashboards/widget-renderer.tsx index 2e05568..2fb108b 100644 --- a/src/components/dashboards/widget-renderer.tsx +++ b/src/components/dashboards/widget-renderer.tsx @@ -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>) : [] const isPresentation = mode === "tv" || mode === "print" const containerClass = cn( @@ -982,7 +992,7 @@ function renderTable({ - {columns.map((column) => ( + {columnsToRender.map((column) => ( {column.label} @@ -992,11 +1002,11 @@ function renderTable({ {rows.map((row, index) => ( - {columns.map((column) => { + {columnsToRender.map((column) => { const cellValue = row[column.field as keyof typeof row] return ( - {renderTableCellValue(cellValue)} + {renderTableCellValue(cellValue, column)} ) })} @@ -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 ( + + {reference ? `#${reference}` : "—"} + + ) + } + if (isStatusColumn(column) && isTicketStatusValue(value)) { + return ( + + {getTicketStatusLabel(String(value))} + + ) + } + 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, diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index 668240e..75bb35c 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -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" diff --git a/src/components/tickets/priority-pill.tsx b/src/components/tickets/priority-pill.tsx index ad1f234..2d69468 100644 --- a/src/components/tickets/priority-pill.tsx +++ b/src/components/tickets/priority-pill.tsx @@ -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 ( - + - {styles?.label ?? priority} + {styles.label} ) } diff --git a/src/components/tickets/priority-select.tsx b/src/components/tickets/priority-select.tsx index 8383ae6..6b10c97 100644 --- a/src/components/tickets/priority-select.tsx +++ b/src/components/tickets/priority-select.tsx @@ -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 = { - 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({ ) } + +export { priorityStyles } diff --git a/src/lib/ticket-priority-style.ts b/src/lib/ticket-priority-style.ts new file mode 100644 index 0000000..838df4c --- /dev/null +++ b/src/lib/ticket-priority-style.ts @@ -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 = { + 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 +}