"use client" import { z } from "zod" import { useEffect, useMemo, useRef, useState } from "react" import { format, parseISO } from "date-fns" import { ptBR } from "date-fns/locale" 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 } from "@/components/tickets/priority-select" import { CategorySelectFields } from "@/components/tickets/category-select" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { useDefaultQueues } from "@/hooks/use-default-queues" import { useLocalTimeZone } from "@/hooks/use-local-time-zone" import { cn } from "@/lib/utils" import { priorityStyles } from "@/lib/ticket-priority-style" import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers" import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types" import { Calendar as CalendarIcon } from "lucide-react" type TriggerVariant = "button" | "card" type CustomerOption = { id: string name: string email: string role: string companyId: string | null companyName: string | null companyIsAvulso: boolean avatarUrl: string | null } function getInitials(name: string | null | undefined, fallback: string): string { const normalizedName = (name ?? "").trim() if (normalizedName.length > 0) { const parts = normalizedName.split(/\s+/).slice(0, 2) const initials = parts.map((part) => part.charAt(0).toUpperCase()).join("") if (initials.length > 0) { return initials } } const normalizedFallback = (fallback ?? "").trim() return normalizedFallback.length > 0 ? normalizedFallback.charAt(0).toUpperCase() : "?" } type RequesterPreviewProps = { customer: CustomerOption | null company: { id: string; name: string; isAvulso?: boolean } | null } function RequesterPreview({ customer, company }: RequesterPreviewProps) { if (!customer) { return (
Selecione um solicitante para visualizar os detalhes aqui.
) } const initials = getInitials(customer.name, customer.email) const companyLabel = customer.companyName ?? company?.name ?? "Sem empresa" return (
{initials}

{customer.name || customer.email}

{customer.email}

{companyLabel}
) } const NO_COMPANY_VALUE = "__no_company__" 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(), companyId: z.string().optional(), requesterId: z.string().min(1, "Selecione um solicitante"), categoryId: z.string().min(1, "Selecione uma categoria"), subcategoryId: z.string().min(1, "Selecione uma categoria secundária"), }) export function NewTicketDialog({ triggerClassName, triggerVariant = "button", }: { triggerClassName?: string triggerVariant?: TriggerVariant } = {}) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const calendarTimeZone = useLocalTimeZone() const form = useForm>({ resolver: zodResolver(schema), defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null, assigneeId: null, companyId: NO_COMPANY_VALUE, requesterId: "", categoryId: "", subcategoryId: "", }, mode: "onTouched", }) const { convexUserId, isStaff, role, session, machineContext } = useAuth() const sessionUser = session?.user ?? null const queuesEnabled = Boolean(isStaff && convexUserId) useDefaultQueues(DEFAULT_TENANT_ID) const queuesRemote = useQuery( api.queues.summary, queuesEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" ) const queues = useMemo( () => (Array.isArray(queuesRemote) ? (queuesRemote as TicketQueueSummary[]) : []), [queuesRemote] ) 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 directoryQueryEnabled = queuesEnabled && Boolean(convexUserId) const companiesRemote = useQuery( api.companies.list, directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" ) const companies = useMemo( () => (Array.isArray(companiesRemote) ? companiesRemote : []).map((company) => ({ id: String(company.id), name: company.name, slug: company.slug ?? null, })), [companiesRemote] ) const ensureTicketFormDefaultsMutation = useMutation(api.tickets.ensureTicketFormDefaults) const hasEnsuredFormsRef = useRef(false) useEffect(() => { if (!convexUserId || hasEnsuredFormsRef.current) return hasEnsuredFormsRef.current = true ensureTicketFormDefaultsMutation({ tenantId: DEFAULT_TENANT_ID, actorId: convexUserId as Id<"users">, }).catch((error) => { console.error("Falha ao preparar formulários personalizados", error) hasEnsuredFormsRef.current = false }) }, [convexUserId, ensureTicketFormDefaultsMutation]) const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE const formsRemote = useQuery( api.tickets.listTicketForms, convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users">, companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined, } : "skip" ) as TicketFormDefinition[] | undefined const forms = useMemo(() => { const base: TicketFormDefinition = { key: "default", label: "Chamado", description: "Formulário básico para abertura de chamados gerais.", fields: [], } if (Array.isArray(formsRemote) && formsRemote.length > 0) { return [base, ...formsRemote] } return [base] }, [formsRemote]) const [selectedFormKey, setSelectedFormKey] = useState("default") const [customFieldValues, setCustomFieldValues] = useState>({}) const [openCalendarField, setOpenCalendarField] = useState(null) const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey]) const handleFormSelection = (key: string) => { setSelectedFormKey(key) setCustomFieldValues({}) } const handleCustomFieldChange = (field: TicketFormFieldDefinition, value: unknown) => { setCustomFieldValues((prev) => ({ ...prev, [field.id]: value, })) } const customersRemote = useQuery( api.users.listCustomers, directoryQueryEnabled ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" ) const rawCustomers = useMemo( () => (Array.isArray(customersRemote) ? (customersRemote as CustomerOption[]) : []), [customersRemote] ) const viewerCustomer = useMemo(() => { if (!convexUserId || !sessionUser) return null return { id: convexUserId, name: sessionUser.name ?? sessionUser.email, email: sessionUser.email, role: sessionUser.role ?? "customer", companyId: machineContext?.companyId ?? null, companyName: null, companyIsAvulso: false, avatarUrl: sessionUser.avatarUrl, } }, [convexUserId, sessionUser, machineContext?.companyId]) const customers = useMemo(() => { if (!viewerCustomer) return rawCustomers const exists = rawCustomers.some((customer) => customer.id === viewerCustomer.id) return exists ? rawCustomers : [...rawCustomers, viewerCustomer] }, [rawCustomers, viewerCustomer]) const [attachments, setAttachments] = useState>([]) const [customersInitialized, setCustomersInitialized] = useState(false) const attachmentsTotalBytes = useMemo( () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), [attachments] ) const priorityValue = form.watch("priority") as TicketPriority const queueValue = form.watch("queueName") ?? "NONE" const assigneeValue = form.watch("assigneeId") ?? null const assigneeSelectValue = assigneeValue ?? "NONE" const requesterValue = form.watch("requesterId") ?? "" const categoryIdValue = form.watch("categoryId") const subcategoryIdValue = form.watch("subcategoryId") const isSubmitted = form.formState.isSubmitted const companyOptions = useMemo(() => { const map = new Map() companies.forEach((company) => { const trimmedName = company.name.trim() const slugFallback = company.slug?.trim() const label = trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}` map.set(company.id, { id: company.id, name: label, isAvulso: false, keywords: company.slug ? [company.slug] : [], }) }) customers.forEach((customer) => { if (customer.companyId && !map.has(customer.companyId)) { const trimmedName = customer.companyName?.trim() ?? "" const label = trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}` map.set(customer.companyId, { id: customer.companyId, name: label, isAvulso: customer.companyIsAvulso, keywords: [], }) } }) const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [ { id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false }, ] const sorted = Array.from(map.values()) .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) return [...base, ...sorted] }, [companies, customers]) const filteredCustomers = useMemo(() => { if (companyValue === NO_COMPANY_VALUE) { return customers.filter((customer) => !customer.companyId) } return customers.filter((customer) => customer.companyId === companyValue) }, [companyValue, customers]) const companyOptionMap = useMemo( () => new Map(companyOptions.map((option) => [option.id, option])), [companyOptions], ) const companyComboboxOptions = useMemo( () => companyOptions.map((option) => ({ value: option.id, label: option.name, description: option.isAvulso ? "Empresa avulsa" : undefined, keywords: option.keywords, })), [companyOptions], ) const selectedCompanyOption = useMemo( () => companyOptionMap.get(companyValue) ?? null, [companyOptionMap, companyValue], ) const requesterById = useMemo( () => new Map(customers.map((customer) => [customer.id, customer])), [customers], ) const selectedRequester = requesterById.get(requesterValue) ?? null const requesterComboboxOptions = useMemo( () => filteredCustomers.map((customer) => ({ value: customer.id, label: customer.name && customer.name.trim().length > 0 ? customer.name : customer.email, description: customer.email, keywords: [ customer.email.toLowerCase(), customer.companyName?.toLowerCase?.() ?? "", customer.name?.toLowerCase?.() ?? "", ].filter(Boolean), })), [filteredCustomers], ) 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) const allowTicketMentions = useMemo(() => { const normalized = (role ?? "").toLowerCase() return normalized === "admin" || normalized === "agent" || normalized === "collaborator" }, [role]) useEffect(() => { if (!open) { setCustomersInitialized(false) form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false }) form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false }) return } if (customersInitialized) return if (!customers.length) return let initialRequester = form.getValues("requesterId") if (!initialRequester || !customers.some((customer) => customer.id === initialRequester)) { if (convexUserId && customers.some((customer) => customer.id === convexUserId)) { initialRequester = convexUserId } else { initialRequester = customers[0].id } } const selected = customers.find((customer) => customer.id === initialRequester) ?? null form.setValue("requesterId", initialRequester ?? "", { shouldDirty: false, shouldTouch: false }) if (selected?.companyId) { form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false }) } else { form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false }) } setCustomersInitialized(true) }, [open, customersInitialized, customers, convexUserId, form]) useEffect(() => { if (!open || !customersInitialized) return const options = filteredCustomers if (options.length === 0) { if (requesterValue !== "") { form.setValue("requesterId", "", { shouldDirty: true, shouldTouch: true, shouldValidate: form.formState.isSubmitted, }) } return } if (!options.some((customer) => customer.id === requesterValue)) { form.setValue("requesterId", options[0].id, { shouldDirty: true, shouldTouch: true, shouldValidate: form.formState.isSubmitted, }) } }, [open, customersInitialized, filteredCustomers, requesterValue, form]) useEffect(() => { if (requesterValue && form.formState.errors.requesterId) { form.clearErrors("requesterId") } }, [requesterValue, form]) useEffect(() => { if (!open) { setAssigneeInitialized(false) setOpenCalendarField(null) return } if (assigneeInitialized) return if (!convexUserId) return form.setValue("assigneeId", convexUserId, { shouldDirty: false, shouldTouch: false }) setAssigneeInitialized(true) }, [open, assigneeInitialized, convexUserId, form]) // Default queue to "Chamados" if available when opening useEffect(() => { if (!open) return const current = form.getValues("queueName") if (current) return const hasChamados = queues.some((q) => q.name === "Chamados") if (hasChamados) { form.setValue("queueName", "Chamados", { shouldDirty: false, shouldTouch: false }) } }, [open, queues, 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 } let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = [] if (selectedFormKey !== "default" && selectedForm?.fields?.length) { const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues) if (!normalized.ok) { toast.error(normalized.message, { id: "new-ticket" }) setLoading(false) return } customFieldsPayload = normalized.payload } 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 requesterToSend = values.requesterId as Id<"users"> 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: requesterToSend, assigneeId: selectedAssignee ? (selectedAssignee as Id<"users">) : undefined, categoryId: values.categoryId as Id<"ticketCategories">, subcategoryId: values.subcategoryId as Id<"ticketSubcategories">, formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined, customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined, }) const summaryFallback = values.summary?.trim() ?? "" const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback const MAX_COMMENT_CHARS = 20000 const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim() if (plainForLimit.length > MAX_COMMENT_CHARS) { toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "new-ticket" }) setLoading(false) return } 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: "INTERNAL", 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() setSelectedFormKey("default") setCustomFieldValues({}) 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) } } const cardTrigger = ( ) const buttonTrigger = ( ) return ( {triggerVariant === "card" ? cardTrigger : buttonTrigger}
Novo ticket Preencha as informações básicas para abrir um chamado.
{forms.length > 1 ? (

Tipo de solicitação

{forms.map((formDef) => ( ))}
{selectedForm?.description ? (

{selectedForm.description}

) : null}
) : null}
Assunto * Resumo