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:
parent
881bb7bfdd
commit
6c57c691f3
14 changed files with 512 additions and 307 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue