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

@ -48,6 +48,7 @@ export default defineSchema({
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
requesterId: v.id("users"), requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")), assigneeId: v.optional(v.id("users")),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")), slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()), firstResponseAt: v.optional(v.number()),

View file

@ -220,6 +220,7 @@ export const create = mutation({
queueId: args.queueId, queueId: args.queueId,
requesterId: args.requesterId, requesterId: args.requesterId,
assigneeId: undefined, assigneeId: undefined,
working: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
firstResponseAt: undefined, firstResponseAt: undefined,
@ -348,6 +349,59 @@ export const updatePriority = mutation({
}, },
}); });
export const toggleWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
const t = await ctx.db.get(ticketId)
if (!t) return
const now = Date.now()
const next = !(t.working ?? false)
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: next ? "WORK_STARTED" : "WORK_PAUSED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
createdAt: now,
})
return next
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { subject, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateSummary = mutation({
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUMMARY_CHANGED",
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const playNext = mutation({ export const playNext = mutation({
args: { args: {
tenantId: v.string(), tenantId: v.string(),

View file

@ -19,8 +19,8 @@ const jetBrainsMono = JetBrains_Mono({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Atlas Support", title: "Sistema de chamados",
description: "Plataforma omnichannel de gestão de chamados", description: "Plataforma de chamados da Rever",
} }
export default async function RootLayout({ export default async function RootLayout({
@ -43,7 +43,7 @@ export default async function RootLayout({
<ConvexClientProvider> <ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}> <AuthProvider demoUser={demoUser} tenantId={tenantId}>
{children} {children}
<Toaster position="top-right" richColors /> <Toaster position="bottom-center" richColors />
</AuthProvider> </AuthProvider>
</ConvexClientProvider> </ConvexClientProvider>
</body> </body>

View file

@ -47,7 +47,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
if (!cardContext || !cardContext.nextTicket) { if (!cardContext || !cardContext.nextTicket) {
return ( return (
<Card className="border-dashed"> <Card className="rounded-xl border bg-card shadow-sm">
<CardHeader> <CardHeader>
<CardTitle>Fila sem tickets pendentes</CardTitle> <CardTitle>Fila sem tickets pendentes</CardTitle>
</CardHeader> </CardHeader>
@ -61,7 +61,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const ticket = cardContext.nextTicket const ticket = cardContext.nextTicket
return ( return (
<Card className="border-dashed"> <Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-2"> <CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold"> <CardTitle className="text-lg font-semibold">
Proximo ticket #{ticket.reference} Proximo ticket #{ticket.reference}

View file

@ -5,19 +5,21 @@ import { useMutation } from "convex/react"
// @ts-ignore // @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { toast } from "sonner" import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
const labels: Record<string, string> = { const labels: Record<TicketPriority, string> = {
LOW: "Baixa", LOW: "Baixa",
MEDIUM: "Média", MEDIUM: "Média",
HIGH: "Alta", HIGH: "Alta",
URGENT: "Urgente", URGENT: "Urgente",
} }
function badgeClass(p: string) { function badgeClass(p: TicketPriority) {
switch (p) { switch (p) {
case "URGENT": case "URGENT":
return "bg-red-100 text-red-700" return "bg-red-100 text-red-700"
@ -30,20 +32,29 @@ function badgeClass(p: string) {
} }
} }
export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) { function PriorityIcon({ p }: { p: TicketPriority }) {
const cls = "size-3.5 text-cyan-600"
if (p === "LOW") return <ArrowDown className={cls} />
if (p === "MEDIUM") return <ArrowRight className={cls} />
if (p === "HIGH") return <ArrowUp className={cls} />
return <ChevronsUp className={cls} />
}
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority) const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState(value) const [priority, setPriority] = useState<TicketPriority>(value)
const { userId } = useAuth() const { userId } = useAuth()
return ( return (
<Select <Select
value={priority} value={priority}
onValueChange={async (val) => { onValueChange={async (val) => {
const prev = priority const prev = priority
setPriority(val as typeof priority) const next = val as TicketPriority
setPriority(next)
toast.loading("Atualizando prioridade...", { id: "prio" }) toast.loading("Atualizando prioridade...", { id: "prio" })
try { try {
if (!userId) throw new Error("No user") if (!userId) throw new Error("No user")
await updatePriority({ ticketId, priority: val as any, actorId: userId as Id<"users"> }) await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "prio" }) toast.success("Prioridade atualizada!", { id: "prio" })
} catch { } catch {
setPriority(prev) setPriority(prev)
@ -53,16 +64,19 @@ export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; v
> >
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2"> <SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
<SelectValue> <SelectValue>
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>{labels[priority]}</Badge> <Badge className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>
<PriorityIcon p={priority} /> {labels[priority]}
</Badge>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => ( {(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
<SelectItem key={p} value={p}> <SelectItem key={p} value={p}>
<span className="inline-flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${p==="URGENT"?"bg-red-500":p==="HIGH"?"bg-amber-500":p==="MEDIUM"?"bg-blue-500":"bg-slate-400"}`}></span>{labels[p]}</span> <span className="inline-flex items-center gap-2"><PriorityIcon p={p} />{labels[p]}</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) )
} }

View file

@ -0,0 +1,74 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
const labels: Record<TicketStatus, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
function badgeClass(s: TicketStatus) {
switch (s) {
case "OPEN":
return "bg-blue-100 text-blue-700"
case "PENDING":
return "bg-amber-100 text-amber-700"
case "ON_HOLD":
return "bg-purple-100 text-purple-700"
case "RESOLVED":
return "bg-emerald-100 text-emerald-700"
case "CLOSED":
return "bg-slate-100 text-slate-700"
default:
return "bg-slate-100 text-slate-700"
}
}
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { userId } = useAuth()
return (
<Select
value={status}
onValueChange={async (val) => {
const prev = status
const next = val as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!userId) throw new Error("No user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${labels[next] ?? next}.`, { id: "status" })
} catch {
setStatus(prev)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className="h-7 w-[160px] border-transparent bg-muted/50 px-2">
<SelectValue>
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(status)}`}>{labels[status]}</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"] as const).map((s) => (
<SelectItem key={s} value={s}>{labels[s as TicketStatus]}</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -21,6 +21,7 @@ import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
interface TicketCommentsProps { interface TicketCommentsProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -57,7 +58,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
setPending((p) => [optimistic, ...p]) setPending((p) => [optimistic, ...p])
setBody("") setBody("")
setAttachmentsToSend([]) setAttachmentsToSend([])
toast.loading("Enviando comentário.", { id: "comment" }) toast.loading("Enviando comentário...", { id: "comment" })
try { try {
const typedAttachments = attachments.map((a) => ({ const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">, storageId: a.storageId as unknown as Id<"_storage">,
@ -83,9 +84,15 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-6 px-4 pb-6"> <CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? ( {commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground"> <Empty>
Ainda sem comentários. Que tal registrar o próximo passo? <EmptyHeader>
</p> <EmptyMedia variant="icon">
<IconMessage className="size-5" />
</EmptyMedia>
<EmptyTitle>Nenhum comentário ainda</EmptyTitle>
<EmptyDescription>Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : ( ) : (
commentsAll.map((comment) => { commentsAll.map((comment) => {
const initials = comment.author.name const initials = comment.author.name
@ -111,7 +118,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span> </span>
</div> </div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words"> <div className="break-words rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
<RichTextContent html={comment.body} /> <RichTextContent html={comment.body} />
</div> </div>
{comment.attachments?.length ? ( {comment.attachments?.length ? (

View file

@ -1,9 +1,9 @@
"use client" "use client"
import { useState } from "react" import { useMemo, useState } from "react"
import { format } from "date-fns" import { format } from "date-fns"
import { ptBR } from "date-fns/locale" 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 { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -15,11 +15,12 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket" import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { PrioritySelect } from "@/components/tickets/priority-select" import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog" 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" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -27,20 +28,46 @@ interface TicketHeaderProps {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth() const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee) const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue) 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 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 queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState<TicketStatus>(ticket.status) const [status] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo", const [editing, setEditing] = useState(false)
OPEN: "Aberto", const [subject, setSubject] = useState(ticket.subject)
PENDING: "Pendente", const [summary, setSummary] = useState(ticket.summary ?? "")
ON_HOLD: "Em espera", const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
RESOLVED: "Resolvido",
CLOSED: "Fechado", 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 ( return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm"> <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"> <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"> <Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
#{ticket.reference} #{ticket.reference}
</Badge> </Badge>
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} /> <PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<TicketStatusBadge status={status} /> <StatusSelect ticketId={ticket.id} value={status} />
<Select <Button
value={status} size="sm"
onValueChange={async (value) => { variant={isPlaying ? "default" : "outline"}
const prev = status className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista onClick={async () => {
if (!userId) return if (!userId) return
toast.loading("Atualizando status…", { id: "status" }) const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
try { if (next) toast.success("Atendimento iniciado", { id: "work" })
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> }) else toast.success("Atendimento pausado", { id: "work" })
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" })
}
}} }}
> >
<SelectTrigger className="h-8 w-[150px]"> {isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
<SelectValue placeholder="Alterar status" /> </Button>
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1> {editing ? (
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p> <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"> <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> </div>
</div> </div>
<Separator /> <Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3"> <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"> <div className="flex flex-col gap-1">
<IconUserCircle className="size-4" /> <span className="text-xs">Solicitante</span>
Solicitante:
<span className="font-medium text-foreground">{ticket.requester.name}</span> <span className="font-medium text-foreground">{ticket.requester.name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconUserCircle className="size-4" /> <span className="text-xs">Responsável</span>
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<Select <Select
value={ticket.assignee?.id ?? ""} value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!userId) return if (!userId) return
toast.loading("Atribuindo responsável", { id: "assignee" }) toast.loading("Atribuindo responsável...", { id: "assignee" })
try { try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> }) 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" }) 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> <SelectContent>
{agents.map((a) => ( {agents.map((a) => (
<SelectItem key={a._id} value={a._id}> <SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
<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>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className="text-xs">Fila</span>
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
<Select <Select
value={ticket.queue ?? ""} value={ticket.queue ?? ""}
onValueChange={async (value) => { onValueChange={async (value) => {
if (!userId) return if (!userId) return
const q = queues.find((qq) => qq.name === value) const q = queues.find((qq) => qq.name === value)
if (!q) return if (!q) return
toast.loading("Atualizando fila", { id: "queue" }) toast.loading("Atualizando fila...", { id: "queue" })
try { try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> }) 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" }) 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> <SelectContent>
{queues.map((q) => ( {queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem> <SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
@ -151,33 +178,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className="text-xs">Atualizado em</span>
Atualizado em: <span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className="text-xs">Criado em</span>
Criado em: <span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div> </div>
{ticket.dueAt ? ( {ticket.dueAt ? (
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className="text-xs">SLA até</span>
SLA ate: <span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
</div> </div>
) : null} ) : null}
{ticket.slaPolicy ? ( {ticket.slaPolicy ? (
<div className="flex items-center gap-2"> <div className="flex flex-col gap-1">
<IconClock className="size-4" /> <span className="text-xs">Política</span>
Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span> <span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
</div> </div>
) : null} ) : null}

View file

@ -19,6 +19,10 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
STATUS_CHANGED: IconSquareCheck, STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle, ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote, COMMENT_ADDED: IconNote,
WORK_STARTED: IconClockHour4,
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote,
} }
const timelineLabels: Record<string, string> = { const timelineLabels: Record<string, string> = {
@ -26,6 +30,10 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado", STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado", ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado", COMMENT_ADDED: "Comentário adicionado",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
QUEUE_CHANGED: "Fila alterada", QUEUE_CHANGED: "Fila alterada",
} }
@ -69,13 +77,15 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</span> </span>
</div> </div>
{(() => { {(() => {
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string } const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string; from?: string }
let message: string | null = null let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}` if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}` if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}` if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}` if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}` if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
if (entry.type === "SUBJECT_CHANGED" && (p.to || p.toLabel)) message = `Assunto alterado${p.to ? ` para “${p.to}` : ""}`
if (entry.type === "SUMMARY_CHANGED") message = `Resumo atualizado`
if (!message) return null if (!message) return null
return ( return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
@ -92,3 +102,4 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
) )
} }

View file

@ -7,6 +7,8 @@ import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import { import {
Table, Table,
TableBody, TableBody,
@ -159,14 +161,29 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableBody> </TableBody>
</Table> </Table>
{tickets.length === 0 && ( {tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm"> <Empty className="my-6">
<p className="text-sm font-medium">Nenhum ticket encontrado</p> <EmptyHeader>
<p className="text-sm text-muted-foreground"> <EmptyMedia variant="icon">
Ajuste os filtros ou selecione outra fila. <span className="inline-block size-3 rounded-full bg-muted-foreground/40" />
</p> </EmptyMedia>
</div> <EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou crie um novo ticket.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<NewTicketDialog />
</EmptyContent>
</Empty>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View file

@ -10,6 +10,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
toastOptions={{
classNames: {
toast: "border border-black bg-black text-white shadow-md",
title: "font-medium",
description: "text-white/80",
icon: "text-cyan-400",
actionButton: "bg-white text-black border border-black",
cancelButton: "bg-transparent text-white border border-white/40",
},
}}
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg": "var(--popover)",