Merge pull request #9 from esdrasrenan/feat/convex-tickets-core

feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-05 02:08:05 -03:00 committed by GitHub
commit f3a9edf712
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 453 additions and 100 deletions

View file

@ -1,4 +1,4 @@
import { action, query } from "./_generated/server";
import { action } from "./_generated/server";
import { v } from "convex/values";
export const generateUploadUrl = action({
@ -8,7 +8,7 @@ export const generateUploadUrl = action({
},
});
export const getUrl = query({
export const getUrl = action({
args: { storageId: v.id("_storage") },
handler: async (ctx, { storageId }) => {
const url = await ctx.storage.getUrl(storageId);

View file

@ -405,6 +405,45 @@ export const addComment = mutation({
},
});
export const updateComment = mutation({
args: {
ticketId: v.id("tickets"),
commentId: v.id("ticketComments"),
actorId: v.id("users"),
body: v.string(),
},
handler: async (ctx, { ticketId, commentId, actorId, body }) => {
const comment = await ctx.db.get(commentId);
if (!comment || comment.ticketId !== ticketId) {
throw new ConvexError("Comentário não encontrado");
}
if (comment.authorId !== actorId) {
throw new ConvexError("Você não tem permissão para editar este comentário");
}
const now = Date.now();
await ctx.db.patch(commentId, {
body,
updatedAt: now,
});
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "COMMENT_EDITED",
payload: {
commentId,
actorId,
actorName: actor?.name,
actorAvatar: actor?.avatarUrl,
},
createdAt: now,
});
await ctx.db.patch(ticketId, { updatedAt: now });
},
});
export const removeCommentAttachment = mutation({
args: {
ticketId: v.id("tickets"),

View file

@ -127,7 +127,7 @@
@apply text-foreground;
}
.rich-text p { @apply my-2; }
.rich-text a { @apply text-[#00e8ff] underline; }
.rich-text a { @apply text-neutral-900 underline; }
.rich-text ul { @apply my-2 list-disc ps-5; }
.rich-text ol { @apply my-2 list-decimal ps-5; }
.rich-text li { @apply my-1; }

View file

@ -1,11 +1,11 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { FileIcon, Trash2, X } from "lucide-react"
import { useMutation } from "convex/react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -21,6 +21,7 @@ import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/component
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner"
interface TicketCommentsProps {
ticket: TicketWithDetails
@ -35,6 +36,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment)
const [body, setBody] = useState("")
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
@ -42,6 +44,52 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
const [removingAttachment, setRemovingAttachment] = useState(false)
const [editingComment, setEditingComment] = useState<{ id: string; value: string } | null>(null)
const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const startEditingComment = useCallback((commentId: string, currentBody: string) => {
setEditingComment({ id: commentId, value: currentBody || "" })
}, [])
const cancelEditingComment = useCallback(() => {
setEditingComment(null)
}, [])
const saveEditedComment = useCallback(
async (commentId: string, originalBody: string) => {
if (!editingComment || editingComment.id !== commentId) return
if (!userId) return
if (commentId.startsWith("temp-")) return
const sanitized = sanitizeEditorHtml(editingComment.value)
if (sanitized === originalBody) {
setEditingComment(null)
return
}
const toastId = `edit-comment-${commentId}`
setSavingCommentId(commentId)
toast.loading("Salvando comentário...", { id: toastId })
try {
await updateComment({
ticketId: ticket.id as Id<"tickets">,
commentId: commentId as unknown as Id<"ticketComments">,
actorId: userId as Id<"users">,
body: sanitized,
})
setLocalBodies((prev) => ({ ...prev, [commentId]: sanitized }))
setEditingComment(null)
toast.success("Comentário atualizado!", { id: toastId })
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o comentário.", { id: toastId })
} finally {
setSavingCommentId(null)
}
},
[editingComment, ticket.id, updateComment, userId]
)
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
@ -150,12 +198,16 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
const bodyHtml = comment.body ?? ""
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
const hasBody = bodyPlain.length > 0
const commentId = String(comment.id)
const storedBody = localBodies[commentId] ?? comment.body ?? ""
const bodyPlain = storedBody.replace(/<[^>]*>/g, "").trim()
const isEditing = editingComment?.id === commentId
const isPending = commentId.startsWith("temp-")
const canEdit = Boolean(userId && String(comment.author.id) === userId && !isPending)
const hasBody = bodyPlain.length > 0 || isEditing
return (
<div key={comment.id} className="flex gap-3">
<div key={comment.id} className="group/comment flex gap-3">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
@ -172,65 +224,74 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
{hasBody ? (
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<RichTextContent html={bodyHtml} />
{isEditing ? (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<RichTextEditor
value={editingComment?.value ?? ""}
onChange={(next) =>
setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev))
}
disabled={savingCommentId === commentId}
placeholder="Edite o comentário..."
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={cancelEditingComment}
disabled={savingCommentId === commentId}
>
Cancelar
</Button>
<Button
type="button"
className={submitButtonClass}
onClick={() => saveEditedComment(commentId, storedBody)}
disabled={savingCommentId === commentId}
>
{savingCommentId === commentId ? <Spinner className="size-4 text-white" /> : "Salvar"}
</Button>
</div>
</div>
) : hasBody ? (
<div className="relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
{canEdit ? (
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
aria-label="Editar comentário"
>
<PencilLine className="size-3.5" />
</button>
) : null}
<RichTextContent html={storedBody} />
</div>
) : canEdit ? (
<div className="rounded-xl border border-dashed border-slate-300 bg-white/60 px-3 py-2 text-sm text-neutral-500">
<button
type="button"
onClick={() => startEditingComment(commentId, storedBody)}
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"
>
<PencilLine className="size-4" />
Adicionar conteúdo ao comentário
</button>
</div>
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => {
const name = attachment?.name ?? ""
const url = attachment?.url
const type = attachment?.type ?? ""
const isImage =
(!!type && type.startsWith("image/")) ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
const openRemovalModal = (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name })
}
return (
<div
{comment.attachments.map((attachment) => (
<CommentAttachmentCard
key={attachment.id}
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"
>
{isImage && url ? (
<button
type="button"
onClick={() => setPreview(url || null)}
className="block w-full overflow-hidden rounded-md"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
</button>
) : (
<a
href={url ?? undefined}
download={name || undefined}
target="_blank"
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
>
<FileIcon className="size-5 text-neutral-600" />
{url ? <span className="font-medium">Baixar</span> : <span>Pendente</span>}
</a>
)}
<button
type="button"
onClick={openRemovalModal}
aria-label={`Remover ${name}`}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
{name}
</div>
</div>
)
})}
attachment={attachment}
onOpenPreview={(url) => setPreview(url)}
onRequestRemoval={() =>
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.name })
}
/>
))}
</div>
) : null}
</div>
@ -341,6 +402,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
<DialogHeader className="sr-only">
<DialogTitle>Visualização de anexo</DialogTitle>
</DialogHeader>
{preview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
@ -353,3 +417,141 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Card>
)
}
type CommentAttachment = TicketWithDetails["comments"][number]["attachments"][number]
function CommentAttachmentCard({
attachment,
onOpenPreview,
onRequestRemoval,
}: {
attachment: CommentAttachment
onOpenPreview: (url: string) => void
onRequestRemoval: () => void
}) {
const getFileUrl = useAction(api.files.getUrl)
const [url, setUrl] = useState<string | null>(attachment.url ?? null)
const [refreshing, setRefreshing] = useState(false)
const [errored, setErrored] = useState(false)
const hasRefreshedRef = useRef(false)
const isImageType = useMemo(() => {
const name = attachment.name ?? ""
const type = attachment.type ?? ""
return (!!type && type.startsWith("image/")) || /\.(png|jpe?g|gif|webp|svg)$/i.test(name)
}, [attachment.name, attachment.type])
useEffect(() => {
setUrl(attachment.url ?? null)
setErrored(false)
hasRefreshedRef.current = false
}, [attachment.id, attachment.url])
const ensureUrl = useCallback(async () => {
try {
setRefreshing(true)
const fresh = await getFileUrl({ storageId: attachment.id as Id<"_storage"> })
if (fresh) {
setUrl(fresh)
setErrored(false)
return fresh
}
} catch (error) {
console.error("Failed to refresh attachment URL", error)
} finally {
setRefreshing(false)
}
return null
}, [attachment.id, getFileUrl])
useEffect(() => {
let cancelled = false
;(async () => {
const fresh = await ensureUrl()
if (!cancelled && fresh) {
setUrl(fresh)
}
})()
return () => {
cancelled = true
}
}, [ensureUrl])
const handleImageError = useCallback(async () => {
if (hasRefreshedRef.current) {
setErrored(true)
return
}
hasRefreshedRef.current = true
const fresh = await ensureUrl()
if (!fresh) {
setErrored(true)
}
}, [ensureUrl])
const handlePreview = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
onOpenPreview(target)
}
}, [ensureUrl, onOpenPreview, url])
const handleDownload = useCallback(async () => {
const target = url ?? (await ensureUrl())
if (target) {
window.open(target, "_blank", "noopener,noreferrer")
}
}, [ensureUrl, url])
const name = attachment.name ?? ""
const urlLooksImage = url ? /\.(png|jpe?g|gif|webp|svg)$/i.test(url) : false
const showImage = isImageType && url && !errored
return (
<div className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm">
{showImage ? (
<button type="button" onClick={handlePreview} className="relative block w-full overflow-hidden rounded-md">
{refreshing ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/70">
<Spinner className="size-5 text-neutral-600" />
</div>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={url ?? "no-url"}
src={url ?? undefined}
alt={name}
className="h-24 w-full rounded-md object-cover"
onError={handleImageError}
/>
</button>
) : (
<button
type="button"
onClick={isImageType || urlLooksImage ? handlePreview : handleDownload}
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
disabled={refreshing}
>
{refreshing ? (
<Spinner className="size-5 text-neutral-600" />
) : isImageType || urlLooksImage ? (
<ImageIcon className="size-5 text-neutral-600" />
) : (
<FileIcon className="size-5 text-neutral-600" />
)}
<span className="font-medium">
{errored ? "Não foi possível carregar" : refreshing ? "Gerando link..." : url ? "Abrir" : "Gerar link"}
</span>
</button>
)}
<button
type="button"
onClick={onRequestRemoval}
aria-label={`Remover ${name}`}
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" />
</button>
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">{name}</div>
</div>
)
}

View file

@ -20,6 +20,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote,
COMMENT_EDITED: IconNote,
WORK_STARTED: IconClockHour4,
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
@ -35,6 +36,7 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
COMMENT_EDITED: "Comentário editado",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
SUBJECT_CHANGED: "Assunto atualizado",
@ -110,6 +112,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
requesterName?: string
authorName?: string
authorId?: string
actorName?: string
actorId?: string
from?: string
attachmentName?: string
sessionDurationMs?: number
@ -136,6 +140,10 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
}
if (entry.type === "COMMENT_EDITED" && (payload.actorName || payload.actorId || payload.authorName)) {
const name = payload.actorName ?? payload.authorName
message = "Comentário editado" + (name ? " por " + name : "")
}
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
}

View file

@ -1,6 +1,7 @@
"use client"
import { useEffect } from "react"
import { forwardRef, useCallback, useEffect, 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"
@ -9,6 +10,8 @@ import { cn } from "@/lib/utils"
import sanitize from "sanitize-html"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
Bold,
Italic,
@ -19,6 +22,8 @@ import {
Undo,
Redo,
Link as LinkIcon,
Check,
Link2Off,
} from "lucide-react"
type RichTextEditorProps = {
@ -67,6 +72,62 @@ export function RichTextEditor({
immediatelyRender: false,
})
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState("")
const linkInputRef = useRef<HTMLInputElement>(null)
const closeLinkPopover = useCallback(() => {
setLinkPopoverOpen(false)
requestAnimationFrame(() => {
editor?.commands.focus()
})
}, [editor])
const openLinkPopover = useCallback(() => {
if (!editor) return
editor.chain().focus()
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
setLinkUrl(prev)
setLinkPopoverOpen(true)
requestAnimationFrame(() => {
linkInputRef.current?.focus()
if (prev) {
linkInputRef.current?.select()
}
})
}, [editor])
const applyLink = useCallback(() => {
if (!editor) return
const trimmed = linkUrl.trim()
if (!trimmed) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
closeLinkPopover()
return
}
const normalized = /^(https?:\/\/|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`
editor.chain().focus().extendMarkRange("link").setLink({ href: normalized }).run()
closeLinkPopover()
}, [closeLinkPopover, editor, linkUrl])
const removeLink = useCallback(() => {
if (!editor) return
editor.chain().focus().extendMarkRange("link").unsetLink().run()
closeLinkPopover()
}, [closeLinkPopover, editor])
useEffect(() => {
if (!editor || !linkPopoverOpen) return
const handler = () => {
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
setLinkUrl(prev)
}
editor.on("selectionUpdate", handler)
return () => {
editor.off("selectionUpdate", handler)
}
}, [editor, linkPopoverOpen])
// Keep external value in sync when it changes
useEffect(() => {
if (!editor) return
@ -124,22 +185,62 @@ export function RichTextEditor({
>
<Quote className="size-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => {
const prev = editor.getAttributes("link").href as string | undefined
const url = window.prompt("URL do link:", prev || "https://")
if (url === null) return
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
<Popover
open={linkPopoverOpen}
onOpenChange={(open) => {
if (open) {
openLinkPopover()
} else {
closeLinkPopover()
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
}}
active={editor.isActive("link")}
ariaLabel="Inserir link"
>
<PopoverTrigger asChild>
<ToolbarButton active={editor.isActive("link")} ariaLabel="Inserir link">
<LinkIcon className="size-4" />
</ToolbarButton>
</PopoverTrigger>
<PopoverContent className="w-72 space-y-3" align="start">
<div className="space-y-1">
<label htmlFor="rich-text-editor-link" className="text-xs font-semibold text-neutral-600">
URL do link
</label>
<Input
id="rich-text-editor-link"
ref={linkInputRef}
placeholder="https://"
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
applyLink()
} else if (event.key === "Escape") {
event.preventDefault()
closeLinkPopover()
}
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2"
onClick={removeLink}
disabled={!editor.isActive("link")}
>
<Link2Off className="size-4" />
Remover
</Button>
<Button type="button" size="sm" className="inline-flex items-center gap-2" onClick={applyLink}>
<Check className="size-4" />
Aplicar
</Button>
</div>
</PopoverContent>
</Popover>
<div className="ms-auto flex items-center gap-1">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
<Undo className="size-4" />
@ -156,30 +257,33 @@ export function RichTextEditor({
)
}
function ToolbarButton({
onClick,
active,
ariaLabel,
children,
}: {
onClick: () => void
type ToolbarButtonProps = {
onClick?: () => void
active?: boolean
ariaLabel?: string
children: React.ReactNode
}) {
children: ReactNode
}
const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ onClick, active, ariaLabel, children }, ref) => {
return (
<Button
type="button"
variant={active ? "default" : "ghost"}
size="icon"
className="h-7 w-7"
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
aria-label={ariaLabel}
ref={ref}
>
{children}
</Button>
)
}
}
)
ToolbarButton.displayName = "ToolbarButton"
// Utilitário simples para renderização segura do HTML do editor.
// Remove tags <script>/<style> e atributos on*.