- Corrige sincronização do avatar no perfil após upload - Reduz tamanho dos ícones de câmera/lixeira no avatar - Remove atributos title (tooltips nativos) de toda aplicação - Adiciona regra no AGENTS.md sobre uso de tooltips - Permite desmarcar resposta no checklist (toggle) - Torna campo answer opcional na mutation setChecklistItemAnswer - Adiciona edição inline dos campos de resumo no painel de detalhes - Redesenha comentários com layout mais limpo e consistente - Cria tratamento especial para comentários automáticos de sistema - Aplica fundo ciano semi-transparente em comentários públicos - Corrige import do Loader2 no notification-preferences-form 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
649 lines
26 KiB
TypeScript
649 lines
26 KiB
TypeScript
import { useMemo, useState } from "react"
|
|
import { format } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
import { MonitorSmartphone } from "lucide-react"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { cn } from "@/lib/utils"
|
|
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { useTicketRemoteAccess } from "@/hooks/use-ticket-remote-access"
|
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
|
import { StatusSelect } from "@/components/tickets/status-select"
|
|
|
|
interface TicketDetailsPanelProps {
|
|
ticket: TicketWithDetails
|
|
}
|
|
|
|
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger" | "primary"
|
|
|
|
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
|
LOW: "Baixa",
|
|
MEDIUM: "Média",
|
|
HIGH: "Alta",
|
|
URGENT: "Urgente",
|
|
}
|
|
|
|
const priorityTone: Record<TicketWithDetails["priority"], SummaryTone> = {
|
|
LOW: "muted",
|
|
MEDIUM: "info",
|
|
HIGH: "warning",
|
|
URGENT: "danger",
|
|
}
|
|
|
|
const slaStatusTone: Record<Exclude<SlaDisplayStatus, "n/a">, { label: string; className: string }> = {
|
|
on_track: { label: "No prazo", className: "text-emerald-600" },
|
|
at_risk: { label: "Em risco", className: "text-amber-600" },
|
|
breached: { label: "Violado", className: "text-rose-600" },
|
|
met: { label: "Concluído", className: "text-emerald-600" },
|
|
}
|
|
|
|
function formatDuration(ms?: number | null) {
|
|
if (!ms || ms <= 0) return "0s"
|
|
const totalSeconds = Math.floor(ms / 1000)
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
const seconds = totalSeconds % 60
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
|
}
|
|
if (minutes > 0) {
|
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
|
}
|
|
return `${seconds}s`
|
|
}
|
|
|
|
function formatMinutes(value?: number | null) {
|
|
if (value === null || value === undefined) return "—"
|
|
return `${value} min`
|
|
}
|
|
|
|
function formatSlaTarget(value?: number | null, mode?: string) {
|
|
if (!value) return "—"
|
|
if (value < 60) return `${value} min${mode === "business" ? " úteis" : ""}`
|
|
const hours = Math.floor(value / 60)
|
|
const minutes = value % 60
|
|
if (minutes === 0) {
|
|
return `${hours}h${mode === "business" ? " úteis" : ""}`
|
|
}
|
|
return `${hours}h ${minutes}m${mode === "business" ? " úteis" : ""}`
|
|
}
|
|
|
|
function getSlaStatusDisplay(status: SlaDisplayStatus) {
|
|
const normalized = status === "n/a" ? "on_track" : status
|
|
return slaStatusTone[normalized as Exclude<SlaDisplayStatus, "n/a">]
|
|
}
|
|
|
|
type SummaryChipConfig = {
|
|
key: string
|
|
label: string
|
|
value: string
|
|
tone: SummaryTone
|
|
labelClassName?: string
|
|
}
|
|
|
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|
const { convexUserId, isStaff } = useAuth()
|
|
const {
|
|
canShowRemoteAccess,
|
|
primaryRemoteAccess,
|
|
connect: handleRemoteConnect,
|
|
hostname: remoteHostname,
|
|
} = useTicketRemoteAccess(ticket)
|
|
|
|
const canEdit = isStaff && !!convexUserId
|
|
|
|
// Mutations para edição
|
|
const updateStatus = useMutation(api.tickets.updateStatus)
|
|
const updatePriority = useMutation(api.tickets.updatePriority)
|
|
const changeQueue = useMutation(api.tickets.changeQueue)
|
|
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
|
|
|
// Queries para opções (só carrega se usuário é staff e pode editar)
|
|
const queues = useQuery(
|
|
api.queues.listForStaff,
|
|
canEdit && convexUserId ? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
|
)
|
|
const agents = useQuery(
|
|
api.users.listAgents,
|
|
canEdit ? { tenantId: ticket.tenantId } : "skip"
|
|
)
|
|
|
|
// Estados para popovers
|
|
const [statusOpen, setStatusOpen] = useState(false)
|
|
const [priorityOpen, setPriorityOpen] = useState(false)
|
|
const [queueOpen, setQueueOpen] = useState(false)
|
|
const [assigneeOpen, setAssigneeOpen] = useState(false)
|
|
|
|
// Handlers de edição
|
|
const handleStatusChange = async (newStatus: string) => {
|
|
if (!convexUserId) return
|
|
try {
|
|
await updateStatus({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: convexUserId as Id<"users">,
|
|
status: newStatus,
|
|
})
|
|
setStatusOpen(false)
|
|
toast.success("Status atualizado")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Erro ao atualizar status")
|
|
}
|
|
}
|
|
|
|
const handlePriorityChange = async (newPriority: string) => {
|
|
if (!convexUserId) return
|
|
try {
|
|
await updatePriority({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: convexUserId as Id<"users">,
|
|
priority: newPriority,
|
|
})
|
|
setPriorityOpen(false)
|
|
toast.success("Prioridade atualizada")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Erro ao atualizar prioridade")
|
|
}
|
|
}
|
|
|
|
const handleQueueChange = async (newQueue: string) => {
|
|
if (!convexUserId) return
|
|
try {
|
|
await changeQueue({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: convexUserId as Id<"users">,
|
|
queueId: newQueue === "__none__" ? undefined : (newQueue as Id<"queues">),
|
|
})
|
|
setQueueOpen(false)
|
|
toast.success("Fila atualizada")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Erro ao atualizar fila")
|
|
}
|
|
}
|
|
|
|
const handleAssigneeChange = async (newAssignee: string) => {
|
|
if (!convexUserId) return
|
|
try {
|
|
await changeAssignee({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: convexUserId as Id<"users">,
|
|
assigneeId: newAssignee === "__none__" ? undefined : (newAssignee as Id<"users">),
|
|
})
|
|
setAssigneeOpen(false)
|
|
toast.success("Responsável atualizado")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Erro ao atualizar responsável")
|
|
}
|
|
}
|
|
|
|
const isAvulso = Boolean(ticket.company?.isAvulso)
|
|
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
|
const responseStatus = getSlaDisplayStatus(ticket, "response")
|
|
const solutionStatus = getSlaDisplayStatus(ticket, "solution")
|
|
const responseDue = getSlaDueDate(ticket, "response")
|
|
const solutionDue = getSlaDueDate(ticket, "solution")
|
|
|
|
const summaryChips = useMemo(() => {
|
|
const chips: SummaryChipConfig[] = [
|
|
{
|
|
key: "queue",
|
|
label: "Fila",
|
|
value: ticket.queue ?? "Sem fila",
|
|
tone: ticket.queue ? "default" : "muted",
|
|
},
|
|
{
|
|
key: "company",
|
|
label: "Empresa",
|
|
value: companyLabel,
|
|
tone: isAvulso ? "warning" : "default",
|
|
},
|
|
{
|
|
key: "status",
|
|
label: "Status",
|
|
value: getTicketStatusLabel(ticket.status) ?? ticket.status,
|
|
tone: getTicketStatusSummaryTone(ticket.status) as SummaryTone,
|
|
},
|
|
{
|
|
key: "priority",
|
|
label: "Prioridade",
|
|
value: priorityLabel[ticket.priority] ?? ticket.priority,
|
|
tone: priorityTone[ticket.priority] ?? "default",
|
|
},
|
|
{
|
|
key: "assignee",
|
|
label: "Responsável",
|
|
value: ticket.assignee?.name ?? "Não atribuído",
|
|
tone: ticket.assignee ? "default" : "muted",
|
|
},
|
|
]
|
|
if (ticket.formTemplateLabel) {
|
|
chips.splice(Math.min(chips.length, 5), 0, {
|
|
key: "formTemplate",
|
|
label: "Fluxo",
|
|
value: ticket.formTemplateLabel,
|
|
tone: "primary",
|
|
labelClassName: "text-white",
|
|
})
|
|
}
|
|
return chips
|
|
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
|
|
|
|
const agentTotals = useMemo(() => {
|
|
const totals = ticket.workSummary?.perAgentTotals ?? []
|
|
return totals
|
|
.map((item) => ({
|
|
agentId: String(item.agentId),
|
|
agentName: item.agentName ?? null,
|
|
totalWorkedMs: item.totalWorkedMs ?? 0,
|
|
internalWorkedMs: item.internalWorkedMs ?? 0,
|
|
externalWorkedMs: item.externalWorkedMs ?? 0,
|
|
}))
|
|
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
|
}, [ticket.workSummary?.perAgentTotals])
|
|
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="px-4 pb-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<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, tempo dedicado e campos personalizados.
|
|
</CardDescription>
|
|
</div>
|
|
{isAvulso ? (
|
|
<Badge className="h-7 rounded-full border border-amber-200 bg-amber-50 px-3 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
|
Cliente avulso
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6 px-4 pb-6">
|
|
<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">
|
|
{/* Fila - editável */}
|
|
{canEdit ? (
|
|
<Popover open={queueOpen} onOpenChange={setQueueOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button type="button" className="text-left w-full">
|
|
<SummaryChip
|
|
label="Fila"
|
|
value={ticket.queue ?? "Sem fila"}
|
|
tone={ticket.queue ? "default" : "muted"}
|
|
editable
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
|
<div className="p-3 space-y-2">
|
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar fila</p>
|
|
<Select
|
|
value={queues?.find((q) => q.name === ticket.queue)?.id ?? "__none__"}
|
|
onValueChange={handleQueueChange}
|
|
>
|
|
<SelectTrigger className="h-10 bg-white">
|
|
<SelectValue placeholder="Selecione a fila" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">Sem fila</SelectItem>
|
|
{queues?.map((q) => (
|
|
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<SummaryChip label="Fila" value={ticket.queue ?? "Sem fila"} tone={ticket.queue ? "default" : "muted"} />
|
|
)}
|
|
|
|
{/* Empresa - não editável */}
|
|
<SummaryChip
|
|
label="Empresa"
|
|
value={companyLabel}
|
|
tone={isAvulso ? "warning" : "default"}
|
|
/>
|
|
|
|
{/* Status - editável */}
|
|
{canEdit ? (
|
|
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button type="button" className="text-left w-full">
|
|
<SummaryChip
|
|
label="Status"
|
|
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
|
|
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
|
|
editable
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
|
<div className="p-3 space-y-2">
|
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar status</p>
|
|
<StatusSelect value={ticket.status} onValueChange={handleStatusChange} />
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<SummaryChip
|
|
label="Status"
|
|
value={getTicketStatusLabel(ticket.status) ?? ticket.status}
|
|
tone={getTicketStatusSummaryTone(ticket.status) as SummaryTone}
|
|
/>
|
|
)}
|
|
|
|
{/* Prioridade - editável */}
|
|
{canEdit ? (
|
|
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button type="button" className="text-left w-full">
|
|
<SummaryChip
|
|
label="Prioridade"
|
|
value={priorityLabel[ticket.priority] ?? ticket.priority}
|
|
tone={priorityTone[ticket.priority] ?? "default"}
|
|
editable
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
|
<div className="p-3 space-y-2">
|
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar prioridade</p>
|
|
<PrioritySelect value={ticket.priority} onValueChange={handlePriorityChange} />
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<SummaryChip
|
|
label="Prioridade"
|
|
value={priorityLabel[ticket.priority] ?? ticket.priority}
|
|
tone={priorityTone[ticket.priority] ?? "default"}
|
|
/>
|
|
)}
|
|
|
|
{/* Responsável - editável */}
|
|
{canEdit ? (
|
|
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button type="button" className="text-left w-full">
|
|
<SummaryChip
|
|
label="Responsável"
|
|
value={ticket.assignee?.name ?? "Não atribuído"}
|
|
tone={ticket.assignee ? "default" : "muted"}
|
|
editable
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-64 p-0" align="start" sideOffset={4}>
|
|
<div className="p-3 space-y-2">
|
|
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide">Alterar responsavel</p>
|
|
<Select value={ticket.assignee?.id ?? "__none__"} onValueChange={handleAssigneeChange}>
|
|
<SelectTrigger className="h-10 bg-white">
|
|
<SelectValue placeholder="Selecione o responsavel" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none__">Nao atribuido</SelectItem>
|
|
{agents?.map((a) => (
|
|
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : (
|
|
<SummaryChip
|
|
label="Responsável"
|
|
value={ticket.assignee?.name ?? "Não atribuído"}
|
|
tone={ticket.assignee ? "default" : "muted"}
|
|
/>
|
|
)}
|
|
|
|
{/* Fluxo - não editável */}
|
|
{ticket.formTemplateLabel && (
|
|
<SummaryChip
|
|
label="Fluxo"
|
|
value={ticket.formTemplateLabel}
|
|
tone="primary"
|
|
labelClassName="text-white"
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{canShowRemoteAccess ? (
|
|
<section className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
|
{primaryRemoteAccess ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
|
<div className="space-y-1 text-sm text-neutral-700">
|
|
<p className="font-medium text-neutral-900">
|
|
Conecte-se remotamente à máquina vinculada a este ticket.
|
|
</p>
|
|
{remoteHostname ? (
|
|
<p className="text-xs text-neutral-500">Host: {remoteHostname}</p>
|
|
) : null}
|
|
{primaryRemoteAccess.identifier ? (
|
|
<p className="text-xs text-neutral-500">ID RustDesk: {primaryRemoteAccess.identifier}</p>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="inline-flex items-center gap-2 border-slate-300 bg-white text-sm font-semibold text-slate-800 shadow-sm hover:border-slate-400 hover:bg-slate-50"
|
|
onClick={handleRemoteConnect}
|
|
>
|
|
<MonitorSmartphone className="size-4 text-slate-700" />
|
|
Acessar remoto
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-neutral-500">
|
|
Nenhum acesso remoto RustDesk está cadastrado para a máquina deste ticket.
|
|
</p>
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
<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">SLA & métricas</h3>
|
|
{ticket.slaSnapshot ? (
|
|
<span className="text-xs font-medium text-neutral-500">
|
|
{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Política de SLA</p>
|
|
{ticket.slaSnapshot ? (
|
|
<div className="mt-3 space-y-4 text-sm text-neutral-700">
|
|
<div>
|
|
<span className="text-xs text-neutral-500">Categoria</span>
|
|
<p className="font-semibold text-neutral-900">{ticket.slaSnapshot.categoryName ?? "Categoria padrão"}</p>
|
|
<p className="text-xs text-neutral-500">
|
|
Prioridade: {priorityLabel[ticket.priority] ?? ticket.priority}
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<SlaMetric
|
|
label="Resposta"
|
|
target={formatSlaTarget(ticket.slaSnapshot.responseTargetMinutes, ticket.slaSnapshot.responseMode)}
|
|
dueDate={responseDue}
|
|
status={responseStatus}
|
|
/>
|
|
<SlaMetric
|
|
label="Resolução"
|
|
target={formatSlaTarget(ticket.slaSnapshot.solutionTargetMinutes, ticket.slaSnapshot.solutionMode)}
|
|
dueDate={solutionDue}
|
|
status={solutionStatus}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-sm text-neutral-600">Sem política de SLA atribuída.</p>
|
|
)}
|
|
</div>
|
|
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Métricas</p>
|
|
{ticket.metrics ? (
|
|
<div className="mt-3 space-y-2 text-sm text-neutral-700">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-xs text-neutral-500">Tempo aguardando</span>
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{formatMinutes(ticket.metrics.timeWaitingMinutes)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-xs text-neutral-500">Tempo aberto</span>
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{formatMinutes(ticket.metrics.timeOpenedMinutes)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="mt-3 text-sm text-neutral-600">Sem dados registrados ainda.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
|
{agentTotals.length > 0 ? (
|
|
<div className="grid gap-2">
|
|
{agentTotals.map((agent) => (
|
|
<div
|
|
key={agent.agentId}
|
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{agent.agentName ?? "Agente removido"}
|
|
</span>
|
|
<span className="text-sm font-semibold text-neutral-900">
|
|
{formatDuration(agent.totalWorkedMs)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-4 text-xs text-neutral-500">
|
|
<span>
|
|
Interno:{" "}
|
|
<span className="font-medium text-neutral-800">{formatDuration(agent.internalWorkedMs)}</span>
|
|
</span>
|
|
<span>
|
|
Externo:{" "}
|
|
<span className="font-medium text-neutral-800">{formatDuration(agent.externalWorkedMs)}</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-neutral-600">Nenhum tempo registrado neste ticket ainda.</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-sm font-semibold text-neutral-900">Histórico</h3>
|
|
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm text-sm text-neutral-700">
|
|
<div className="flex items-center justify-between gap-4 border-b border-slate-100 pb-2">
|
|
<span className="text-xs uppercase tracking-wide text-neutral-500">Criado em</span>
|
|
<span className="font-semibold text-neutral-900">
|
|
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 border-b border-slate-100 py-2">
|
|
<span className="text-xs uppercase tracking-wide text-neutral-500">Atualizado em</span>
|
|
<span className="font-semibold text-neutral-900">
|
|
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-4 pt-2">
|
|
<span className="text-xs uppercase tracking-wide text-neutral-500">Resolvido em</span>
|
|
<span className="font-semibold text-neutral-900">
|
|
{ticket.resolvedAt ? format(ticket.resolvedAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "—"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function SummaryChip({
|
|
label,
|
|
value,
|
|
tone = "default",
|
|
labelClassName,
|
|
editable = false,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
tone?: SummaryTone
|
|
labelClassName?: string
|
|
editable?: boolean
|
|
}) {
|
|
const toneClasses: Record<SummaryTone, string> = {
|
|
default: "border-slate-200 bg-white text-neutral-900",
|
|
info: "border-sky-200 bg-sky-50 text-sky-900",
|
|
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
|
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 transition-all",
|
|
toneClasses[tone],
|
|
editable && "cursor-pointer hover:ring-2 hover:ring-slate-300 hover:border-slate-400"
|
|
)}
|
|
>
|
|
<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>
|
|
)
|
|
}
|
|
|
|
interface SlaMetricProps {
|
|
label: string
|
|
target: string
|
|
dueDate: Date | null
|
|
status: SlaDisplayStatus
|
|
}
|
|
|
|
function SlaMetric({ label, target, dueDate, status }: SlaMetricProps) {
|
|
const display = getSlaStatusDisplay(status)
|
|
return (
|
|
<div className="rounded-xl border border-slate-200 bg-white px-3 py-3 shadow-sm">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-xs text-neutral-500">{label}</p>
|
|
<p className="text-sm font-semibold text-neutral-900">{target}</p>
|
|
{dueDate ? (
|
|
<p className="text-xs text-neutral-500">{format(dueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}</p>
|
|
) : (
|
|
<p className="text-xs text-neutral-500">Sem prazo calculado</p>
|
|
)}
|
|
</div>
|
|
<span className={cn("text-xs font-semibold uppercase", display.className)}>{display.label}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|