diff --git a/web/convex/files.ts b/web/convex/files.ts index 55dd9ce..9e7ee74 100644 --- a/web/convex/files.ts +++ b/web/convex/files.ts @@ -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); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index f3c18bb..96cedeb 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -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"), diff --git a/web/src/app/globals.css b/web/src/app/globals.css index fb17b73..2f2ba1d 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -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; } diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index af3029a..c75f7e0 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -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>([]) const [preview, setPreview] = useState(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(null) + const [localBodies, setLocalBodies] = useState>({}) + + 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 ( -
+
{initials} @@ -172,65 +224,74 @@ export function TicketComments({ ticket }: TicketCommentsProps) { {formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
- {hasBody ? ( -
- + {isEditing ? ( +
+ + setEditingComment((prev) => (prev && prev.id === commentId ? { ...prev, value: next } : prev)) + } + disabled={savingCommentId === commentId} + placeholder="Edite o comentário..." + /> +
+ + +
+
+ ) : hasBody ? ( +
+ {canEdit ? ( + + ) : null} + +
+ ) : canEdit ? ( +
+
) : null} {comment.attachments?.length ? (
- {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 ( -
- {isImage && url ? ( - - ) : ( - - - {url ? Baixar : Pendente} - - )} - -
- {name} -
-
- ) - })} + {comment.attachments.map((attachment) => ( + setPreview(url)} + onRequestRemoval={() => + setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name: attachment.name }) + } + /> + ))}
) : null}
@@ -341,6 +402,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) { !open && setPreview(null)}> + + Visualização de anexo + {preview ? ( <> {/* eslint-disable-next-line @next/next/no-img-element */} @@ -353,3 +417,141 @@ export function TicketComments({ ticket }: TicketCommentsProps) { ) } + +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(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 ( +
+ {showImage ? ( + + ) : ( + + )} + +
{name}
+
+ ) +} diff --git a/web/src/components/tickets/ticket-timeline.tsx b/web/src/components/tickets/ticket-timeline.tsx index c17e6a5..f59af8a 100644 --- a/web/src/components/tickets/ticket-timeline.tsx +++ b/web/src/components/tickets/ticket-timeline.tsx @@ -20,6 +20,7 @@ const timelineIcons: Record> = { 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 = { 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 + "\"" : "") } diff --git a/web/src/components/ui/rich-text-editor.tsx b/web/src/components/ui/rich-text-editor.tsx index a76ac22..6252e47 100644 --- a/web/src/components/ui/rich-text-editor.tsx +++ b/web/src/components/ui/rich-text-editor.tsx @@ -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(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({ > - { - 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 + { + if (open) { + openLinkPopover() + } else { + closeLinkPopover() } - editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run() }} - active={editor.isActive("link")} - ariaLabel="Inserir link" > - - + + + + + + +
+ + setLinkUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + applyLink() + } else if (event.key === "Escape") { + event.preventDefault() + closeLinkPopover() + } + }} + /> +
+
+ + +
+
+
editor.chain().focus().undo().run()} ariaLabel="Desfazer"> @@ -156,31 +257,34 @@ export function RichTextEditor({ ) } -function ToolbarButton({ - onClick, - active, - ariaLabel, - children, -}: { - onClick: () => void +type ToolbarButtonProps = { + onClick?: () => void active?: boolean ariaLabel?: string - children: React.ReactNode -}) { - return ( - - ) + children: ReactNode } +const ToolbarButton = forwardRef( + ({ onClick, active, ariaLabel, children }, ref) => { + return ( + + ) + } +) + +ToolbarButton.displayName = "ToolbarButton" + // Utilitário simples para renderização segura do HTML do editor. // Remove tags