"use client" import { z } from "zod" import { useEffect, useMemo, useState } from "react" import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { useMutation, useQuery } from "convex/react" import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" import { Spinner } from "@/components/ui/spinner" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { PriorityIcon, priorityStyles, } from "@/components/tickets/priority-select" import { CategorySelectFields } from "@/components/tickets/category-select" import { useDefaultQueues } from "@/hooks/use-default-queues" const schema = z.object({ subject: z.string().default(""), summary: z.string().optional(), description: z.string().default(""), priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), queueName: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(), categoryId: z.string().min(1, "Selecione uma categoria"), subcategoryId: z.string().min(1, "Selecione uma categoria secundária"), }) export function NewTicketDialog() { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const form = useForm>({ resolver: zodResolver(schema), defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null, assigneeId: null, categoryId: "", subcategoryId: "", }, mode: "onTouched", }) const { convexUserId, isStaff } = useAuth() const queuesEnabled = Boolean(isStaff && convexUserId) const queueArgs = queuesEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" useDefaultQueues(DEFAULT_TENANT_ID) const queuesRaw = useQuery( queuesEnabled ? api.queues.summary : "skip", queueArgs ) as TicketQueueSummary[] | undefined const queues = useMemo(() => queuesRaw ?? [], [queuesRaw]) const create = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) const staffRaw = useQuery(api.users.listAgents, { tenantId: DEFAULT_TENANT_ID }) as Doc<"users">[] | undefined const staff = useMemo( () => (staffRaw ?? []).sort((a, b) => a.name.localeCompare(b.name, "pt-BR")), [staffRaw] ) const [attachments, setAttachments] = useState>([]) const priorityValue = form.watch("priority") as TicketPriority const channelValue = form.watch("channel") const queueValue = form.watch("queueName") ?? "NONE" const assigneeValue = form.watch("assigneeId") ?? null const assigneeSelectValue = assigneeValue ?? "NONE" const categoryIdValue = form.watch("categoryId") const subcategoryIdValue = form.watch("subcategoryId") const isSubmitted = form.formState.isSubmitted 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" const [assigneeInitialized, setAssigneeInitialized] = useState(false) useEffect(() => { if (!open) { setAssigneeInitialized(false) return } if (assigneeInitialized) return if (!convexUserId) return form.setValue("assigneeId", convexUserId, { shouldDirty: false, shouldTouch: false }) setAssigneeInitialized(true) }, [open, assigneeInitialized, convexUserId, form]) const handleCategoryChange = (value: string) => { const previous = form.getValues("categoryId") ?? "" const next = value ?? "" form.setValue("categoryId", next, { shouldDirty: previous !== next && previous !== "", shouldTouch: true, shouldValidate: isSubmitted, }) if (!isSubmitted) { form.clearErrors("categoryId") } } const handleSubcategoryChange = (value: string) => { const previous = form.getValues("subcategoryId") ?? "" const next = value ?? "" form.setValue("subcategoryId", next, { shouldDirty: previous !== next && previous !== "", shouldTouch: true, shouldValidate: isSubmitted, }) if (!isSubmitted) { form.clearErrors("subcategoryId") } } async function submit(values: z.infer) { if (!convexUserId) return const subjectTrimmed = (values.subject ?? "").trim() if (subjectTrimmed.length < 3) { form.setError("subject", { type: "min", message: "Informe um assunto com pelo menos 3 caracteres." }) return } const sanitizedDescription = sanitizeEditorHtml(values.description ?? "") const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim() if (plainDescription.length === 0) { form.setError("description", { type: "custom", message: "Descreva o contexto do chamado." }) return } setLoading(true) toast.loading("Criando ticket…", { id: "new-ticket" }) try { const sel = queues.find((q) => q.name === values.queueName) const selectedAssignee = form.getValues("assigneeId") ?? null const id = await create({ actorId: convexUserId as Id<"users">, tenantId: DEFAULT_TENANT_ID, subject: subjectTrimmed, summary: values.summary?.trim() || undefined, priority: values.priority, channel: values.channel, queueId: sel?.id as Id<"queues"> | undefined, requesterId: convexUserId as Id<"users">, assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined, categoryId: values.categoryId as Id<"ticketCategories">, subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, }) const summaryFallback = values.summary?.trim() ?? "" const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback if (attachments.length > 0 || bodyHtml.trim().length > 0) { const typedAttachments = attachments.map((a) => ({ storageId: a.storageId as unknown as Id<"_storage">, name: a.name, size: a.size, type: a.type, })) await addComment({ ticketId: id as Id<"tickets">, authorId: convexUserId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments }) } toast.success("Ticket criado!", { id: "new-ticket" }) setOpen(false) form.reset({ subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null, assigneeId: convexUserId ?? null, categoryId: "", subcategoryId: "", }) form.clearErrors() setAssigneeInitialized(false) setAttachments([]) // Navegar para o ticket recém-criado window.location.href = `/tickets/${id}` } catch { toast.error("Não foi possível criar o ticket.", { id: "new-ticket" }) } finally { setLoading(false) } } return (
Novo ticket Preencha as informações básicas para abrir um chamado.
Assunto * Resumo