"use client" import { useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useMutation } from "convex/react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { TicketPriority } from "@/lib/schemas/ticket" import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { useAuth } from "@/lib/auth-client" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { CategorySelectFields } from "@/components/tickets/category-select" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor } from "@/components/ui/rich-text-editor" const DEFAULT_PRIORITY: TicketPriority = "MEDIUM" function toHtml(text: string) { const escaped = text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") return `

${escaped.replace(/\n/g, "
")}

` } export function PortalTicketForm() { const router = useRouter() const { convexUserId, session, machineContext, machineContextError, machineContextLoading } = useAuth() const createTicket = useMutation(api.tickets.create) const addComment = useMutation(api.tickets.addComment) const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null const [subject, setSubject] = useState("") const [summary, setSummary] = useState("") const [description, setDescription] = useState("") const [categoryId, setCategoryId] = useState(null) const [subcategoryId, setSubcategoryId] = useState(null) const [attachments, setAttachments] = useState>([]) const attachmentsTotalBytes = useMemo( () => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0), [attachments] ) const [isSubmitting, setIsSubmitting] = useState(false) const machineInactive = machineContext?.isActive === false const isFormValid = useMemo(() => { return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId && !machineInactive) }, [subject, description, categoryId, subcategoryId, machineInactive]) const isViewerReady = Boolean(viewerId) const viewerErrorMessage = useMemo(() => { if (!machineContextError) return null const suffix = machineContextError.status ? ` (status ${machineContextError.status})` : "" return `${machineContextError.message}${suffix}` }, [machineContextError]) async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (isSubmitting || !isFormValid) return if (machineInactive) { toast.error("Esta máquina está desativada no momento. Reative-a para abrir novos chamados.", { id: "portal-new-ticket" }) return } if (!viewerId) { const detail = viewerErrorMessage ? ` Detalhes: ${viewerErrorMessage}` : "" toast.error( `Não foi possível identificar o colaborador vinculado a esta máquina. Tente abrir novamente o portal ou contate o suporte.${detail}`, { id: "portal-new-ticket" } ) return } const trimmedSubject = subject.trim() const trimmedSummary = summary.trim() const sanitizedDescription = sanitizeEditorHtml(description || "") const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim() if (plainDescription.length === 0) { toast.error("Descreva o que aconteceu para que possamos ajudar melhor.", { id: "portal-new-ticket" }) return } setIsSubmitting(true) toast.loading("Abrindo chamado...", { id: "portal-new-ticket" }) try { const id = await createTicket({ actorId: viewerId, tenantId, subject: trimmedSubject, summary: trimmedSummary || undefined, priority: DEFAULT_PRIORITY, channel: "MANUAL", queueId: undefined, requesterId: viewerId, categoryId: categoryId as Id<"ticketCategories">, subcategoryId: subcategoryId as Id<"ticketSubcategories">, }) if (plainDescription.length > 0) { const MAX_COMMENT_CHARS = 20000 if (plainDescription.length > MAX_COMMENT_CHARS) { toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "portal-new-ticket" }) setIsSubmitting(false) return } const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject) const typedAttachments = attachments.map((file) => ({ storageId: file.storageId as Id<"_storage">, name: file.name, size: file.size, type: file.type, })) await addComment({ ticketId: id as Id<"tickets">, authorId: viewerId, visibility: "PUBLIC", body: htmlBody, attachments: typedAttachments, }) } toast.success("Chamado criado com sucesso!", { id: "portal-new-ticket" }) setAttachments([]) router.replace(`/portal/tickets/${id}`) } catch (error) { console.error(error) toast.error("Não foi possível abrir o chamado.", { id: "portal-new-ticket" }) } finally { setIsSubmitting(false) } } return ( Abrir novo chamado {machineInactive ? (
Esta máquina foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
) : null} {!isViewerReady ? (
Vincule esta máquina a um colaborador na aplicação desktop para enviar chamados em nome dele. {machineContextLoading ? (

Carregando informa��es da m�quina...

) : null} {viewerErrorMessage ? (

Detalhes do erro: {viewerErrorMessage}

) : null}
) : null}
setSubject(event.target.value)} placeholder="Ex.: Problema de acesso ao sistema" disabled={machineInactive || isSubmitting} required />
setSummary(event.target.value)} placeholder="Descreva rapidamente o que está acontecendo" maxLength={600} disabled={machineInactive || isSubmitting} />
setDescription(html)} placeholder="Compartilhe passos para reproduzir, mensagens de erro ou informações adicionais." className="rounded-2xl border border-slate-200 shadow-sm focus-within:border-neutral-900 focus-within:ring-neutral-900/20" disabled={machineInactive || isSubmitting} />
Anexos (opcional) setAttachments((prev) => [...prev, ...files])} className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-3 py-4 text-sm text-neutral-600 shadow-inner" currentFileCount={attachments.length} currentTotalBytes={attachmentsTotalBytes} disabled={!isViewerReady || machineInactive || isSubmitting} />

Formatos comuns de imagens e documentos são aceitos.

) }