feat: link tickets in comments and align admin sidebars

This commit is contained in:
Esdras Renan 2025-10-23 00:46:50 -03:00
parent c35eb673d3
commit b0f57009ac
15 changed files with 1606 additions and 424 deletions

View file

@ -42,6 +42,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const isManager = normalizedRole === "manager"
const canStaffComment = hasAssignee || isManager
const canComment = isRequester || (isStaff && canStaffComment)
const allowTicketMentions = normalizedRole === "admin" || normalizedRole === "agent"
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment)
@ -303,6 +304,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
disabled={savingCommentId === commentId}
placeholder="Edite o comentário..."
ticketMention={{ enabled: allowTicketMentions }}
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
@ -381,7 +383,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</div>
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." className="rounded-2xl border border-slate-200" disabled={!canComment} />
<RichTextEditor
value={body}
onChange={setBody}
placeholder="Escreva um comentário..."
className="rounded-2xl border border-slate-200"
disabled={!canComment}
ticketMention={{ enabled: allowTicketMentions }}
/>
<Dropzone
onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])}
currentFileCount={attachmentsToSend.length}

View file

@ -208,6 +208,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
@ -321,12 +323,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId)
throw new Error("assignee-not-allowed")
}
const reasonValue = assigneeChangeReason.trim()
if (reasonValue.length < 5) {
setAssigneeReasonError("Descreva o motivo com pelo menos 5 caracteres.")
toast.error("Informe um motivo para registrar a troca do responsável.", { id: "assignee" })
return
}
if (reasonValue.length > 1000) {
setAssigneeReasonError("Use no máximo 1.000 caracteres.")
toast.error("Reduza o motivo para até 1.000 caracteres.", { id: "assignee" })
return
}
setAssigneeReasonError(null)
toast.loading("Atualizando responsável...", { id: "assignee" })
try {
await changeAssignee({
ticketId: ticket.id as Id<"tickets">,
assigneeId: assigneeSelection as Id<"users">,
actorId: convexUserId as Id<"users">,
reason: reasonValue,
})
toast.success("Responsável atualizado!", { id: "assignee" })
if (assigneeSelection) {
@ -341,6 +356,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
}
}
setAssigneeChangeReason("")
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o responsável.", { id: "assignee" })
@ -387,11 +403,15 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
})
setQueueSelection(currentQueueName)
setAssigneeSelection(currentAssigneeId)
setAssigneeChangeReason("")
setAssigneeReasonError(null)
setEditing(false)
}
useEffect(() => {
if (editing) return
setAssigneeChangeReason("")
setAssigneeReasonError(null)
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
@ -1097,6 +1117,28 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{assigneeState?.name ?? "Não atribuído"}</span>
)}
</div>
{editing && assigneeDirty ? (
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
<span className={sectionLabelClass}>Motivo da troca</span>
<Textarea
value={assigneeChangeReason}
onChange={(event) => {
setAssigneeChangeReason(event.target.value)
if (assigneeReasonError) {
setAssigneeReasonError(null)
}
}}
placeholder="Explique brevemente por que o chamado será reatribuído..."
className="min-h-[96px]"
/>
<p className="text-xs text-neutral-500">
O motivo é registrado como comentário interno visível para administradores e agentes.
</p>
{assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : null}
</div>
) : null}
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>