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

-
-
- - setSubject(e.target.value)} required /> -
-
- -