diff --git a/web/convex/schema.ts b/web/convex/schema.ts index 62ddd5f..61b56f0 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -57,6 +57,8 @@ export default defineSchema({ updatedAt: v.number(), createdAt: v.number(), tags: v.optional(v.array(v.string())), + totalWorkedMs: v.optional(v.number()), + activeSessionId: v.optional(v.id("ticketWorkSessions")), }) .index("by_tenant_status", ["tenantId", "status"]) .index("by_tenant_queue", ["tenantId", "queueId"]) @@ -89,4 +91,14 @@ export default defineSchema({ payload: v.optional(v.any()), createdAt: v.number(), }).index("by_ticket", ["ticketId"]), + + ticketWorkSessions: defineTable({ + ticketId: v.id("tickets"), + agentId: v.id("users"), + startedAt: v.number(), + stoppedAt: v.optional(v.number()), + durationMs: v.optional(v.number()), + }) + .index("by_ticket", ["ticketId"]) + .index("by_ticket_agent", ["ticketId", "agentId"]), }); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 2c91238..cb3d867 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -1,5 +1,5 @@ import { internalMutation, mutation, query } from "./_generated/server"; -import { v } from "convex/values"; +import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; @@ -53,6 +53,7 @@ export const list = query({ const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; return { id: t._id, reference: t.reference, @@ -88,6 +89,16 @@ export const list = query({ tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + } + : null, + }, }; }) ); @@ -121,6 +132,7 @@ export const getById = query({ id: att.storageId, name: att.name, size: att.size, + type: att.type, url: await ctx.storage.getUrl(att.storageId), })) ); @@ -142,6 +154,8 @@ export const getById = query({ }) ); + const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; + return { id: t._id, reference: t.reference, @@ -177,6 +191,16 @@ export const getById = query({ tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + } + : null, + }, description: undefined, customFields: {}, timeline: timeline.map((ev) => ({ @@ -201,6 +225,10 @@ export const create = mutation({ requesterId: v.id("users"), }, handler: async (ctx, args) => { + const subject = args.subject.trim(); + if (subject.length < 3) { + throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); + } // compute next reference (simple monotonic counter per tenant) const existing = await ctx.db .query("tickets") @@ -212,8 +240,8 @@ export const create = mutation({ const id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, - subject: args.subject, - summary: args.summary, + subject, + summary: args.summary?.trim() || undefined, status: "NEW", priority: args.priority, channel: args.channel, @@ -221,6 +249,8 @@ export const create = mutation({ requesterId: args.requesterId, assigneeId: undefined, working: false, + activeSessionId: undefined, + totalWorkedMs: 0, createdAt: now, updatedAt: now, firstResponseAt: undefined, @@ -282,6 +312,51 @@ export const addComment = mutation({ }, }); +export const removeCommentAttachment = mutation({ + args: { + ticketId: v.id("tickets"), + commentId: v.id("ticketComments"), + attachmentId: v.id("_storage"), + actorId: v.id("users"), + }, + handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => { + const comment = await ctx.db.get(commentId); + if (!comment || comment.ticketId !== ticketId) { + throw new ConvexError("Comentário não encontrado"); + } + + const attachments = comment.attachments ?? []; + const target = attachments.find((att) => att.storageId === attachmentId); + if (!target) { + throw new ConvexError("Anexo não encontrado"); + } + + await ctx.storage.delete(attachmentId); + + const now = Date.now(); + await ctx.db.patch(commentId, { + attachments: attachments.filter((att) => att.storageId !== attachmentId), + updatedAt: now, + }); + + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null; + await ctx.db.insert("ticketEvents", { + ticketId, + type: "ATTACHMENT_REMOVED", + payload: { + attachmentId, + attachmentName: target.name, + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + }, + createdAt: now, + }); + + await ctx.db.patch(ticketId, { updatedAt: now }); + }, +}); + export const updateStatus = mutation({ args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, status, actorId }) => { @@ -334,6 +409,27 @@ export const changeQueue = mutation({ }, }); +export const workSummary = query({ + args: { ticketId: v.id("tickets") }, + handler: async (ctx, { ticketId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) return null + + const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null + return { + ticketId, + totalWorkedMs: ticket.totalWorkedMs ?? 0, + activeSession: activeSession + ? { + id: activeSession._id, + agentId: activeSession.agentId, + startedAt: activeSession.startedAt, + } + : null, + } + }, +}) + export const updatePriority = mutation({ args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") }, handler: async (ctx, { ticketId, priority, actorId }) => { @@ -349,22 +445,89 @@ export const updatePriority = mutation({ }, }); -export const toggleWork = mutation({ +export const startWork = 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 ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + if (ticket.activeSessionId) { + return { status: "already_started", sessionId: ticket.activeSessionId } + } + const now = Date.now() - const next = !(t.working ?? false) - await ctx.db.patch(ticketId, { working: next, updatedAt: now }) + const sessionId = await ctx.db.insert("ticketWorkSessions", { + ticketId, + agentId: actorId, + startedAt: now, + }) + + await ctx.db.patch(ticketId, { + working: true, + activeSessionId: sessionId, + 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 }, + type: "WORK_STARTED", + payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId }, createdAt: now, }) - return next + + return { status: "started", sessionId, startedAt: now } + }, +}) + +export const pauseWork = mutation({ + args: { ticketId: v.id("tickets"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, actorId }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + if (!ticket.activeSessionId) { + return { status: "already_paused" } + } + + const session = await ctx.db.get(ticket.activeSessionId) + if (!session) { + await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false }) + return { status: "session_missing" } + } + + const now = Date.now() + const durationMs = now - session.startedAt + + await ctx.db.patch(ticket.activeSessionId, { + stoppedAt: now, + durationMs, + }) + + await ctx.db.patch(ticketId, { + working: false, + activeSessionId: undefined, + totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, + updatedAt: now, + }) + + const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null + await ctx.db.insert("ticketEvents", { + ticketId, + type: "WORK_PAUSED", + payload: { + actorId, + actorName: actor?.name, + actorAvatar: actor?.avatarUrl, + sessionId: session._id, + sessionDurationMs: durationMs, + }, + createdAt: now, + }) + + return { status: "paused", durationMs } }, }) @@ -374,12 +537,16 @@ export const updateSubject = mutation({ const now = Date.now(); const t = await ctx.db.get(ticketId); if (!t) return; - await ctx.db.patch(ticketId, { subject, updatedAt: now }); + const trimmed = subject.trim(); + if (trimmed.length < 3) { + throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); + } + await ctx.db.patch(ticketId, { subject: trimmed, 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 }, + payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl }, createdAt: now, }); }, diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 997bf7c..fb17b73 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -43,74 +43,74 @@ --radius-xl: calc(var(--radius) + 4px); } -:root { - --radius: 0.75rem; - --background: oklch(0.99 0.004 95.08); - --foreground: oklch(0.28 0.02 254.6); - --card: oklch(1 0 0); - --card-foreground: oklch(0.22 0.02 254.6); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.22 0.02 254.6); - --primary: oklch(0.63 0.16 254.6); - --primary-foreground: oklch(0.99 0.004 95.08); - --secondary: oklch(0.96 0.022 254.6); - --secondary-foreground: oklch(0.3 0.03 254.6); - --muted: oklch(0.96 0.01 254.6); - --muted-foreground: oklch(0.55 0.03 254.6); - --accent: oklch(0.96 0.01 254.6); - --accent-foreground: oklch(0.3 0.03 254.6); - --destructive: oklch(0.55 0.23 23.3); - --border: oklch(0.9 0.01 254.6); - --input: oklch(0.92 0.008 254.6); - --ring: oklch(0.63 0.16 254.6); - --chart-1: oklch(0.63 0.16 254.6); - --chart-2: oklch(0.66 0.11 200.43); - --chart-3: oklch(0.73 0.14 146.75); - --chart-4: oklch(0.7 0.17 95.21); - --chart-5: oklch(0.73 0.1 18.06); - --sidebar: oklch(0.985 0.004 95.08); - --sidebar-foreground: oklch(0.28 0.02 254.6); - --sidebar-primary: oklch(0.63 0.16 254.6); - --sidebar-primary-foreground: oklch(0.99 0.004 95.08); - --sidebar-accent: oklch(0.95 0.008 254.6); - --sidebar-accent-foreground: oklch(0.28 0.02 254.6); - --sidebar-border: oklch(0.9 0.01 254.6); - --sidebar-ring: oklch(0.63 0.16 254.6); -} - -.dark { - --background: oklch(0.16 0.02 254.6); - --foreground: oklch(0.96 0.02 254.6); - --card: oklch(0.19 0.02 254.6); - --card-foreground: oklch(0.96 0.02 254.6); - --popover: oklch(0.22 0.02 254.6); - --popover-foreground: oklch(0.96 0.02 254.6); - --primary: oklch(0.71 0.15 254.6); - --primary-foreground: oklch(0.1 0.01 254.6); - --secondary: oklch(0.32 0.02 254.6); - --secondary-foreground: oklch(0.96 0.02 254.6); - --muted: oklch(0.3 0.02 254.6); - --muted-foreground: oklch(0.68 0.02 254.6); - --accent: oklch(0.29 0.02 254.6); - --accent-foreground: oklch(0.96 0.02 254.6); - --destructive: oklch(0.6 0.21 23.3); - --border: oklch(0.32 0.02 254.6); - --input: oklch(0.32 0.02 254.6); - --ring: oklch(0.71 0.15 254.6); - --chart-1: oklch(0.71 0.15 254.6); - --chart-2: oklch(0.63 0.12 200.43); - --chart-3: oklch(0.62 0.14 146.75); - --chart-4: oklch(0.6 0.17 95.21); - --chart-5: oklch(0.64 0.1 18.06); - --sidebar: oklch(0.18 0.02 254.6); - --sidebar-foreground: oklch(0.96 0.02 254.6); - --sidebar-primary: oklch(0.71 0.15 254.6); - --sidebar-primary-foreground: oklch(0.1 0.01 254.6); - --sidebar-accent: oklch(0.26 0.02 254.6); - --sidebar-accent-foreground: oklch(0.96 0.02 254.6); - --sidebar-border: oklch(0.26 0.02 254.6); - --sidebar-ring: oklch(0.71 0.15 254.6); -} +:root { + --radius: 0.75rem; + --background: #f7f8fb; + --foreground: #0f172a; + --card: #ffffff; + --card-foreground: #0f172a; + --popover: #ffffff; + --popover-foreground: #0f172a; + --primary: #00e8ff; + --primary-foreground: #020617; + --secondary: #0f172a; + --secondary-foreground: #f8fafc; + --muted: #e2e8f0; + --muted-foreground: #475569; + --accent: #dff7fb; + --accent-foreground: #0f172a; + --destructive: #ef4444; + --border: #d6d8de; + --input: #e4e7ec; + --ring: #00d6eb; + --chart-1: #00d6eb; + --chart-2: #0891b2; + --chart-3: #0e7490; + --chart-4: #155e75; + --chart-5: #0f4c5c; + --sidebar: #f2f5f7; + --sidebar-foreground: #0f172a; + --sidebar-primary: #00e8ff; + --sidebar-primary-foreground: #020617; + --sidebar-accent: #c4eef6; + --sidebar-accent-foreground: #0f172a; + --sidebar-border: #cbd5e1; + --sidebar-ring: #00d6eb; +} + +.dark { + --background: #020617; + --foreground: #f8fafc; + --card: #0b1120; + --card-foreground: #f8fafc; + --popover: #0b1120; + --popover-foreground: #f8fafc; + --primary: #00d6eb; + --primary-foreground: #041019; + --secondary: #1f2937; + --secondary-foreground: #f9fafb; + --muted: #1e293b; + --muted-foreground: #cbd5f5; + --accent: #083344; + --accent-foreground: #f1f5f9; + --destructive: #f87171; + --border: #1f2933; + --input: #1e2933; + --ring: #00e6ff; + --chart-1: #00e6ff; + --chart-2: #0891b2; + --chart-3: #0e7490; + --chart-4: #155e75; + --chart-5: #0f4c5c; + --sidebar: #050c16; + --sidebar-foreground: #f8fafc; + --sidebar-primary: #00d6eb; + --sidebar-primary-foreground: #041019; + --sidebar-accent: #083344; + --sidebar-accent-foreground: #f8fafc; + --sidebar-border: #0f1b2a; + --sidebar-ring: #00e6ff; +} @layer base { * { @@ -127,7 +127,7 @@ @apply text-foreground; } .rich-text p { @apply my-2; } - .rich-text a { @apply text-primary underline; } + .rich-text a { @apply text-[#00e8ff] underline; } .rich-text ul { @apply my-2 list-disc ps-5; } .rich-text ol { @apply my-2 list-decimal ps-5; } .rich-text li { @apply my-1; } diff --git a/web/src/app/tickets/[id]/page.tsx b/web/src/app/tickets/[id]/page.tsx index b150aee..98bacb4 100644 --- a/web/src/app/tickets/[id]/page.tsx +++ b/web/src/app/tickets/[id]/page.tsx @@ -21,7 +21,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps title={`Ticket #${id}`} lead={"Detalhes do ticket"} secondaryAction={Compartilhar} - primaryAction={Adicionar comentario} + primaryAction={Adicionar comentário} /> } > diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index e7674c9..e110da6 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -1,122 +1,230 @@ -"use client"; +"use client" -import { useMemo, useState } from "react"; -import type { Id } from "@/convex/_generated/dataModel"; -import type { TicketQueueSummary } from "@/lib/schemas/ticket"; -import { useRouter } from "next/navigation"; -import { useMutation, useQuery } from "convex/react"; +import { useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import type { Id } from "@/convex/_generated/dataModel" +import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { useAuth } from "@/lib/auth-client" // 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 { useAuth } from "@/lib/auth-client"; -import { RichTextEditor } from "@/components/ui/rich-text-editor"; +import { api } from "@/convex/_generated/api" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { RichTextEditor } from "@/components/ui/rich-text-editor" +import { Spinner } from "@/components/ui/spinner" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { + PriorityIcon, + priorityBadgeClass, + priorityItemClass, + priorityStyles, + priorityTriggerClass, +} from "@/components/tickets/priority-select" export default function NewTicketPage() { - const router = useRouter(); - const { userId } = useAuth(); - const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []; - const create = useMutation(api.tickets.create); - const addComment = useMutation(api.tickets.addComment); - const ensureDefaults = useMutation(api.bootstrap.ensureDefaults); + const router = useRouter() + const { userId } = useAuth() + const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [] + const create = useMutation(api.tickets.create) + const addComment = useMutation(api.tickets.addComment) + const ensureDefaults = useMutation(api.bootstrap.ensureDefaults) - const [subject, setSubject] = useState(""); - const [summary, setSummary] = useState(""); - const [priority, setPriority] = useState("MEDIUM"); - const [channel, setChannel] = useState("MANUAL"); - const [queueName, setQueueName] = useState(null); - const [description, setDescription] = useState(""); + const [subject, setSubject] = useState("") + const [summary, setSummary] = useState("") + const [priority, setPriority] = useState("MEDIUM") + const [channel, setChannel] = useState("MANUAL") + const [queueName, setQueueName] = useState(null) + const [description, setDescription] = useState("") + const [loading, setLoading] = useState(false) + const [subjectError, setSubjectError] = useState(null) - const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]); + const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]) - async function submit(e: React.FormEvent) { - e.preventDefault(); - if (!userId) return; - if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID }); - // Encontrar a fila pelo nome (simples) - const selQueue = queues.find((q) => q.name === queueName); - const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined; - const id = await create({ - tenantId: DEFAULT_TENANT_ID, - subject, - summary, - priority, - channel, - queueId, - requesterId: userId as Id<"users">, - }); - const hasDescription = description.replace(/<[^>]*>/g, "").trim().length > 0 - if (hasDescription) { - await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: description, attachments: [] }) + async function submit(event: React.FormEvent) { + event.preventDefault() + if (!userId || loading) return + + const trimmedSubject = subject.trim() + if (trimmedSubject.length < 3) { + setSubjectError("Informe um assunto com pelo menos 3 caracteres.") + return + } + setSubjectError(null) + + setLoading(true) + toast.loading("Criando ticket...", { id: "create-ticket" }) + try { + if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID }) + const selQueue = queues.find((q) => q.name === queueName) + const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined + const id = await create({ + tenantId: DEFAULT_TENANT_ID, + subject: trimmedSubject, + summary: summary.trim() || undefined, + priority, + channel, + queueId, + requesterId: userId as Id<"users">, + }) + const plainDescription = description.replace(/<[^>]*>/g, "").trim() + if (plainDescription.length > 0) { + await addComment({ + ticketId: id as Id<"tickets">, + authorId: userId as Id<"users">, + visibility: "PUBLIC", + body: description, + attachments: [], + }) + } + toast.success("Ticket criado!", { id: "create-ticket" }) + router.replace(`/tickets/${id}`) + } catch (error) { + console.error(error) + toast.error("Não foi possível criar o ticket.", { id: "create-ticket" }) + } finally { + setLoading(false) } - router.replace(`/tickets/${id}`); } + const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" + const selectItemClass = "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" + return ( - - Novo ticket - - - Assunto - setSubject(e.target.value)} required /> - - - Resumo - setSummary(e.target.value)} rows={3} /> - - - Descrição - - - - - Prioridade - setPriority(e.target.value)}> - {[ - ["LOW", "Baixa"], - ["MEDIUM", "Media"], - ["HIGH", "Alta"], - ["URGENT", "Urgente"], - ].map(([v, l]) => ( - - {l} - - ))} - - - - Canal - setChannel(e.target.value)}> - {[ - ["EMAIL", "E-mail"], - ["WHATSAPP", "WhatsApp"], - ["CHAT", "Chat"], - ["PHONE", "Telefone"], - ["API", "API"], - ["MANUAL", "Manual"], - ].map(([v, l]) => ( - - {l} - - ))} - - - - - Fila - setQueueName(e.target.value || null)}> - Sem fila - {queueOptions.map((q) => ( - - {q} - - ))} - - - - Criar - - + + + + Novo ticket + Preencha as informações básicas para abrir um chamado. + + + + + + Assunto + + { + setSubject(event.target.value) + if (subjectError) setSubjectError(null) + }} + placeholder="Ex.: Erro 500 no portal" + aria-invalid={subjectError ? "true" : undefined} + /> + {subjectError ? {subjectError} : null} + + + + Resumo + + setSummary(event.target.value)} + /> + + + Descrição + + + + + Prioridade + setPriority(value as TicketPriority)}> + + + + + {priorityStyles[priority]?.label ?? priority} + + + + + {(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => ( + + + + {priorityStyles[option].label} + + + ))} + + + + + Canal + + + + + + + E-mail + + + WhatsApp + + + Chat + + + Telefone + + + API + + + Manual + + + + + + Fila + setQueueName(value === "NONE" ? null : value)}> + + + + + + Sem fila + + {queueOptions.map((name) => ( + + {name} + + ))} + + + + + + + {loading ? ( + <> + + Criando... + > + ) : ( + "Criar" + )} + + + + + - ); + ) } diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 2eba29e..d43001e 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -33,8 +33,8 @@ import { SidebarRail, } from "@/components/ui/sidebar" -const navigation = { - versions: ["MVP", "Beta", "Roadmap"], +const navigation = { + versions: ["0.0.1"], navMain: [ { title: "Operação", @@ -82,7 +82,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/web/src/components/tickets/delete-ticket-dialog.tsx b/web/src/components/tickets/delete-ticket-dialog.tsx index 9cb51c5..ea5601d 100644 --- a/web/src/components/tickets/delete-ticket-dialog.tsx +++ b/web/src/components/tickets/delete-ticket-dialog.tsx @@ -38,8 +38,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { return ( - - Excluir + + diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx index d52cd73..87d0667 100644 --- a/web/src/components/tickets/new-ticket-dialog.tsx +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -3,7 +3,7 @@ import { z } from "zod" import { useState } from "react" import type { Id } from "@/convex/_generated/dataModel" -import type { TicketQueueSummary } from "@/lib/schemas/ticket" +import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { useMutation, useQuery } from "convex/react" // @ts-ignore import { api } from "@/convex/_generated/api" @@ -20,6 +20,15 @@ import { toast } from "sonner" import { Spinner } from "@/components/ui/spinner" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor } from "@/components/ui/rich-text-editor" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { + PriorityIcon, + priorityBadgeClass, + priorityItemClass, + priorityStyles, + priorityTriggerClass, +} from "@/components/tickets/priority-select" const schema = z.object({ subject: z.string().min(3, "Informe um assunto"), @@ -43,6 +52,11 @@ export function NewTicketDialog() { const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) const [attachments, setAttachments] = useState>([]) + const priorityValue = form.watch("priority") as TicketPriority + const channelValue = form.watch("channel") + const queueValue = form.watch("queueName") ?? "NONE" + const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" + const selectItemClass = "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" async function submit(values: z.infer) { if (!userId) return @@ -91,7 +105,12 @@ export function NewTicketDialog() { return ( - Novo ticket + + Novo ticket + @@ -126,53 +145,90 @@ export function NewTicketDialog() { Prioridade - form.setValue("priority", v as z.infer["priority"])}> - - - Baixa - Média - Alta - Urgente + form.setValue("priority", v as z.infer["priority"])}> + + + + + {priorityStyles[priorityValue]?.label ?? priorityValue} + + + + + {(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => ( + + + + {priorityStyles[option].label} + + + ))} Canal - form.setValue("channel", v as z.infer["channel"])}> - - - E-mail - WhatsApp - Chat - Telefone - API - Manual + form.setValue("channel", v as z.infer["channel"])}> + + + + + + E-mail + + + WhatsApp + + + Chat + + + Telefone + + + API + + + Manual + + + + + + Fila + form.setValue("queueName", v === "NONE" ? null : v)}> + + + + + + Sem fila + + {queues.map((q) => ( + + {q.name} + + ))} - - Fila - {(() => { - const NONE = "NONE"; - const current = form.watch("queueName") ?? NONE; - return ( - form.setValue("queueName", v === NONE ? null : v)}> - - - Sem fila - {queues.map((q) => ( - {q.name} - ))} - - - ) - })()} - - {loading ? (<> Criando…>) : "Criar"} + + {loading ? ( + <> + Criando… + > + ) : ( + "Criar" + )} + diff --git a/web/src/components/tickets/priority-pill.tsx b/web/src/components/tickets/priority-pill.tsx index 376965f..baabfdb 100644 --- a/web/src/components/tickets/priority-pill.tsx +++ b/web/src/components/tickets/priority-pill.tsx @@ -1,20 +1,15 @@ import { type TicketPriority } from "@/lib/schemas/ticket" import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" +import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select" -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" +const baseClass = "inline-flex items-center gap-1.5 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 d3e82dd..46dada8 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -13,19 +13,19 @@ import { toast } from "sonner" import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react" import { cn } from "@/lib/utils" -const priorityStyles: Record = { +export 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]" }, } -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" +export const priorityTriggerClass = "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]" +export const priorityItemClass = "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" +export const priorityBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold" -function PriorityIcon({ value }: { value: TicketPriority }) { +export function PriorityIcon({ value }: { value: TicketPriority }) { if (value === "LOW") return if (value === "MEDIUM") return if (value === "HIGH") return @@ -55,9 +55,9 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T } }} > - + - + {priorityStyles[priority]?.label ?? priority} @@ -65,7 +65,7 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T {(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => ( - + {priorityStyles[option].label} diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index 97ad8c9..f145b9b 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react" 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 { FileIcon, Trash2, X } from "lucide-react" import { useMutation } from "convex/react" // @ts-ignore import { api } from "@/convex/_generated/api" @@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button" import { toast } from "sonner" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" -import { Dialog, DialogContent } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" @@ -28,16 +28,20 @@ interface TicketCommentsProps { 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]" +const submitButtonClass = + "inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" export function TicketComments({ ticket }: TicketCommentsProps) { const { userId } = useAuth() const addComment = useMutation(api.tickets.addComment) + const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const [body, setBody] = useState("") const [attachmentsToSend, setAttachmentsToSend] = useState>([]) const [preview, setPreview] = useState(null) const [pending, setPending] = useState[]>([]) const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC") + const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null) + const [removingAttachment, setRemovingAttachment] = useState(false) const commentsAll = useMemo(() => { return [...pending, ...ticket.comments] @@ -47,7 +51,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) { event.preventDefault() if (!userId) return const now = new Date() - const attachments = attachmentsToSend + const attachments = attachmentsToSend.map((item) => ({ ...item })) + const previewsToRevoke = attachments + .map((attachment) => attachment.previewUrl) + .filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:"))) const optimistic = { id: `temp-${now.getTime()}`, author: ticket.requester, @@ -56,6 +63,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { attachments: attachments.map((attachment) => ({ id: attachment.storageId, name: attachment.name, + type: attachment.type, url: attachment.previewUrl, })), createdAt: now, @@ -87,6 +95,34 @@ export function TicketComments({ ticket }: TicketCommentsProps) { setPending([]) toast.error("Falha ao enviar comentário.", { id: "comment" }) } + previewsToRevoke.forEach((previewUrl) => { + try { + URL.revokeObjectURL(previewUrl) + } catch (error) { + console.error("Failed to revoke preview URL", error) + } + }) + } + + async function handleRemoveAttachment() { + if (!attachmentToRemove || !userId) return + setRemovingAttachment(true) + toast.loading("Removendo anexo...", { id: "remove-attachment" }) + try { + await removeAttachment({ + ticketId: ticket.id as unknown as Id<"tickets">, + commentId: attachmentToRemove.commentId as Id<"ticketComments">, + attachmentId: attachmentToRemove.attachmentId as Id<"_storage">, + actorId: userId as Id<"users">, + }) + toast.success("Anexo removido.", { id: "remove-attachment" }) + setAttachmentToRemove(null) + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" }) + } finally { + setRemovingAttachment(false) + } } return ( @@ -114,6 +150,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) { .slice(0, 2) .map((part) => part[0]?.toUpperCase()) .join("") + const bodyHtml = comment.body ?? "" + const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim() + const hasBody = bodyPlain.length > 0 return ( @@ -133,39 +172,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) { {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })} - - - + {hasBody ? ( + + + + ) : 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 ( - setPreview(attachment.url || null)} - className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400" - > - - - {attachment.name} - - - ) + const name = attachment?.name ?? "" + const url = attachment?.url + const type = attachment?.type ?? "" + const isImage = + (!!type && type.startsWith("image/")) || + /\.(png|jpe?g|gif|webp|svg)$/i.test(name) || + /\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "") + const openRemovalModal = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name }) } return ( - - {attachment.name} - {attachment.url ? : null} - + {isImage && url ? ( + setPreview(url || null)} + className="block w-full overflow-hidden rounded-md" + > + + + ) : ( + + + {url ? Baixar : Pendente} + + )} + + + + + {name} + + ) })} @@ -178,6 +240,55 @@ export function TicketComments({ ticket }: TicketCommentsProps) { setAttachmentsToSend((prev) => [...prev, ...files])} /> + {attachmentsToSend.length > 0 ? ( + + {attachmentsToSend.map((attachment, index) => { + const name = attachment.name + const previewUrl = attachment.previewUrl + const isImage = + (attachment.type ?? "").startsWith("image/") || + /\.(png|jpe?g|gif|webp|svg)$/i.test(name) + return ( + + {isImage && previewUrl ? ( + setPreview(previewUrl || null)} + className="block w-full overflow-hidden rounded-md" + > + + + ) : ( + + + {name} + + )} + + setAttachmentsToSend((prev) => { + const next = [...prev] + const removed = next.splice(index, 1)[0] + if (removed?.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(removed.previewUrl) + } + return next + }) + } + className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30" + aria-label={`Remover ${name}`} + > + + + + {name} + + + ) + })} + + ) : null} Visibilidade: @@ -196,6 +307,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) { + { if (!open && !removingAttachment) setAttachmentToRemove(null) }}> + + + Remover anexo + + Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário? + + + + setAttachmentToRemove(null)} + className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100" + disabled={removingAttachment} + > + Cancelar + + + + {removingAttachment ? "Removendo..." : "Excluir"} + + + + !open && setPreview(null)}> {preview ? : null} diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 3f2e37d..7dd3003 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -1,7 +1,7 @@ "use client" -import { useMemo, useState } from "react" -import { format } from "date-fns" +import { useEffect, useMemo, useState } from "react" +import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" @@ -26,15 +26,35 @@ interface TicketHeaderProps { ticket: TicketWithDetails } -const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" +const cardClass = "relative 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 startButtonClass = + "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-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30" +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-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" +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-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" 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" +const subtleBadgeClass = + "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600" + +function formatDuration(durationMs: number) { + if (durationMs <= 0) return "0s" + const totalSeconds = Math.floor(durationMs / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m` + } + if (minutes > 0) { + return `${minutes}m ${seconds.toString().padStart(2, "0")}s` + } + return `${seconds}s` +} export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const { userId } = useAuth() @@ -42,10 +62,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { 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 startWork = useMutation(api.tickets.startWork) + const pauseWork = useMutation(api.tickets.pauseWork) 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] = useState(ticket.status) + const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as + | { + ticketId: Id<"tickets"> + totalWorkedMs: number + activeSession: { id: Id<"ticketWorkSessions">; agentId: Id<"users">; startedAt: number } | null + } + | null + | undefined const [editing, setEditing] = useState(false) const [subject, setSubject] = useState(ticket.subject) @@ -78,11 +107,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { setEditing(false) } - const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED") - const isPlaying = lastWork?.type === "WORK_STARTED" + const workSummary = useMemo(() => { + if (workSummaryRemote !== undefined) return workSummaryRemote ?? null + if (!ticket.workSummary) return null + return { + ticketId: ticket.id as Id<"tickets">, + totalWorkedMs: ticket.workSummary.totalWorkedMs, + activeSession: ticket.workSummary.activeSession + ? { + id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">, + agentId: ticket.workSummary.activeSession.agentId as Id<"users">, + startedAt: ticket.workSummary.activeSession.startedAt.getTime(), + } + : null, + } + }, [ticket.id, ticket.workSummary, workSummaryRemote]) + + const isPlaying = Boolean(workSummary?.activeSession) + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!workSummary?.activeSession) return + const interval = setInterval(() => { + setNow(Date.now()) + }, 1000) + return () => clearInterval(interval) + }, [workSummary?.activeSession]) + + const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0 + const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 + + const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs]) + const formattedCurrentSession = useMemo(() => formatDuration(currentSessionMs), [currentSessionMs]) + const updatedRelative = useMemo( + () => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }), + [ticket.updatedAt] + ) return ( + + {!editing ? ( + setEditing(true)}> + Editar + + ) : null} + } /> + @@ -94,9 +165,27 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { className={isPlaying ? pauseButtonClass : startButtonClass} onClick={async () => { if (!userId) return - 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" }) + toast.dismiss("work") + toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" }) + try { + if (isPlaying) { + const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> }) + if (result?.status === "already_paused") { + toast.info("O atendimento já estava pausado", { id: "work" }) + } else { + toast.success("Atendimento pausado", { id: "work" }) + } + } else { + const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> }) + if (result?.status === "already_started") { + toast.info("O atendimento já estava em andamento", { id: "work" }) + } else { + toast.success("Atendimento iniciado", { id: "work" }) + } + } + } catch { + toast.error("Não foi possível atualizar o atendimento", { id: "work" }) + } }} > {isPlaying ? ( @@ -105,11 +194,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { > ) : ( <> - Iniciar + Iniciar > )} + {workSummary ? ( + + + Tempo total: {formattedTotalWorked} + + {isPlaying ? ( + + Sessão atual: {formattedCurrentSession} + + ) : null} + + ) : null} {editing ? ( {summary} : null} )} - - {editing ? ( - <> - - Cancelar - - - Salvar - - > - ) : ( - setEditing(true)}> - Editar + {editing ? ( + + + Cancelar - )} - } /> - + + Salvar + + + ) : null} @@ -214,7 +308,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { Atualizado em - {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + + {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} + {updatedRelative} + Criado em diff --git a/web/src/components/tickets/ticket-timeline.tsx b/web/src/components/tickets/ticket-timeline.tsx index 3e05c2e..d334756 100644 --- a/web/src/components/tickets/ticket-timeline.tsx +++ b/web/src/components/tickets/ticket-timeline.tsx @@ -4,6 +4,7 @@ import { ptBR } from "date-fns/locale" import { IconClockHour4, IconNote, + IconPaperclip, IconSquareCheck, IconUserCircle, } from "@tabler/icons-react" @@ -23,6 +24,8 @@ const timelineIcons: Record> = { SUBJECT_CHANGED: IconNote, SUMMARY_CHANGED: IconNote, QUEUE_CHANGED: IconSquareCheck, + PRIORITY_CHANGED: IconSquareCheck, + ATTACHMENT_REMOVED: IconPaperclip, } const timelineLabels: Record = { @@ -35,6 +38,8 @@ const timelineLabels: Record = { SUBJECT_CHANGED: "Assunto atualizado", SUMMARY_CHANGED: "Resumo atualizado", QUEUE_CHANGED: "Fila alterada", + PRIORITY_CHANGED: "Prioridade alterada", + ATTACHMENT_REMOVED: "Anexo removido", } interface TicketTimelineProps { @@ -42,6 +47,21 @@ interface TicketTimelineProps { } export function TicketTimeline({ ticket }: TicketTimelineProps) { + const formatDuration = (durationMs: number) => { + if (!durationMs || durationMs <= 0) return "0s" + const totalSeconds = Math.floor(durationMs / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m` + } + if (minutes > 0) { + return `${minutes}m ${seconds.toString().padStart(2, "0")}s` + } + return `${seconds}s` + } + return ( @@ -88,6 +108,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { authorName?: string authorId?: string from?: string + attachmentName?: string + sessionDurationMs?: number } let message: string | null = null @@ -100,6 +122,9 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) { message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "") } + if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) { + message = "Prioridade alterada para " + (payload.toLabel || payload.to) + } if (entry.type === "CREATED" && payload.requesterName) { message = "Criado por " + payload.requesterName } @@ -112,6 +137,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { if (entry.type === "SUMMARY_CHANGED") { message = "Resumo atualizado" } + if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) { + message = `Anexo removido: ${payload.attachmentName}` + } + if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") { + message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}` + } if (!message) return null return ( diff --git a/web/src/components/tickets/tickets-table.tsx b/web/src/components/tickets/tickets-table.tsx index d2d13b6..c8d24eb 100644 --- a/web/src/components/tickets/tickets-table.tsx +++ b/web/src/components/tickets/tickets-table.tsx @@ -1,3 +1,6 @@ +"use client" + +import { useEffect, useState } from "react" import Link from "next/link" import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" @@ -29,10 +32,26 @@ const channelLabel: Record = { MANUAL: "Manual", } -const cellClass = "py-4 align-top" -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 channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-[#00c4d7]/40 bg-[#00e8ff]/15 px-2.5 py-1 text-xs font-semibold text-neutral-900" -const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700" +const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8" +const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" +const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" +const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600" +const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none" + +function formatDuration(ms?: number) { + if (!ms || ms <= 0) return "—" + const totalSeconds = Math.floor(ms / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m` + } + if (minutes > 0) { + return `${minutes}m ${seconds.toString().padStart(2, "0")}s` + } + return `${seconds}s` +} function AssigneeCell({ ticket }: { ticket: Ticket }) { if (!ticket.assignee) { @@ -68,30 +87,67 @@ export type TicketsTableProps = { } export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const interval = setInterval(() => { + setNow(Date.now()) + }, 1000) + return () => clearInterval(interval) + }, []) + + const getWorkedMs = (ticket: Ticket) => { + const base = ticket.workSummary?.totalWorkedMs ?? 0 + const activeStart = ticket.workSummary?.activeSession?.startedAt + if (activeStart instanceof Date) { + return base + Math.max(0, now - activeStart.getTime()) + } + return base + } + return ( - - - - - - Ticket - Assunto - Fila - Canal - Prioridade - Status - Responsável - Atualizado + + + + + + + Ticket + + + Assunto + + + Fila + + + Canal + + + Prioridade + + + Status + + + Tempo + + + Responsável + + + Atualizado + {tickets.map((ticket) => ( - + - + #{ticket.reference} @@ -101,18 +157,18 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { - + {ticket.subject} {ticket.summary ?? "Sem resumo"} - - {ticket.requester.name} + + {ticket.requester.name} {ticket.tags?.map((tag) => ( {tag} @@ -126,7 +182,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { - + {channelLabel[ticket.channel] ?? ticket.channel} @@ -143,11 +199,19 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { ) : null} + + + {formatDuration(getWorkedMs(ticket))} + {ticket.workSummary?.activeSession ? ( + Em andamento + ) : null} + + - + {formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })} diff --git a/web/src/components/ui/dropzone.tsx b/web/src/components/ui/dropzone.tsx index 46b239c..527b41f 100644 --- a/web/src/components/ui/dropzone.tsx +++ b/web/src/components/ui/dropzone.tsx @@ -75,26 +75,43 @@ export function Dropzone({ return ( inputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + inputRef.current?.click() + } + }} onDragEnter={(e) => { e.preventDefault(); setDrag(true); }} onDragOver={(e) => { e.preventDefault(); setDrag(true); }} onDragLeave={(e) => { e.preventDefault(); setDrag(false); }} onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }} > - e.target.files && startUpload(e.target.files)} /> + e.target.files && startUpload(e.target.files)} + /> - + {items.some((it) => it.status === "uploading") ? ( ) : ( - + )} - Arraste arquivos aqui ou inputRef.current?.click()}>selecione - Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos + + Arraste arquivos aqui ou selecione + + Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos {items.length > 0 && ( @@ -102,9 +119,9 @@ export function Dropzone({ {items.map((it) => ( {it.name} - + - {it.progress}% + {it.progress}% ))} diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx index 6a72603..b970564 100644 --- a/web/src/components/ui/input.tsx +++ b/web/src/components/ui/input.tsx @@ -8,9 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "placeholder:text-neutral-400 selection:bg-neutral-900 selection:text-white aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 h-9 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-base text-neutral-800 shadow-sm transition-colors outline-none file:inline-flex file:h-7 file:rounded-md file:border file:border-black file:bg-black file:px-3 file:text-sm file:font-semibold file:text-white disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 md:text-sm", + "focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20", className )} {...props} diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx index f87ed26..7ec386d 100644 --- a/web/src/components/ui/select.tsx +++ b/web/src/components/ui/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]", className )} {...props} @@ -61,9 +61,9 @@ function SelectContent({ - + {children} diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 312a473..76a0bf6 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -12,12 +12,17 @@ const Toaster = ({ ...props }: ToasterProps) => { className="toaster group" toastOptions={{ classNames: { - toast: "border border-black bg-black text-white shadow-md", + toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold", + success: "border border-black bg-black text-white", + error: "border border-black bg-black text-white", + info: "border border-black bg-black text-white", + warning: "border border-black bg-black text-white", + loading: "border border-black bg-black text-white", 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", + icon: "text-[#00e8ff]", + actionButton: "bg-white text-black border border-black rounded-lg", + cancelButton: "bg-transparent text-white border border-white/40 rounded-lg", }, }} style={ diff --git a/web/src/lib/mappers/ticket.ts b/web/src/lib/mappers/ticket.ts index f01bc63..a744ab7 100644 --- a/web/src/lib/mappers/ticket.ts +++ b/web/src/lib/mappers/ticket.ts @@ -32,6 +32,19 @@ const serverTicketSchema = z.object({ tags: z.array(z.string()).default([]).optional(), lastTimelineEntry: z.string().nullable().optional(), metrics: z.any().nullable().optional(), + workSummary: z + .object({ + totalWorkedMs: z.number(), + activeSession: z + .object({ + id: z.string(), + agentId: z.string(), + startedAt: z.number(), + }) + .nullable(), + }) + .nullable() + .optional(), }); const serverAttachmentSchema = z.object({ @@ -75,6 +88,17 @@ export function mapTicketFromServer(input: unknown) { dueAt: s.dueAt ? new Date(s.dueAt) : null, firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null, + workSummary: s.workSummary + ? { + totalWorkedMs: s.workSummary.totalWorkedMs, + activeSession: s.workSummary.activeSession + ? { + ...s.workSummary.activeSession, + startedAt: new Date(s.workSummary.activeSession.startedAt), + } + : null, + } + : undefined, }; return ui as unknown as z.infer; } @@ -100,6 +124,17 @@ export function mapTicketWithDetailsFromServer(input: unknown) { createdAt: new Date(c.createdAt), updatedAt: new Date(c.updatedAt), })), + workSummary: s.workSummary + ? { + totalWorkedMs: s.workSummary.totalWorkedMs, + activeSession: s.workSummary.activeSession + ? { + ...s.workSummary.activeSession, + startedAt: new Date(s.workSummary.activeSession.startedAt), + } + : null, + } + : undefined, }; return ui as unknown as z.infer; } diff --git a/web/src/lib/schemas/ticket.ts b/web/src/lib/schemas/ticket.ts index 9c94e44..0f85a55 100644 --- a/web/src/lib/schemas/ticket.ts +++ b/web/src/lib/schemas/ticket.ts @@ -47,6 +47,7 @@ export const ticketCommentSchema = z.object({ id: z.string(), name: z.string(), size: z.number().optional(), + type: z.string().optional(), url: z.string().url().optional(), }) ) @@ -97,6 +98,19 @@ export const ticketSchema = z.object({ timeOpenedMinutes: z.number().nullable(), }) .nullable(), + workSummary: z + .object({ + totalWorkedMs: z.number(), + activeSession: z + .object({ + id: z.string(), + agentId: z.string(), + startedAt: z.coerce.date(), + }) + .nullable(), + }) + .nullable() + .optional(), }) export type Ticket = z.infer
{subjectError}
Arraste arquivos aqui ou inputRef.current?.click()}>selecione
Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos
+ Arraste arquivos aqui ou selecione +