feat(ui,tickets): aplicar visual Rever (badges revertidas), header com play/pause, edição inline com cancelar, empty states e toasts centralizados; novas mutations Convex (updateSubject/updateSummary/toggleWork)

This commit is contained in:
esdrasrenan 2025-10-04 17:13:13 -03:00
parent 881bb7bfdd
commit 6c57c691f3
14 changed files with 512 additions and 307 deletions

View file

@ -1,9 +1,9 @@
"use client"
import { useState } from "react"
import { useMemo, useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -15,32 +15,59 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject)
const updateSummary = useMutation(api.tickets.updateSummary)
const toggleWork = useMutation(api.tickets.toggleWork)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
const [status] = useState<TicketStatus>(ticket.status)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
async function handleSave() {
if (!userId) return
toast.loading("Salvando alterações...", { id: "save-header" })
try {
if (subject !== ticket.subject) {
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
setEditing(false)
} catch {
toast.error("Não foi possível salvar.", { id: "save-header" })
}
}
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setEditing(false)
}
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
const isPlaying = lastWork?.type === "WORK_STARTED"
return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
@ -49,57 +76,67 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
#{ticket.reference}
</Badge>
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
<TicketStatusBadge status={status} />
<Select
value={status}
onValueChange={async (value) => {
const prev = status
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<Button
size="sm"
variant={isPlaying ? "default" : "outline"}
className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
onClick={async () => {
if (!userId) return
toast.loading("Atualizando status…", { id: "status" })
try {
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
toast.error("Não foi possível alterar o status.", { id: "status" })
}
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
if (next) toast.success("Atendimento iniciado", { id: "work" })
else toast.success("Atendimento pausado", { id: "work" })
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder="Alterar status" />
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
))}
</SelectContent>
</Select>
{isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
</Button>
</div>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
{editing ? (
<div className="space-y-2">
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-base font-semibold" />
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full rounded-md border bg-background p-2 text-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<>
<h1 className="break-words text-2xl font-semibold text-foreground">{subject}</h1>
{summary ? (
<p className="max-w-2xl text-sm text-muted-foreground">{summary}</p>
) : null}
</>
)}
<div className="ms-auto flex items-center gap-2">
<DeleteTicketDialog ticketId={ticket.id as any} />
{editing ? (
<>
<Button variant="ghost" size="sm" onClick={handleCancel}>Cancelar</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty}>Salvar</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>Editar</Button>
)}
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
</div>
</div>
<Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Solicitante:
<div className="flex flex-col gap-1">
<span className="text-xs">Solicitante</span>
<span className="font-medium text-foreground">{ticket.requester.name}</span>
</div>
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<div className="flex flex-col gap-1">
<span className="text-xs">Responsável</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
toast.loading("Atribuindo responsável", { id: "assignee" })
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
@ -108,33 +145,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}}
>
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectTrigger className="h-8 w-[220px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a._id} value={a._id}>
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-full bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
{a.avatarUrl ? <img src={a.avatarUrl} alt={a.name} className="h-6 w-6 rounded-full object-cover" /> : <span className="text-[10px] font-medium">{a.name.split(' ').slice(0,2).map(p=>p[0]).join('').toUpperCase()}</span>}
</span>
{a.name}
</span>
</SelectItem>
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
<div className="flex flex-col gap-1">
<span className="text-xs">Fila</span>
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq) => qq.name === value)
if (!q) return
toast.loading("Atualizando fila", { id: "queue" })
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
@ -143,7 +170,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectTrigger className="h-8 w-[180px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
@ -151,37 +178,27 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Atualizado em:
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
<div className="flex flex-col gap-1">
<span className="text-xs">Atualizado em</span>
<span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Criado em:
<span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{ticket.dueAt ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
SLA ate:
<span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>
</div>
)
}
<div className="flex flex-col gap-1">
<span className="text-xs">Criado em</span>
<span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
{ticket.dueAt ? (
<div className="flex flex-col gap-1">
<span className="text-xs">SLA até</span>
<span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex flex-col gap-1">
<span className="text-xs">Política</span>
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>
</div>
)
}