sistema-de-chamados/src/components/portal/portal-ticket-form.tsx

257 lines
11 KiB
TypeScript
Raw Blame History

"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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
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