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

@ -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}>