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

@ -1,11 +1,21 @@
"use client"
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import type { ReactNode } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
import Placeholder from "@tiptap/extension-placeholder"
import Mention from "@tiptap/extension-mention"
import { ReactRenderer } from "@tiptap/react"
import tippy, { type Instance, type Props as TippyProps } from "tippy.js"
import { cn } from "@/lib/utils"
import sanitize from "sanitize-html"
import { Button } from "@/components/ui/button"
@ -33,8 +43,356 @@ type RichTextEditorProps = {
placeholder?: string
disabled?: boolean
minHeight?: number
ticketMention?: {
enabled?: boolean
}
}
type TicketMentionItem = {
id: string
reference: number
subject: string
status: string
priority: string
requesterName: string | null
assigneeName: string | null
companyName: string | null
url: string
updatedAt: string
}
type TicketMentionSuggestionProps = {
command: (item: TicketMentionItem) => void
items: TicketMentionItem[]
onRegister?: (handler: ((event: KeyboardEvent) => boolean) | null) => void
}
const TICKET_MENTION_CLASS = "ticket-mention"
function formatMentionSubject(subject: string) {
if (!subject) return ""
return subject.length > 60 ? `${subject.slice(0, 57)}` : subject
}
const statusLabels: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const statusTone: Record<string, string> = {
PENDING: "bg-amber-400",
AWAITING_ATTENDANCE: "bg-sky-500",
PAUSED: "bg-violet-500",
RESOLVED: "bg-emerald-500",
}
const priorityLabels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const mentionCache = new Map<string, TicketMentionItem[]>()
let mentionAbortController: AbortController | null = null
async function fetchTicketMentions(query: string): Promise<TicketMentionItem[]> {
const cacheKey = query.trim().toLowerCase()
if (mentionCache.has(cacheKey)) {
return mentionCache.get(cacheKey) ?? []
}
try {
mentionAbortController?.abort()
mentionAbortController = new AbortController()
const response = await fetch(`/api/tickets/mentions?q=${encodeURIComponent(query)}`, {
signal: mentionAbortController.signal,
})
if (!response.ok) {
return []
}
const json = (await response.json()) as { items?: TicketMentionItem[] }
const items = Array.isArray(json.items) ? json.items : []
mentionCache.set(cacheKey, items)
return items
} catch (error) {
if ((error as Error).name === "AbortError") {
return []
}
return []
}
}
function TicketMentionList({ items, command, onRegister }: TicketMentionSuggestionProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const selectItem = useCallback(
(index: number) => {
const item = items[index]
if (item) {
command(item)
}
},
[command, items]
)
const upHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + items.length - 1) % items.length
return nextIndex
})
}, [items.length])
const downHandler = useCallback(() => {
setSelectedIndex((prev) => {
const nextIndex = (prev + 1) % items.length
return nextIndex
})
}, [items.length])
const enterHandler = useCallback(() => {
selectItem(selectedIndex)
}, [selectItem, selectedIndex])
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === "ArrowUp") {
upHandler()
return true
}
if (event.key === "ArrowDown") {
downHandler()
return true
}
if (event.key === "Enter") {
enterHandler()
return true
}
return false
},
[downHandler, enterHandler, upHandler]
)
useEffect(() => {
onRegister?.(handleKeyDown)
return () => {
onRegister?.(null)
}
}, [handleKeyDown, onRegister])
useEffect(() => {
setSelectedIndex(0)
}, [items])
if (!items.length) {
return (
<div className="min-w-[260px] p-3 text-sm text-muted-foreground">
Nenhum chamado encontrado com esse termo.
</div>
)
}
return (
<div className="max-h-72 min-w-[320px] space-y-1 overflow-y-auto p-2">
{items.map((item, index) => {
const isActive = index === selectedIndex
const status = statusLabels[item.status] ?? item.status
const statusDot = statusTone[item.status] ?? "bg-slate-400"
const priority = priorityLabels[item.priority] ?? item.priority
return (
<button
key={item.id}
type="button"
className={cn(
"w-full rounded-lg border border-transparent px-3 py-2 text-left transition",
isActive ? "border-slate-200 bg-slate-100" : "hover:bg-slate-50"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(event) => {
event.preventDefault()
selectItem(index)
}}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-neutral-900">#{item.reference}</span>
<span className="text-xs font-medium uppercase tracking-wide text-neutral-500">{priority}</span>
</div>
<div className="mt-1 line-clamp-1 text-sm text-neutral-700">{formatMentionSubject(item.subject)}</div>
<div className="mt-1 flex items-center gap-2 text-xs text-neutral-500">
<span className="inline-flex items-center gap-1">
<span className={cn("inline-flex size-2 rounded-full", statusDot)} />
{status}
</span>
{item.companyName ? <span> {item.companyName}</span> : null}
{item.assigneeName ? <span> {item.assigneeName}</span> : null}
</div>
</button>
)
})}
</div>
)
}
TicketMentionList.displayName = "TicketMentionList"
const TicketMentionListComponent = (props: TicketMentionSuggestionProps) => (
<TicketMentionList {...props} />
)
const TicketMentionExtension = Mention.extend({
name: "ticketMention",
addAttributes() {
return {
id: { default: null },
reference: { default: null },
subject: { default: null },
status: { default: null },
priority: { default: null },
url: { default: null },
}
},
parseHTML() {
return [
{
tag: `a[data-ticket-mention="true"]`,
getAttrs: (dom: HTMLElement | string) => {
if (dom instanceof HTMLElement) {
return {
id: dom.dataset.ticketId ?? null,
reference: dom.dataset.ticketReference ?? null,
status: dom.dataset.ticketStatus ?? null,
priority: dom.dataset.ticketPriority ?? null,
subject: dom.getAttribute("data-ticket-subject") ?? dom.textContent ?? null,
url: dom.getAttribute("href") ?? null,
}
}
return {}
},
},
]
},
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
const referenceValue =
HTMLAttributes.reference ?? HTMLAttributes["data-ticket-reference"] ?? ""
const reference = String(referenceValue ?? "")
const subjectValue =
HTMLAttributes.subject ?? HTMLAttributes["data-ticket-subject"] ?? ""
const subject = String(subjectValue ?? "")
const status = String(HTMLAttributes.status ?? HTMLAttributes["data-ticket-status"] ?? "PENDING").toUpperCase()
const priority = String(HTMLAttributes.priority ?? HTMLAttributes["data-ticket-priority"] ?? "MEDIUM").toUpperCase()
const href = String(HTMLAttributes.url ?? HTMLAttributes.href ?? "#")
return [
"a",
{
...HTMLAttributes,
href,
"data-ticket-mention": "true",
"data-ticket-id": HTMLAttributes.id ?? HTMLAttributes["data-ticket-id"] ?? "",
"data-ticket-reference": reference ?? "",
"data-ticket-status": status,
"data-ticket-priority": priority,
"data-ticket-subject": subject ?? "",
class: TICKET_MENTION_CLASS,
},
[
"span",
{ class: "ticket-mention-dot" },
],
[
"span",
{ class: "ticket-mention-ref" },
`#${reference ?? ""}`,
],
[
"span",
{ class: "ticket-mention-sep" },
"•",
],
[
"span",
{ class: "ticket-mention-subject" },
formatMentionSubject(subject ?? ""),
],
]
},
renderLabel({ node }: { node: { attrs: Record<string, unknown> } }) {
const subjectAttr = node.attrs.subject ?? node.attrs["data-ticket-subject"] ?? ""
const displayedSubject = typeof subjectAttr === "string" ? subjectAttr : String(subjectAttr ?? "")
const refAttr = node.attrs.reference ?? node.attrs.id ?? node.attrs["data-ticket-reference"] ?? ""
const reference = typeof refAttr === "string" ? refAttr : String(refAttr ?? "")
const subjectPart = displayedSubject ? `${formatMentionSubject(displayedSubject)}` : ""
return `#${reference}${subjectPart}`
},
}).configure({
suggestion: {
char: "@",
startOfLine: false,
allowSpaces: false,
render: () => {
let component: ReactRenderer | null = null
let popup: Instance<TippyProps> | null = null
let keydownHandler: ((event: KeyboardEvent) => boolean) | null = null
const registerHandler = (handler: ((event: KeyboardEvent) => boolean) | null) => {
keydownHandler = handler
}
return {
onStart: (props) => {
const listProps: TicketMentionSuggestionProps = {
command: props.command,
items: props.items,
onRegister: registerHandler,
}
component = new ReactRenderer(TicketMentionListComponent, {
props: listProps,
editor: props.editor,
})
if (!props.clientRect) return
popup = tippy(document.body, {
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
},
onUpdate(props) {
component?.updateProps({
command: props.command,
items: props.items,
onRegister: registerHandler,
})
if (!props.clientRect) return
popup?.setProps({
getReferenceClientRect: () => props.clientRect?.() ?? new DOMRect(),
})
},
onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.hide()
return true
}
if (keydownHandler && keydownHandler(props.event)) {
return true
}
return false
},
onExit() {
popup?.destroy()
component?.destroy()
keydownHandler = null
},
}
},
items: async ({ query }) => {
return fetchTicketMentions(query)
},
},
})
export function RichTextEditor({
value,
onChange,
@ -42,9 +400,10 @@ export function RichTextEditor({
placeholder = "Escreva aqui...",
disabled,
minHeight = 120,
ticketMention,
}: RichTextEditorProps) {
const editor = useEditor({
extensions: [
const extensions = useMemo(() => {
const baseExtensions = [
StarterKit.configure({
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
@ -56,6 +415,13 @@ export function RichTextEditor({
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Placeholder.configure({ placeholder }),
]
return ticketMention?.enabled ? [...baseExtensions, TicketMentionExtension] : baseExtensions
}, [placeholder, ticketMention?.enabled])
const editor = useEditor({
extensions: [
...extensions,
],
editorProps: {
attributes: {
@ -300,15 +666,48 @@ export function sanitizeEditorHtml(html: string): string {
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
],
allowedAttributes: {
a: ["href","name","target","rel"],
span: ["style"],
a: [
"href",
"name",
"target",
"rel",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
"title",
],
span: [
"style",
"class",
"data-ticket-mention",
"data-ticket-id",
"data-ticket-reference",
"data-ticket-status",
"data-ticket-priority",
"data-ticket-subject",
],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http","https","mailto"],
// prevent target=_self phishing
transformTags: {
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
a: (tagName, attribs) => {
const isMention = attribs["data-ticket-mention"] === "true"
const nextAttribs = {
...attribs,
rel: attribs.rel ?? "noopener noreferrer",
target: isMention ? "_self" : "_blank",
}
return {
tagName,
attribs: nextAttribs,
}
},
},
// disallow inline event handlers
allowVulnerableTags: false,