feat: padroniza tickets recentes nos dashboards
This commit is contained in:
parent
4655c7570a
commit
4079f67fcb
7 changed files with 184 additions and 35 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
35
src/lib/ticket-priority-style.ts
Normal file
35
src/lib/ticket-priority-style.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue