Atualiza dashboards e painel de tickets

This commit is contained in:
Esdras Renan 2025-11-07 00:56:59 -03:00
parent c66ffa6e0b
commit 4655c7570a
9 changed files with 483 additions and 420 deletions

View file

@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [
title: "Relatórios",
requiredRole: "staff",
items: [
{ title: "Dashboards", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" },
{ title: "Painéis customizados", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" },
{ title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },

View file

@ -100,6 +100,9 @@ import { getMetricDefinition, getMetricOptionsForRole } from "@/components/dashb
const GRID_COLUMNS = 12
const DEFAULT_ROW_HEIGHT = 80
const MAX_ROWS = 32
const QUEUE_SUMMARY_DEFAULT_ROWS = 8
const QUEUE_SUMMARY_MIN_ROWS = 7
const QUEUE_SUMMARY_MAX_ROWS = 12
type DashboardRecord = {
id: Id<"dashboards">
@ -233,7 +236,11 @@ const widgetSizePresets: Record<
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 } },
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 } },
"queue-summary": {
default: { w: 12, h: QUEUE_SUMMARY_DEFAULT_ROWS },
min: { w: 8, h: QUEUE_SUMMARY_MIN_ROWS },
max: { w: 12, h: QUEUE_SUMMARY_MAX_ROWS },
},
text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } },
}
@ -572,8 +579,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
const autoFullscreenRef = useRef(false)
const tvFullscreenWarningShownRef = useRef(false)
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
const ensureQueueSummaryRequestedRef = useRef(false)
const suppressQueueEnsureRef = useRef(false)
const updateLayoutMutation = useMutation(api.dashboards.updateLayout)
const updateFiltersMutation = useMutation(api.dashboards.updateFilters)
@ -632,8 +641,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
useEffect(() => {
if (!dashboard || !convexUserId || !isStaff) return
if (widgets.length === 0) return
const queueIndex = widgets.findIndex((widget) => {
const queueWidget = widgets.find((widget) => {
const type = (widget.type ?? "").toLowerCase()
if (type === "queue-summary") return true
const configType =
@ -642,30 +651,44 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
: ""
return configType === "queue-summary"
})
if (queueIndex === 0) {
if (!queueWidget) {
if (suppressQueueEnsureRef.current) {
suppressQueueEnsureRef.current = false
}
ensureQueueSummaryRequestedRef.current = false
return
}
if (queueIndex === -1 || queueIndex > 0) {
if (ensureQueueSummaryRequestedRef.current) return
ensureQueueSummaryRequestedRef.current = true
ensureQueueSummaryWidgetMutation({
tenantId,
actorId: convexUserId as Id<"users">,
dashboardId: dashboard.id as Id<"dashboards">,
}).catch((error) => {
if (suppressQueueEnsureRef.current) {
return
}
const queueIndex = widgets.findIndex((widget) => widget.widgetKey === queueWidget.widgetKey)
const queueLayout = layoutState.find((item) => item.i === queueWidget.widgetKey)
const layoutTooSmall =
!queueLayout ||
queueLayout.h < QUEUE_SUMMARY_MIN_ROWS ||
(queueLayout.minH ?? 0) < QUEUE_SUMMARY_MIN_ROWS
const needsEnsure = queueIndex !== 0 || layoutTooSmall
if (!needsEnsure) {
ensureQueueSummaryRequestedRef.current = false
return
}
if (ensureQueueSummaryRequestedRef.current) return
ensureQueueSummaryRequestedRef.current = true
ensureQueueSummaryWidgetMutation({
tenantId,
actorId: convexUserId as Id<"users">,
dashboardId: dashboard.id as Id<"dashboards">,
})
.catch((error) => {
console.error("[dashboards] Failed to ensure queue summary widget", error)
ensureQueueSummaryRequestedRef.current = false
})
}
}, [
dashboard,
widgets,
convexUserId,
isStaff,
tenantId,
ensureQueueSummaryWidgetMutation,
])
}, [dashboard, widgets, layoutState, convexUserId, isStaff, tenantId, ensureQueueSummaryWidgetMutation])
useEffect(() => {
if (sections.length === 0) {
@ -690,47 +713,78 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
}, [setOpen, setOpenMobile])
const handleToggleFullscreen = useCallback(async () => {
if (typeof document === "undefined") return
try {
if (!document.fullscreenElement) {
const handleToggleFullscreen = useCallback(
async (options?: { requestedByUser?: boolean }) => {
if (typeof document === "undefined") return false
const requestedByUser = Boolean(options?.requestedByUser)
const target = fullscreenContainerRef.current ?? document.documentElement
const enterFullscreen = async () => {
previousSidebarStateRef.current = { open, openMobile }
if (isMobile) {
setOpenMobile(false)
} else {
setOpen(false)
}
const target = fullscreenContainerRef.current ?? document.documentElement
if (target && target.requestFullscreen) {
if (target?.requestFullscreen) {
await target.requestFullscreen()
return true
}
} else if (document.exitFullscreen) {
await document.exitFullscreen()
return false
}
} catch (error) {
console.error("[dashboards] Failed to toggle fullscreen", error)
}
}, [isMobile, open, openMobile, setOpen, setOpenMobile])
try {
if (!document.fullscreenElement) {
return await enterFullscreen()
}
if (document.exitFullscreen) {
await document.exitFullscreen()
return true
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const isPermissionError =
error instanceof DOMException
? error.name === "NotAllowedError" || error.name === "SecurityError" || error.name === "TypeError"
: /permission/i.test(message)
if (!requestedByUser && isPermissionError) {
return false
}
console.error("[dashboards] Failed to toggle fullscreen", error)
}
return false
},
[isMobile, open, openMobile, setOpen, setOpenMobile],
)
useEffect(() => {
if (typeof document === "undefined") return
if (enforceTv) {
if (!document.fullscreenElement) {
handleToggleFullscreen()
.then(() => {
autoFullscreenRef.current = true
.then((entered) => {
autoFullscreenRef.current = entered
if (entered) {
tvFullscreenWarningShownRef.current = false
} else if (!tvFullscreenWarningShownRef.current) {
tvFullscreenWarningShownRef.current = true
toast.info("Clique em \"Tela cheia\" para ativar o modo apresentação.")
}
})
.catch(() => {
autoFullscreenRef.current = false
})
} else {
autoFullscreenRef.current = true
tvFullscreenWarningShownRef.current = false
}
} else if (autoFullscreenRef.current && document.fullscreenElement) {
document.exitFullscreen?.().catch(() => null)
autoFullscreenRef.current = false
tvFullscreenWarningShownRef.current = false
} else {
autoFullscreenRef.current = false
tvFullscreenWarningShownRef.current = false
}
}, [enforceTv, handleToggleFullscreen])
@ -952,6 +1006,12 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const handleRemoveWidget = async (widget: DashboardWidgetRecord) => {
if (!canEdit || !viewerId || !dashboard) return
const widgetType = (widget.type ?? "").toLowerCase()
const configType =
widget.config && typeof widget.config === "object" ? ((widget.config as WidgetConfig).type ?? "").toLowerCase() : ""
if (widgetType === "queue-summary" || configType === "queue-summary") {
suppressQueueEnsureRef.current = true
}
try {
await removeWidgetMutation({
tenantId,
@ -961,6 +1021,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
setLayoutState((prev) => prev.filter((item) => item.i !== widget.widgetKey))
toast.success("Widget removido do painel.")
} catch (error) {
suppressQueueEnsureRef.current = false
console.error(error)
toast.error("Não foi possível remover o widget.")
}
@ -1168,7 +1229,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
variant="secondary"
size="sm"
className="pointer-events-auto gap-2 rounded-full border border-slate-200 bg-white/90 px-4 py-2 text-sm font-semibold text-slate-900 shadow-lg transition hover:bg-white"
onClick={handleToggleFullscreen}
onClick={() => handleToggleFullscreen({ requestedByUser: true })}
>
<Minimize2 className="size-4" />
Sair da tela cheia
@ -1374,7 +1435,7 @@ function BuilderHeader({
totalWidgets: number
onDeleteRequest?: () => void
isFullscreen?: boolean
onToggleFullscreen: () => void
onToggleFullscreen: (options?: { requestedByUser?: boolean }) => void | Promise<boolean>
}) {
const [name, setName] = useState(dashboard.name)
const [description, setDescription] = useState(dashboard.description ?? "")
@ -1505,36 +1566,15 @@ function BuilderHeader({
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
</Badge>
{isTvMode && hasSections ? (
<Badge className="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-primary">
<MonitorPlay className="size-3.5" />
<Badge className="inline-flex items-center gap-2 rounded-full border border-sidebar-border bg-sidebar-accent px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-sidebar-accent-foreground shadow-sm">
<MonitorPlay className="size-3.5 text-current" />
Slide {activeSectionIndex + 1} de {totalSections}
</Badge>
) : null}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
{isEditingHeader ? (
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-sm font-semibold text-neutral-700"
onClick={handleCancelEditHeader}
disabled={isSavingHeader}
>
Cancelar
</Button>
<Button
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/85"
onClick={handleSaveHeader}
disabled={isSavingHeader}
>
{isSavingHeader ? "Salvando..." : "Salvar"}
</Button>
</div>
) : null}
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-1 flex-col gap-3 sm:min-h-[180px]">
{canEdit ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
@ -1545,7 +1585,7 @@ function BuilderHeader({
variant="ghost"
size="sm"
className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
onClick={onToggleFullscreen}
onClick={() => onToggleFullscreen({ requestedByUser: true })}
>
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
@ -1612,7 +1652,7 @@ function BuilderHeader({
<Button
variant="ghost"
size="sm"
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50"
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50 hover:!text-rose-600 hover:[&>svg]:!text-rose-600"
onClick={onDeleteRequest}
>
<Trash2 className="size-4" />
@ -1620,6 +1660,27 @@ function BuilderHeader({
</Button>
) : null}
</div>
{isEditingHeader ? (
<div className="mt-auto flex flex-wrap items-center justify-end gap-2 pt-3">
<Button
variant="ghost"
size="sm"
className="text-sm font-semibold text-neutral-700"
onClick={handleCancelEditHeader}
disabled={isSavingHeader}
>
Cancelar
</Button>
<Button
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/85"
onClick={handleSaveHeader}
disabled={isSavingHeader}
>
{isSavingHeader ? "Salvando..." : "Salvar"}
</Button>
</div>
) : null}
</div>
</div>
</div>
@ -1873,7 +1934,7 @@ function BuilderWidgetCard({
<Button
size="icon"
variant="outline"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-white text-rose-600 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 hover:text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-white text-rose-600 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 hover:!text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200 [&>svg]:text-rose-600 hover:[&>svg]:!text-rose-600"
onClick={onRemove}
>
<Trash2 className="size-3.5" />

View file

@ -230,7 +230,7 @@ export function DashboardListView() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">Dashboards personalizados</h2>
<h2 className="text-lg font-semibold">Painéis customizados</h2>
<p className="text-sm text-muted-foreground">
Combine KPIs, gráficos, tabelas e texto em painéis dinâmicos com filtros globais.
</p>
@ -247,7 +247,7 @@ export function DashboardListView() {
<Sparkles className="size-5" />
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900">Nenhum dashboard ainda</h3>
<h3 className="text-xl font-semibold text-neutral-900">Nenhum painel ainda</h3>
<p className="text-sm text-muted-foreground">Use KPIs, filas e texto para contar a história da operação.</p>
</div>
</div>
@ -317,12 +317,12 @@ export function DashboardListView() {
</CardContent>
<CardFooter className="flex gap-2">
<Button asChild className="flex-1">
<Link href={`/dashboards/${dashboard.id}`}>Abrir dashboard</Link>
<Link href={`/dashboards/${dashboard.id}`}>Abrir painel</Link>
</Button>
<Button
variant="outline"
size="icon"
className="shrink-0 size-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:text-rose-700"
className="shrink-0 size-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 hover:!text-rose-600 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:!text-rose-600"
onClick={() => setDashboardToDelete(dashboard)}
aria-label={`Excluir ${dashboard.name}`}
>
@ -344,9 +344,9 @@ export function DashboardListView() {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir dashboard</DialogTitle>
<DialogTitle>Excluir painel</DialogTitle>
<DialogDescription>
Essa ação remove o dashboard para toda a equipe. Confirme para continuar.
Essa ação remove o painel para toda a equipe. Confirme para continuar.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
@ -360,7 +360,7 @@ export function DashboardListView() {
Cancelar
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
{isDeleting ? "Removendo..." : "Excluir dashboard"}
{isDeleting ? "Removendo..." : "Excluir painel"}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1029,7 +1029,7 @@ function renderQueueSummary({
<EmptyState />
) : (
<div className="pb-1">
<TicketQueueSummaryCards queues={queues} />
<TicketQueueSummaryCards queues={queues} layout="compact" />
</div>
)}
</WidgetCard>

View file

@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { format, parseISO } from "date-fns"
@ -32,6 +32,7 @@ type TicketCustomFieldsListProps = {
record?: TicketCustomFieldRecord | null
emptyMessage?: string
className?: string
actionSlot?: ReactNode
}
const DEFAULT_FORM: TicketFormDefinition = {
@ -146,19 +147,28 @@ function normalizeFieldValue(
}
}
export function TicketCustomFieldsList({ record, emptyMessage, className }: TicketCustomFieldsListProps) {
export function TicketCustomFieldsList({ record, emptyMessage, className, actionSlot }: TicketCustomFieldsListProps) {
const entries = useMemo(() => mapTicketCustomFields(record), [record])
const hasAction = Boolean(actionSlot)
if (entries.length === 0) {
return (
<p className={cn("text-sm text-neutral-500", className)}>
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
</p>
<div className={cn("space-y-3", className)}>
{hasAction ? (
<div className="grid gap-3 sm:grid-cols-2">
{actionSlot}
</div>
) : null}
<p className="text-sm text-neutral-500">
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
</p>
</div>
)
}
return (
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
{hasAction ? actionSlot : null}
{entries.map((entry) => (
<div
key={entry.key}
@ -174,9 +184,11 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
type TicketCustomFieldsSectionProps = {
ticket: TicketWithDetails
variant?: "card" | "inline"
className?: string
}
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
@ -229,10 +241,15 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [currentFields, setCurrentFields] = useState<TicketCustomFieldRecord | null | undefined>(ticket.customFields)
useEffect(() => {
setCurrentFields(ticket.customFields)
}, [ticket.customFields])
const initialValues = useMemo(
() => buildInitialValues(selectedForm.fields, ticket.customFields),
[selectedForm.fields, ticket.customFields]
() => buildInitialValues(selectedForm.fields, currentFields),
[selectedForm.fields, currentFields]
)
useEffect(() => {
@ -283,11 +300,12 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
setIsSaving(true)
setValidationError(null)
try {
await updateCustomFields({
const result = await updateCustomFields({
ticketId: ticket.id as Id<"tickets">,
actorId: viewerId,
fields: payload,
})
setCurrentFields(result?.customFields ?? currentFields)
toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" })
setEditorOpen(false)
} catch (error) {
@ -298,281 +316,233 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
}
}
const entries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
const hasConfiguredFields = selectedForm.fields.length > 0
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
{canEdit && hasConfiguredFields ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2"
onClick={() => setEditorOpen(true)}
>
<Pencil className="size-3.5" />
Editar campos
const editActionSlot =
canEdit && hasConfiguredFields ? (
<button
type="button"
onClick={() => setEditorOpen(true)}
className="flex h-full min-h-[88px] w-full flex-col items-start justify-center gap-1 rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50/60 px-4 py-3 text-left text-sm font-semibold text-neutral-700 transition hover:border-slate-400 hover:bg-white"
>
<span className="inline-flex items-center gap-2 text-sm font-semibold text-neutral-900">
<Pencil className="size-3.5 text-neutral-500" />
Editar campos
</span>
<span className="text-xs font-medium text-neutral-500">Atualize informações personalizadas deste ticket.</span>
</button>
) : null
const dialog = (
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-3xl gap-4">
<DialogHeader>
<DialogTitle>Editar campos personalizados</DialogTitle>
<DialogDescription>
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
</DialogDescription>
</DialogHeader>
{hasConfiguredFields ? (
<div className="grid gap-4 sm:grid-cols-2">
{selectedForm.fields.map((field) => renderFieldEditor(field))}
</div>
) : (
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
)}
{validationError ? <p className="text-sm font-semibold text-rose-600">{validationError}</p> : null}
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)} disabled={isSaving}>
Cancelar
</Button>
) : null}
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving ? "Salvando..." : "Salvar alterações"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
const listClassName = cn(variant === "inline" ? "sm:col-span-2 lg:col-span-3" : "", className)
if (variant === "inline") {
return (
<>
<TicketCustomFieldsList
record={currentFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
actionSlot={editActionSlot}
className={listClassName}
/>
{dialog}
</>
)
}
return (
<section className={cn("space-y-3", className)}>
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
</div>
<TicketCustomFieldsList
record={ticket.customFields}
<TicketCustomFieldsList
record={currentFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado."
actionSlot={editActionSlot}
/>
{dialog}
</section>
)
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-w-3xl gap-4">
<DialogHeader>
<DialogTitle>Editar campos personalizados</DialogTitle>
<DialogDescription>
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
</DialogDescription>
</DialogHeader>
function renderFieldEditor(field: TicketFormFieldDefinition) {
const value = customFieldValues[field.id]
const fieldId = `ticket-custom-field-${field.id}`
const isTextarea =
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
const spanClass =
field.type === "boolean" || field.type === "date" || isTextarea ? "sm:col-span-2" : ""
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
{hasConfiguredFields ? (
<div className="grid gap-4 sm:grid-cols-2">
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `ticket-custom-field-${field.id}`
const isTextarea =
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
const spanClass =
isTextarea || field.type === "boolean" || field.type === "date" ? "sm:col-span-2" : ""
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
const isIndeterminate = value === null || value === undefined
return (
<div
key={field.id}
className={cn(
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass
)}
>
<input
id={fieldId}
type="checkbox"
className="mt-1 size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
ref={(element) => {
if (!element) return
element.indeterminate = isIndeterminate
}}
onChange={(event) => handleFieldChange(field, event.target.checked)}
/>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</label>
{helpText}
{!field.required ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
onClick={() => handleClearField(field.id)}
>
<X className="size-3" />
Remover valor
</button>
) : null}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleFieldChange(field, selected)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{!field.required ? (
<SelectItem value="" className="text-neutral-500">
Limpar seleção
</SelectItem>
) : null}
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={
typeof value === "number"
? String(value)
: typeof value === "string"
? value
: ""
}
onChange={(event) => handleFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
const parsedDate =
typeof value === "string" && value
? parseISO(value)
: undefined
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
return (
<Field key={field.id} className={cn("flex flex-col gap-1", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!isValidDate && "text-muted-foreground"
)}
>
<span>
{isValidDate
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
</span>
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? (parsedDate as Date) : undefined}
onSelect={(selected) => {
handleFieldChange(
field,
selected ? format(selected, "yyyy-MM-dd") : ""
)
setOpenCalendarField(null)
}}
initialFocus
captionLayout="dropdown"
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
/>
</PopoverContent>
</Popover>
{!field.required ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
onClick={() => handleFieldChange(field, "")}
>
<X className="size-3" />
Limpar data
</button>
) : null}
{helpText}
</Field>
)
}
if (isTextarea) {
return (
<Field key={field.id} className={cn("flex-col", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Textarea
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
className="min-h-[90px]"
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
id={fieldId}
value={
typeof value === "string"
? value
: typeof value === "number"
? String(value)
: value != null
? String(value)
: ""
}
onChange={(event) => handleFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-neutral-600">
Nenhum campo personalizado configurado para este formulário.
</div>
)}
{validationError ? (
<p className="text-sm font-medium text-destructive">{validationError}</p>
if (field.type === "boolean") {
const isIndeterminate = value === null || value === undefined
return (
<div
key={field.id}
className={cn(
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass
)}
>
<input
id={fieldId}
type="checkbox"
className="mt-1 size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
ref={(element) => {
if (!element) return
element.indeterminate = isIndeterminate
}}
onChange={(event) => handleFieldChange(field, event.target.checked)}
/>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</label>
{helpText}
{!field.required ? (
<button
type="button"
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
onClick={() => handleClearField(field.id)}
>
<X className="size-3" />
Remover valor
</button>
) : null}
</div>
</div>
)
}
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Select value={typeof value === "string" ? value : ""} onValueChange={(selected) => handleFieldChange(field, selected)}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{!field.required ? (
<SelectItem value="" className="text-neutral-500">
Limpar seleção
</SelectItem>
) : null}
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
type="number"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
placeholder="0"
className="rounded-lg border border-slate-300"
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
return (
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Popover open={openCalendarField === field.id} onOpenChange={() => setOpenCalendarField(field.id)}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
onClick={() => setEditorOpen(false)}
disabled={isSaving}
className={cn(
"justify-between rounded-lg border border-slate-300 text-left font-normal",
!value && "text-muted-foreground"
)}
>
Cancelar
{value ? (
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR })
) : (
<span>Selecionar data</span>
)}
<CalendarIcon className="ml-2 size-4 opacity-50" />
</Button>
<Button type="button" onClick={handleSubmit} disabled={isSaving || !hasConfiguredFields}>
{isSaving ? <Spinner className="size-4" /> : null}
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md">
<Calendar
mode="single"
selected={value ? new Date(value as string) : undefined}
onSelect={(date) => {
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
setOpenCalendarField(null)
}}
/>
</PopoverContent>
</Popover>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Textarea
value={typeof value === "string" ? value : ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
className="min-h-[88px] resize-none rounded-lg border border-slate-300"
/>
{helpText}
</Field>
)
}
}

View file

@ -7,13 +7,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
interface TicketDetailsPanelProps {
ticket: TicketWithDetails
}
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger"
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger" | "primary"
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa",
@ -49,12 +48,20 @@ function formatMinutes(value?: number | null) {
return `${value} min`
}
type SummaryChipConfig = {
key: string
label: string
value: string
tone: SummaryTone
labelClassName?: string
}
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const summaryChips = useMemo(() => {
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
const chips: SummaryChipConfig[] = [
{
key: "queue",
label: "Fila",
@ -87,11 +94,12 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
},
]
if (ticket.formTemplateLabel) {
chips.push({
chips.splice(Math.min(chips.length, 5), 0, {
key: "formTemplate",
label: "Tipo de solicitação",
label: "Fluxo",
value: ticket.formTemplateLabel,
tone: "info",
tone: "primary",
labelClassName: "text-white",
})
}
return chips
@ -117,7 +125,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<div className="space-y-1">
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
<CardDescription className="text-sm text-neutral-500">
Resumo do ticket, métricas de SLA e tempo dedicado pela equipe.
Resumo do ticket, métricas de SLA, tempo dedicado e campos personalizados.
</CardDescription>
</div>
{isAvulso ? (
@ -131,8 +139,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<section className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
<div className="grid gap-3 sm:grid-cols-2">
{summaryChips.map(({ key, label, value, tone }) => (
<SummaryChip key={key} label={label} value={value} tone={tone} />
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
))}
</div>
</section>
@ -190,8 +198,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
</div>
</section>
<TicketCustomFieldsSection ticket={ticket} />
<section className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
{agentTotals.length > 0 ? (
@ -255,7 +261,17 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
)
}
function SummaryChip({ label, value, tone = "default" }: { label: string; value: string; tone?: SummaryTone }) {
function SummaryChip({
label,
value,
tone = "default",
labelClassName,
}: {
label: string
value: string
tone?: SummaryTone
labelClassName?: string
}) {
const toneClasses: Record<SummaryTone, string> = {
default: "border-slate-200 bg-white text-neutral-900",
info: "border-sky-200 bg-sky-50 text-sky-900",
@ -263,11 +279,12 @@ function SummaryChip({ label, value, tone = "default" }: { label: string; value:
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
muted: "border-slate-200 bg-slate-50 text-neutral-600",
danger: "border-rose-200 bg-rose-50 text-rose-700",
primary: "border-black bg-black text-white",
}
return (
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">{label}</p>
<p className={cn("text-[11px] font-semibold uppercase tracking-wide text-neutral-500", labelClassName)}>{label}</p>
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
</div>
)

View file

@ -11,9 +11,10 @@ import type { Id } from "@/convex/_generated/dataModel"
interface TicketQueueSummaryProps {
queues?: TicketQueueSummary[]
layout?: "default" | "compact"
}
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQueueSummaryProps) {
const { convexUserId, isStaff } = useAuth()
const enabled = Boolean(isStaff && convexUserId)
const shouldFetch = Boolean(!queues && enabled)
@ -23,75 +24,87 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
) as TicketQueueSummary[] | undefined
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
const gridLayoutClass =
layout === "compact"
? "mx-auto grid w-full max-w-5xl grid-cols-1 gap-5 pb-3 md:grid-cols-2"
: "grid w-full grid-cols-1 gap-5 pb-3 sm:grid-cols-2 md:grid-cols-3"
if (!queues && shouldFetch && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div>
))}
// Usa o mesmo grid do estado carregado para não “pular”
<div className="h-full min-h-0 overflow-auto">
<div className={gridLayoutClass}>
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
</div>
)
}
return (
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
{data.map((queue) => {
const totalOpen = queue.pending + queue.inProgress + queue.paused
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
return (
<Card
key={queue.id}
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
>
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
{queue.name}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
Pendentes
</p>
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
{queue.pending}
</p>
<div className="h-full min-h-0 overflow-auto">
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
<div className={gridLayoutClass}>
{data.map((queue) => {
const totalOpen = queue.pending + queue.inProgress + queue.paused
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
return (
<Card
key={queue.id}
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
>
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
{queue.name}
</CardTitle>
</CardHeader>
{/* min-w-0 evita conteúdo interno empurrar a coluna */}
<CardContent className="min-w-0 space-y-3 sm:space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
Pendentes
</p>
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
{queue.pending}
</p>
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
Em andamento
</p>
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
{queue.inProgress}
</p>
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
Pausados
</p>
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
{queue.paused}
</p>
</div>
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
Em andamento
</p>
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
{queue.inProgress}
</p>
<div className="pt-1">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% dos chamados da fila estão fora do SLA
</span>
<span className="mt-1 block text-xs text-neutral-400">
Em atraso: {queue.breached}
</span>
</div>
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
Pausados
</p>
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
{queue.paused}
</p>
</div>
</div>
<div className="pt-1">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% dos chamados da fila estão fora do SLA
</span>
<span className="mt-1 block text-xs text-neutral-400">
Em atraso: {queue.breached}
</span>
</div>
</CardContent>
</Card>
)
})}
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
}

View file

@ -18,6 +18,7 @@ import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select"
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { CheckCircle2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
@ -1568,6 +1569,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div>
) : null}
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />
{editing ? (
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>