257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
"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, """)
|
||
.replace(/'/g, "'")
|
||
return `<p>${escaped.replace(/\n/g, "<br />")}</p>`
|
||
}
|
||
|
||
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<string | null>(null)
|
||
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
|
||
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||
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 dispositivo 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 dispositivo. 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">,
|
||
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
|
||
})
|
||
|
||
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 (
|
||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||
<CardHeader className="px-5 py-5">
|
||
<CardTitle className="text-xl font-semibold text-neutral-900">Abrir novo chamado</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6 px-5 pb-6">
|
||
{machineInactive ? (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||
Esta dispositivo foi desativada pelos administradores e não pode abrir novos chamados até ser reativada.
|
||
</div>
|
||
) : null}
|
||
{!isViewerReady ? (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||
Vincule esta dispositivo a um colaborador na aplicação desktop para enviar chamados em nome dele.
|
||
{machineContextLoading ? (
|
||
<p className="mt-2 text-xs text-amber-600">Carregando informa<EFBFBD><EFBFBD>es da m<EFBFBD>quina...</p>
|
||
) : null}
|
||
{viewerErrorMessage ? (
|
||
<p className="mt-2 text-xs text-amber-600">
|
||
Detalhes do erro: {viewerErrorMessage}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
<div className="space-y-3">
|
||
<div className="space-y-1">
|
||
<label htmlFor="subject" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||
Assunto <span className="text-red-500">*</span>
|
||
</label>
|
||
<Input
|
||
id="subject"
|
||
value={subject}
|
||
onChange={(event) => setSubject(event.target.value)}
|
||
placeholder="Ex.: Problema de acesso ao sistema"
|
||
disabled={machineInactive || isSubmitting}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label htmlFor="summary" className="text-sm font-medium text-neutral-800">
|
||
Resumo (opcional)
|
||
</label>
|
||
<Input
|
||
id="summary"
|
||
value={summary}
|
||
onChange={(event) => setSummary(event.target.value)}
|
||
placeholder="Descreva rapidamente o que está acontecendo"
|
||
maxLength={600}
|
||
disabled={machineInactive || isSubmitting}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||
Detalhes <span className="text-red-500">*</span>
|
||
</label>
|
||
<RichTextEditor
|
||
value={description}
|
||
onChange={(html) => 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}
|
||
ticketMention={{ enabled: allowTicketMentions }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<CategorySelectFields
|
||
tenantId={tenantId}
|
||
categoryId={categoryId}
|
||
subcategoryId={subcategoryId}
|
||
onCategoryChange={setCategoryId}
|
||
onSubcategoryChange={setSubcategoryId}
|
||
layout="stacked"
|
||
categoryLabel="Categoria *"
|
||
subcategoryLabel="Subcategoria *"
|
||
secondaryEmptyLabel="Selecione uma categoria"
|
||
/>
|
||
<div className="space-y-1">
|
||
<span className="text-sm font-medium text-neutral-800">Anexos (opcional)</span>
|
||
<Dropzone
|
||
onUploaded={(files) => 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}
|
||
/>
|
||
<p className="text-xs text-neutral-500">
|
||
Formatos comuns de imagens e documentos são aceitos.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => router.push("/portal/tickets")}
|
||
className="rounded-full border-slate-300 px-6 text-sm font-semibold text-neutral-700 hover:bg-neutral-100"
|
||
disabled={isSubmitting}
|
||
>
|
||
Cancelar
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
disabled={!isFormValid || isSubmitting || machineInactive}
|
||
className="rounded-full bg-neutral-900 px-6 text-sm font-semibold text-white hover:bg-neutral-900/90"
|
||
>
|
||
Registrar chamado
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
const allowTicketMentions = true
|