From 6c57c691f312fbd1e19a75956adf67022a5528b5 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 17:13:13 -0300 Subject: [PATCH] =?UTF-8?q?feat(ui,tickets):=20aplicar=20visual=20Rever=20?= =?UTF-8?q?(badges=20revertidas),=20header=20com=20play/pause,=20edi=C3=A7?= =?UTF-8?q?=C3=A3o=20inline=20com=20cancelar,=20empty=20states=20e=20toast?= =?UTF-8?q?s=20centralizados;=20novas=20mutations=20Convex=20(updateSubjec?= =?UTF-8?q?t/updateSummary/toggleWork)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/convex/schema.ts | 1 + web/convex/tickets.ts | 54 +++++ web/src/app/layout.tsx | 10 +- .../tickets/play-next-ticket-card.tsx | 6 +- web/src/components/tickets/priority-pill.tsx | 70 +++--- .../components/tickets/priority-select.tsx | 30 ++- web/src/components/tickets/status-badge.tsx | 26 +-- web/src/components/tickets/status-select.tsx | 74 ++++++ .../tickets/ticket-comments.rich.tsx | 77 +++--- .../tickets/ticket-summary-header.tsx | 221 ++++++++++-------- .../components/tickets/ticket-timeline.tsx | 13 +- web/src/components/tickets/tickets-table.tsx | 159 +++++++------ web/src/components/ui/badge.tsx | 40 ++-- web/src/components/ui/sonner.tsx | 38 +-- 14 files changed, 512 insertions(+), 307 deletions(-) create mode 100644 web/src/components/tickets/status-select.tsx diff --git a/web/convex/schema.ts b/web/convex/schema.ts index 2156464..62ddd5f 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -48,6 +48,7 @@ export default defineSchema({ queueId: v.optional(v.id("queues")), requesterId: v.id("users"), assigneeId: v.optional(v.id("users")), + working: v.optional(v.boolean()), slaPolicyId: v.optional(v.id("slaPolicies")), dueAt: v.optional(v.number()), // ms since epoch firstResponseAt: v.optional(v.number()), diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 7f2edcc..2c91238 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -220,6 +220,7 @@ export const create = mutation({ queueId: args.queueId, requesterId: args.requesterId, assigneeId: undefined, + working: false, createdAt: now, updatedAt: now, 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({ args: { tenantId: v.string(), diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4e21d87..d8aafff 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({ display: "swap", }) -export const metadata: Metadata = { - title: "Atlas Support", - description: "Plataforma omnichannel de gestão de chamados", -} +export const metadata: Metadata = { + title: "Sistema de chamados", + description: "Plataforma de chamados da Rever", +} export default async function RootLayout({ children, @@ -43,7 +43,7 @@ export default async function RootLayout({ {children} - + diff --git a/web/src/components/tickets/play-next-ticket-card.tsx b/web/src/components/tickets/play-next-ticket-card.tsx index f289c58..944a5ff 100644 --- a/web/src/components/tickets/play-next-ticket-card.tsx +++ b/web/src/components/tickets/play-next-ticket-card.tsx @@ -47,7 +47,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { if (!cardContext || !cardContext.nextTicket) { return ( - + Fila sem tickets pendentes @@ -60,8 +60,8 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { const ticket = cardContext.nextTicket - return ( - + return ( + Proximo ticket • #{ticket.reference} diff --git a/web/src/components/tickets/priority-pill.tsx b/web/src/components/tickets/priority-pill.tsx index c16b19e..2985e92 100644 --- a/web/src/components/tickets/priority-pill.tsx +++ b/web/src/components/tickets/priority-pill.tsx @@ -1,40 +1,40 @@ -import { cn } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" - -const priorityConfig = { - LOW: { - label: "Baixa", - className: "bg-slate-100 text-slate-600 border-transparent", - }, - MEDIUM: { - label: "Media", - className: "bg-blue-100 text-blue-600 border-transparent", - }, - HIGH: { - label: "Alta", - className: "bg-amber-100 text-amber-700 border-transparent", - }, - URGENT: { - label: "Urgente", - className: "bg-red-100 text-red-700 border-transparent", - }, -} satisfies Record +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +const priorityConfig = { + LOW: { + label: "Baixa", + className: "bg-slate-100 text-slate-600 border-transparent", + }, + MEDIUM: { + label: "Media", + className: "bg-blue-100 text-blue-600 border-transparent", + }, + HIGH: { + label: "Alta", + className: "bg-amber-100 text-amber-700 border-transparent", + }, + URGENT: { + label: "Urgente", + className: "bg-red-100 text-red-700 border-transparent", + }, +} satisfies Record type TicketPriorityPillProps = { priority: keyof typeof priorityConfig } -export function TicketPriorityPill({ priority }: TicketPriorityPillProps) { - const config = priorityConfig[priority] - return ( - - {config?.label ?? priority} - - ) -} +export function TicketPriorityPill({ priority }: TicketPriorityPillProps) { + const config = priorityConfig[priority] + return ( + + {config?.label ?? priority} + + ) +} diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx index 34e53ab..cb0872c 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -5,19 +5,21 @@ import { useMutation } from "convex/react" // @ts-ignore import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" +import type { TicketPriority } 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" +import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react" -const labels: Record = { +const labels: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente", } -function badgeClass(p: string) { +function badgeClass(p: TicketPriority) { switch (p) { case "URGENT": 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 + if (p === "MEDIUM") return + if (p === "HIGH") return + return +} + +export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) { const updatePriority = useMutation(api.tickets.updatePriority) - const [priority, setPriority] = useState(value) + const [priority, setPriority] = useState(value) const { userId } = useAuth() return ( ) } + diff --git a/web/src/components/tickets/status-badge.tsx b/web/src/components/tickets/status-badge.tsx index 600442e..7c4223a 100644 --- a/web/src/components/tickets/status-badge.tsx +++ b/web/src/components/tickets/status-badge.tsx @@ -3,13 +3,13 @@ import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket" import { Badge } from "@/components/ui/badge" -const statusConfig = { - NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" }, - OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" }, - PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" }, - ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, - RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, - CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, +const statusConfig = { + NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" }, + OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" }, + PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" }, + ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" }, + RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" }, + CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" }, } satisfies Record type TicketStatusBadgeProps = { status: TicketStatus } @@ -17,11 +17,11 @@ type TicketStatusBadgeProps = { status: TicketStatus } export function TicketStatusBadge({ status }: TicketStatusBadgeProps) { const config = statusConfig[status] return ( - - {config?.label ?? status} - + + {config?.label ?? status} + ) } diff --git a/web/src/components/tickets/status-select.tsx b/web/src/components/tickets/status-select.tsx new file mode 100644 index 0000000..f227972 --- /dev/null +++ b/web/src/components/tickets/status-select.tsx @@ -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 = { + 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(value) + const { userId } = useAuth() + return ( + + ) +} diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index e2fecc6..7b86312 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -21,6 +21,7 @@ import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { Dialog, DialogContent } from "@/components/ui/dialog" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" interface TicketCommentsProps { ticket: TicketWithDetails @@ -57,7 +58,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { setPending((p) => [optimistic, ...p]) setBody("") setAttachmentsToSend([]) - toast.loading("Enviando comentário.", { id: "comment" }) + toast.loading("Enviando comentário...", { id: "comment" }) try { const typedAttachments = attachments.map((a) => ({ storageId: a.storageId as unknown as Id<"_storage">, @@ -83,9 +84,15 @@ export function TicketComments({ ticket }: TicketCommentsProps) { {commentsAll.length === 0 ? ( -

- Ainda sem comentários. Que tal registrar o próximo passo? -

+ + + + + + Nenhum comentário ainda + Registre o próximo passo abaixo. + + ) : ( commentsAll.map((comment) => { const initials = comment.author.name @@ -111,38 +118,38 @@ export function TicketComments({ ticket }: TicketCommentsProps) { {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} -
+
- {comment.attachments?.length ? ( -
- {comment.attachments.map((att) => { - const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i) - if (isImg && att.url) { - return ( - - ) - } - return ( - - {att.name} - {att.url ? : null} - - ) - })} -
- ) : null} + {comment.attachments?.length ? ( +
+ {comment.attachments.map((att) => { + const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i) + if (isImg && att.url) { + return ( + + ) + } + return ( + + {att.name} + {att.url ? : null} + + ) + })} +
+ ) : null}
) @@ -154,7 +161,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
Visibilidade: - setVisibility(v as "PUBLIC" | "INTERNAL")}> Pública diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 89edf2d..4d40c78 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -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(ticket.status) - const statusPt: Record = { - NEW: "Novo", - OPEN: "Aberto", - PENDING: "Pendente", - ON_HOLD: "Em espera", - RESOLVED: "Resolvido", - CLOSED: "Fechado", + const [status] = useState(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 (
@@ -49,57 +76,67 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { #{ticket.reference} - - - + {isPlaying ? (<> Pausar) : (<> Iniciar)} +
-

{ticket.subject}

-

{ticket.summary}

+ {editing ? ( +
+ setSubject(e.target.value)} className="h-9 text-base font-semibold" /> +