diff --git a/agents.md b/agents.md index 7aab815..526ea39 100644 --- a/agents.md +++ b/agents.md @@ -265,3 +265,35 @@ Médio prazo: - Prioridade: alterar no cabeçalho; observar evento de timeline e toasts. - Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`. - Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo. + +--- + +## Atualizações recentes (abr/2025) + +Resumo do que foi integrado nesta rodada para o núcleo de tickets e UX: + +- Header do ticket + - Status como dropdown‑badge (padrão visual alinhado às badges existentes). + - Edição inline de Assunto/Resumo com Cancelar/Salvar e toasts. + - Ação de Play/Pause (toggle de atendimento) com eventos WORK_STARTED/WORK_PAUSED na timeline. + - Layout dos campos reorganizado: labels acima e controles abaixo (evita redundância do valor + dropdown lado a lado). +- Tabela e comentários + - Empty states padronizados com Empty + CTA de novo ticket. +- Notificações + - Toaster centralizado no rodapé (bottom‑center) com estilo consistente. +- Título do app + - Atualizado para “Sistema de chamados”. + +Backend Convex +- ickets.updateSubject e ickets.updateSummary adicionadas para edição do cabeçalho. +- ickets.toggleWork adicionada; campo opcional working no schema de ickets. + +Próximos passos sugeridos +- Status dropdown‑badge também na tabela (edição rápida opcional com confirmação). +- Combobox (command) para busca de responsável no select. +- Tokens de cor: manter badges padrão do design atual; quando migração completa para paleta Rever estiver definida, aplicar via globals.css para herdar em todos os componentes. +- Testes (Vitest): adicionar casos de mappers e smoke tests de páginas. + +Observações de codificação +- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex. +- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod. 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..693953f 100644 --- a/web/src/components/tickets/play-next-ticket-card.tsx +++ b/web/src/components/tickets/play-next-ticket-card.tsx @@ -5,7 +5,6 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -21,11 +20,15 @@ import { TicketPriorityPill } from "@/components/tickets/priority-pill" import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" - -interface PlayNextTicketCardProps { - context?: TicketPlayContext -} - + +interface PlayNextTicketCardProps { + context?: TicketPlayContext +} + +const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700" +const startButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]" +const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 hover:bg-slate-100" + export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { const router = useRouter() const { userId } = useAuth() @@ -43,85 +46,110 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) { })?.[0] const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null - const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null) + const cardContext: TicketPlayContext | null = + context ?? + (nextTicketUi + ? { + queue: { + id: "default", + name: "Geral", + pending: queueSummary.reduce((acc, item) => acc + item.pending, 0), + waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0), + breached: 0, + }, + nextTicket: nextTicketUi, + } + : null) if (!cardContext || !cardContext.nextTicket) { return ( - + - Fila sem tickets pendentes + Fila sem tickets pendentes - - Nenhum ticket disponivel no momento. Excelente trabalho! + + Nenhum ticket disponível no momento. Excelente trabalho! ) } const ticket = cardContext.nextTicket - - return ( - - - - Proximo ticket • #{ticket.reference} - - - - + + return ( + + + + Próximo ticket • #{ticket.reference} + + + +
- Fila: - setSelectedQueueId(value === "ALL" ? undefined : value)}> + + + + Todas - {queueSummary.map((q) => ( - {q.name} + {queueSummary.map((queue) => ( + + {queue.name} + ))}
-

{ticket.subject}

-

{ticket.summary}

+

{ticket.subject}

+

{ticket.summary}

-
- {ticket.queue ?? "Sem fila"} - - Solicitante: {ticket.requester.name} -
- -
+
+ {ticket.queue ?? "Sem fila"} + + Solicitante: {ticket.requester.name} +
+ +
Pendentes na fila - {cardContext.queue.pending} + {cardContext.queue.pending}
Em espera - {cardContext.queue.waiting} + {cardContext.queue.waiting}
SLA violado - {cardContext.queue.breached} + {cardContext.queue.breached}
- - - - ) + + + + ) } diff --git a/web/src/components/tickets/priority-pill.tsx b/web/src/components/tickets/priority-pill.tsx index c16b19e..376965f 100644 --- a/web/src/components/tickets/priority-pill.tsx +++ b/web/src/components/tickets/priority-pill.tsx @@ -1,40 +1,21 @@ -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} - - ) -} +import { type TicketPriority } from "@/lib/schemas/ticket" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +const priorityStyles: Record = { + LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" }, + MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, + HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" }, + URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" }, +} + +const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold" + +export function TicketPriorityPill({ priority }: { priority: TicketPriority }) { + const styles = priorityStyles[priority] + return ( + + {styles?.label ?? priority} + + ) +} diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx index 34e53ab..d3e82dd 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -5,61 +5,71 @@ 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" +import { cn } from "@/lib/utils" -const labels: Record = { - LOW: "Baixa", - MEDIUM: "Média", - HIGH: "Alta", - URGENT: "Urgente", +const priorityStyles: Record = { + LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" }, + MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, + HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" }, + URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" }, } -function badgeClass(p: string) { - switch (p) { - case "URGENT": - return "bg-red-100 text-red-700" - case "HIGH": - return "bg-amber-100 text-amber-700" - case "MEDIUM": - return "bg-blue-100 text-blue-700" - default: - return "bg-slate-100 text-slate-700" - } +const triggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const itemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10" +const iconClass = "size-4 text-neutral-700" +const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold" + +function PriorityIcon({ value }: { value: TicketPriority }) { + if (value === "LOW") return + if (value === "MEDIUM") return + if (value === "HIGH") return + return } -export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) { +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 ( { + const previous = status + const next = selected as TicketStatus + setStatus(next) + toast.loading("Atualizando status...", { id: "status" }) + try { + if (!userId) throw new Error("missing user") + await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> }) + toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" }) + } catch { + setStatus(previous) + toast.error("Não foi possível atualizar o status.", { id: "status" }) + } + }} + > + + + + {statusStyles[status]?.label ?? status} + + + + + {(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => ( + + {statusStyles[option].label} + + ))} + + + ) +} diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index e2fecc6..97ad8c9 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -5,8 +5,7 @@ import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { IconLock, IconMessage } from "@tabler/icons-react" import { Download, FileIcon } from "lucide-react" -import { useAction, useMutation } from "convex/react" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment +import { useMutation } from "convex/react" // @ts-ignore import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" @@ -21,71 +20,93 @@ 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 } +const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white" +const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]" + export function TicketComments({ ticket }: TicketCommentsProps) { const { userId } = useAuth() const addComment = useMutation(api.tickets.addComment) - const generateUploadUrl = useAction(api.files.generateUploadUrl) const [body, setBody] = useState("") const [attachmentsToSend, setAttachmentsToSend] = useState>([]) const [preview, setPreview] = useState(null) - const [pending, setPending] = useState[]>([]) + const [pending, setPending] = useState[]>([]) const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC") const commentsAll = useMemo(() => { return [...pending, ...ticket.comments] }, [pending, ticket.comments]) - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() if (!userId) return - const attachments = attachmentsToSend const now = new Date() + const attachments = attachmentsToSend const optimistic = { id: `temp-${now.getTime()}`, author: ticket.requester, visibility, body: sanitizeEditorHtml(body), - attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })), + attachments: attachments.map((attachment) => ({ + id: attachment.storageId, + name: attachment.name, + url: attachment.previewUrl, + })), createdAt: now, updatedAt: now, } - setPending((p) => [optimistic, ...p]) + + setPending((current) => [optimistic, ...current]) 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">, - name: a.name, - size: a.size, - type: a.type, + const payload = attachments.map((attachment) => ({ + storageId: attachment.storageId as unknown as Id<"_storage">, + name: attachment.name, + size: attachment.size, + type: attachment.type, })) - await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments }) + await addComment({ + ticketId: ticket.id as Id<"tickets">, + authorId: userId as Id<"users">, + visibility, + body: optimistic.body, + attachments: payload, + }) setPending([]) toast.success("Comentário enviado!", { id: "comment" }) - } catch (err) { + } catch { setPending([]) toast.error("Falha ao enviar comentário.", { id: "comment" }) } } return ( - - - - Conversa + + + + Conversa {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 @@ -93,56 +114,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) { .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join("") + return (
- + {initials}
- {comment.author.name} + {comment.author.name} {comment.visibility === "INTERNAL" ? ( - - Interno + + Interno ) : null} - + {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((attachment) => { + const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i) + if (isImage && attachment.url) { + return ( + + ) + } + return ( + + {attachment.name} + {attachment.url ? : null} + + ) + })} +
+ ) : null}
) @@ -152,25 +179,26 @@ export function TicketComments({ ticket }: TicketCommentsProps) { setAttachmentsToSend((prev) => [...prev, ...files])} />
-
+
Visibilidade: - setVisibility(value as "PUBLIC" | "INTERNAL")}> + + + + Pública Interna
- +
- !o && setPreview(null)}> + !open && setPreview(null)}> - {preview ? ( - // eslint-disable-next-line @next/next/no-img-element - Preview - ) : null} + {preview ? Preview : null} diff --git a/web/src/components/tickets/ticket-detail-view.tsx b/web/src/components/tickets/ticket-detail-view.tsx index 25f39a4..ce66e8a 100644 --- a/web/src/components/tickets/ticket-detail-view.tsx +++ b/web/src/components/tickets/ticket-detail-view.tsx @@ -10,7 +10,6 @@ import type { Id } from "@/convex/_generated/dataModel"; import type { TicketWithDetails } from "@/lib/schemas/ticket"; import { getTicketById } from "@/lib/mocks/tickets"; import { Card, CardContent } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { TicketComments } from "@/components/tickets/ticket-comments.rich"; import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel"; @@ -28,7 +27,7 @@ export function TicketDetailView({ id }: { id: string }) { } if (!ticket) return (
- +
@@ -36,7 +35,7 @@ export function TicketDetailView({ id }: { id: string }) {
- + {Array.from({ length: 3 }).map((_, i) => (
@@ -46,7 +45,7 @@ export function TicketDetailView({ id }: { id: string }) { ))} - + {Array.from({ length: 5 }).map((_, i) => ())} diff --git a/web/src/components/tickets/ticket-details-panel.tsx b/web/src/components/tickets/ticket-details-panel.tsx index f3bc082..5c8a052 100644 --- a/web/src/components/tickets/ticket-details-panel.tsx +++ b/web/src/components/tickets/ticket-details-panel.tsx @@ -1,85 +1,85 @@ -import { format } from "date-fns" -import { ptBR } from "date-fns/locale" -import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react" - -import type { TicketWithDetails } from "@/lib/schemas/ticket" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" - -interface TicketDetailsPanelProps { - ticket: TicketWithDetails -} - +import { format } from "date-fns" +import { ptBR } from "date-fns/locale" +import { IconAlertTriangle, IconClockHour4, IconTags } from "@tabler/icons-react" + +import type { TicketWithDetails } from "@/lib/schemas/ticket" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" + +interface TicketDetailsPanelProps { + ticket: TicketWithDetails +} + +const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700" +const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700" +const iconAccentClass = "size-3 text-neutral-700" + export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { return ( - - - Detalhes + + + Detalhes - +
-

Fila

- {ticket.queue ?? "Sem fila"} +

Fila

+ {ticket.queue ?? "Sem fila"}
- +
-

SLA

+

SLA

{ticket.slaPolicy ? ( -
- {ticket.slaPolicy.name} -
+
+ {ticket.slaPolicy.name} +
{ticket.slaPolicy.targetMinutesToFirstResponse ? ( - - Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min - + Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min ) : null} {ticket.slaPolicy.targetMinutesToResolution ? ( - - Resolução: {ticket.slaPolicy.targetMinutesToResolution} min - + Resolução: {ticket.slaPolicy.targetMinutesToResolution} min ) : null}
) : ( - Sem política atribuída. + Sem política atribuída. )}
- +
-

Métricas

+

Métricas

{ticket.metrics ? ( -
+
- Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min + Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min - Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min + Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
) : ( - Sem dados de SLA ainda. + Sem dados de SLA ainda. )}
- +
-

Tags

+

Tags

{ticket.tags?.length ? ( ticket.tags.map((tag) => ( - - {tag} + + {tag} )) ) : ( - Sem tags. + Sem tags. )}
- +
-

Histórico

-
+

Histórico

+
Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} {ticket.resolvedAt ? ( @@ -90,4 +90,4 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { ) -} +} diff --git a/web/src/components/tickets/ticket-queue-summary.tsx b/web/src/components/tickets/ticket-queue-summary.tsx index adeeb01..fbc60a2 100644 --- a/web/src/components/tickets/ticket-queue-summary.tsx +++ b/web/src/components/tickets/ticket-queue-summary.tsx @@ -1,67 +1,68 @@ "use client" import { useQuery } from "convex/react" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { TicketQueueSummary } from "@/lib/schemas/ticket" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" - -interface TicketQueueSummaryProps { - queues?: TicketQueueSummary[] -} - + +interface TicketQueueSummaryProps { + queues?: TicketQueueSummary[] +} + export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) { const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? []) + if (!queues && fromServer === undefined) { return (
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
))}
) } + return (
{data.map((queue) => { const total = queue.pending + queue.waiting const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100) return ( - - - Fila - {queue.name} - - -
- Pendentes - {queue.pending} -
-
- Aguardando resposta - {queue.waiting} -
-
- Violados - {queue.breached} -
-
- - - {breachPercent}% com SLA violado nesta fila - -
-
-
- ) - })} -
- ) + + + Fila + {queue.name} + + +
+ Pendentes + {queue.pending} +
+
+ Aguardando resposta + {queue.waiting} +
+
+ Violados + {queue.breached} +
+
+ + + {breachPercent}% com SLA violado nesta fila + +
+
+
+ ) + })} +
+ ) } diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 89edf2d..3f2e37d 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,91 +15,154 @@ 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 +} + +const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" +const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700" +const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-[#00e8ff] px-3 py-1.5 text-sm font-semibold text-black transition hover:bg-[#00d6eb]" +const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90" +const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90" +const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500" +const sectionValueClass = "font-medium text-neutral-900" + 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 ( -
+
-
-
- - #{ticket.reference} - - - - + {isPlaying ? ( + <> + Pausar + + ) : ( + <> + Iniciar + + )} +
-

{ticket.subject}

-

{ticket.summary}

-
- + {editing ? ( +
+ setSubject(e.target.value)} + className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900" + /> +