feat(rich-text, types): Tiptap editor, SSR-safe, comments + description; stricter typing (no any) across app
- Add Tiptap editor + toolbar and rich content rendering with sanitize-html - Fix SSR hydration (immediatelyRender: false) and setContent options - Comments: rich text + visibility selector, typed attachments (Id<_storage>) - New Ticket: description rich text; attachments typed; queues typed - Convex: server-side filters using indexes; priority order rename; stronger Doc/Id typing; remove helper with any - Schemas/Mappers: zod v4 record typing; event payload record typing; customFields typed - UI: replace any in header/play/list/timeline/fields; improve select typings - Build passes; only non-blocking lint warnings remain
This commit is contained in:
parent
9b0c0bd80a
commit
ea60c3b841
26 changed files with 1390 additions and 245 deletions
|
|
@ -81,11 +81,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader className="gap-3">
|
||||
<VersionSwitcher
|
||||
label="Release"
|
||||
versions={navigation.versions}
|
||||
defaultVersion={navigation.versions[0]}
|
||||
/>
|
||||
<VersionSwitcher
|
||||
label="Release"
|
||||
versions={[...navigation.versions]}
|
||||
defaultVersion={navigation.versions[0]}
|
||||
/>
|
||||
<SearchForm placeholder="Buscar tickets, macros ou artigos" />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { z } from "zod"
|
||||
import { useState } from "react"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -17,10 +19,12 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||
import { toast } from "sonner"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||
|
||||
const schema = z.object({
|
||||
subject: z.string().min(3, "Informe um assunto"),
|
||||
summary: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
|
||||
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
|
||||
queueName: z.string().nullable().optional(),
|
||||
|
|
@ -31,11 +35,11 @@ export function NewTicketDialog() {
|
|||
const [loading, setLoading] = useState(false)
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
|
||||
defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
|
||||
mode: "onTouched",
|
||||
})
|
||||
const { userId } = useAuth()
|
||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
|
||||
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const create = useMutation(api.tickets.create)
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||
|
|
@ -45,18 +49,26 @@ export function NewTicketDialog() {
|
|||
setLoading(true)
|
||||
toast.loading("Criando ticket…", { id: "new-ticket" })
|
||||
try {
|
||||
const sel = queues.find((q: any) => q.name === values.queueName)
|
||||
const sel = queues.find((q) => q.name === values.queueName)
|
||||
const id = await create({
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
subject: values.subject,
|
||||
summary: values.summary,
|
||||
priority: values.priority,
|
||||
channel: values.channel,
|
||||
queueId: sel?.id,
|
||||
requesterId: userId as any,
|
||||
queueId: sel?.id as Id<"queues"> | undefined,
|
||||
requesterId: userId as Id<"users">,
|
||||
})
|
||||
if (attachments.length > 0 || (values.summary && values.summary.trim().length > 0)) {
|
||||
await addComment({ ticketId: id as any, authorId: userId as any, visibility: "PUBLIC", body: values.summary || "", attachments })
|
||||
const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
|
||||
const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
|
||||
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
|
||||
const typedAttachments = attachments.map((a) => ({
|
||||
storageId: a.storageId as unknown as Id<"_storage">,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
type: a.type,
|
||||
}))
|
||||
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: bodyHtml, attachments: typedAttachments })
|
||||
}
|
||||
toast.success("Ticket criado!", { id: "new-ticket" })
|
||||
setOpen(false)
|
||||
|
|
@ -93,6 +105,14 @@ export function NewTicketDialog() {
|
|||
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
||||
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} {...form.register("summary")} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Descrição</FieldLabel>
|
||||
<RichTextEditor
|
||||
value={form.watch("description") || ""}
|
||||
onChange={(html) => form.setValue("description", html)}
|
||||
placeholder="Detalhe o problema, passos para reproduzir, links, etc."
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Anexos</FieldLabel>
|
||||
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
|
||||
|
|
@ -101,7 +121,7 @@ export function NewTicketDialog() {
|
|||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel>Prioridade</FieldLabel>
|
||||
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as any)}>
|
||||
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
|
||||
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Baixa</SelectItem>
|
||||
|
|
@ -113,7 +133,7 @@ export function NewTicketDialog() {
|
|||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Canal</FieldLabel>
|
||||
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
|
||||
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
||||
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EMAIL">E-mail</SelectItem>
|
||||
|
|
@ -135,7 +155,7 @@ export function NewTicketDialog() {
|
|||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Sem fila</SelectItem>
|
||||
{queues.map((q: any) => (
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
|
|
@ -9,7 +10,9 @@ import { useMutation, useQuery } from "convex/react"
|
|||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketPlayContext } from "@/lib/schemas/ticket"
|
||||
import type { TicketPlayContext, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { mapTicketFromServer } from "@/lib/mappers/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -17,6 +20,7 @@ import { Separator } from "@/components/ui/separator"
|
|||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
|
||||
interface PlayNextTicketCardProps {
|
||||
context?: TicketPlayContext
|
||||
|
|
@ -25,19 +29,21 @@ interface PlayNextTicketCardProps {
|
|||
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||
const router = useRouter()
|
||||
const { userId } = useAuth()
|
||||
const queueSummary = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
|
||||
const queueSummary = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const playNext = useMutation(api.tickets.playNext)
|
||||
const [selectedQueueId, setSelectedQueueId] = useState<string | undefined>(undefined)
|
||||
|
||||
const nextTicketFromServer = useQuery(api.tickets.list, {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
status: undefined,
|
||||
priority: undefined,
|
||||
channel: undefined,
|
||||
queueId: undefined,
|
||||
queueId: (selectedQueueId as Id<"queues">) || undefined,
|
||||
limit: 1,
|
||||
})?.[0]
|
||||
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
|
||||
|
||||
const cardContext: TicketPlayContext | null = context ?? (nextTicketFromServer ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a: number, b: any) => a + b.pending, 0), waiting: queueSummary.reduce((a: number, b: any) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketFromServer } : null)
|
||||
const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null)
|
||||
|
||||
if (!cardContext || !cardContext.nextTicket) {
|
||||
return (
|
||||
|
|
@ -62,11 +68,23 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
</CardTitle>
|
||||
<TicketPriorityPill priority={ticket.priority} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
|
||||
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
|
||||
</div>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm text-muted-foreground">Fila:</span>
|
||||
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
|
||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">Todas</SelectItem>
|
||||
{queueSummary.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
|
||||
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
|
|
@ -91,7 +109,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
className="gap-2"
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: undefined, agentId: userId as any })
|
||||
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
|
||||
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
|||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
||||
import { TicketsTable } from "@/components/tickets/tickets-table";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Ticket } from "@/lib/schemas/ticket";
|
||||
|
||||
export function RecentTicketsPanel() {
|
||||
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
|
||||
|
|
@ -24,10 +25,10 @@ export function RecentTicketsPanel() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
const tickets = mapTicketsFromServerList(ticketsRaw as any[]);
|
||||
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
|
||||
return (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<TicketsTable tickets={tickets as any} />
|
||||
<TicketsTable tickets={tickets} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { ticketStatusSchema } from "@/lib/schemas/ticket"
|
||||
"use client"
|
||||
|
||||
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const statusConfig = {
|
||||
|
|
@ -10,11 +10,9 @@ const statusConfig = {
|
|||
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
|
||||
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
|
||||
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
|
||||
} satisfies Record<(typeof ticketStatusSchema)["_type"], { label: string; className: string }>
|
||||
} satisfies Record<TicketStatus, { label: string; className: string }>
|
||||
|
||||
type TicketStatusBadgeProps = {
|
||||
status: (typeof ticketStatusSchema)["_type"]
|
||||
}
|
||||
type TicketStatusBadgeProps = { status: TicketStatus }
|
||||
|
||||
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { useMemo, useState } from "react"
|
|||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||
import { Download, ImageIcon, FileIcon } from "lucide-react"
|
||||
import { Download, FileIcon } from "lucide-react"
|
||||
import { useAction, useMutation } from "convex/react"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
|
@ -17,12 +18,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { Dropzone } from "@/components/ui/dropzone"
|
||||
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||
const { userId } = useAuth()
|
||||
const addComment = useMutation(api.tickets.addComment)
|
||||
|
|
@ -31,6 +34,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
|
||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||
|
||||
const commentsAll = useMemo(() => {
|
||||
return [...pending, ...ticket.comments]
|
||||
|
|
@ -43,19 +47,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
const now = new Date()
|
||||
const optimistic = {
|
||||
id: `temp-${now.getTime()}`,
|
||||
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
|
||||
visibility: "PUBLIC" as const,
|
||||
body,
|
||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
|
||||
author: ticket.requester,
|
||||
visibility,
|
||||
body: sanitizeEditorHtml(body),
|
||||
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
setPending((p) => [optimistic, ...p])
|
||||
setBody("")
|
||||
setAttachmentsToSend([])
|
||||
toast.loading("Enviando comentário…", { id: "comment" })
|
||||
toast.loading("Enviando comentário.", { id: "comment" })
|
||||
try {
|
||||
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments })
|
||||
const typedAttachments = attachments.map((a) => ({
|
||||
storageId: a.storageId as unknown as Id<"_storage">,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
type: a.type,
|
||||
}))
|
||||
await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
|
||||
setPending([])
|
||||
toast.success("Comentário enviado!", { id: "comment" })
|
||||
} catch (err) {
|
||||
|
|
@ -74,7 +84,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
{commentsAll.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda sem comentarios. Que tal registrar o proximo passo?
|
||||
Ainda sem comentários. Que tal registrar o próximo passo?
|
||||
</p>
|
||||
) : (
|
||||
commentsAll.map((comment) => {
|
||||
|
|
@ -101,20 +111,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words whitespace-pre-wrap">
|
||||
{comment.body}
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words">
|
||||
<RichTextContent html={comment.body} />
|
||||
</div>
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((a) => {
|
||||
const att = a as any
|
||||
{comment.attachments.map((att) => {
|
||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImg && att.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url)}
|
||||
onClick={() => setPreview(att.url || null)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
|
|
@ -140,15 +149,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
})
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-background p-3 text-sm"
|
||||
placeholder="Escreva um comentario..."
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Visibilidade:
|
||||
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
|
||||
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PUBLIC">Pública</SelectItem>
|
||||
<SelectItem value="INTERNAL">Interna</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Enviar</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -6,21 +6,23 @@ import { useQuery } from "convex/react";
|
|||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||
import { getTicketById } from "@/lib/mocks/tickets";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TicketComments } from "@/components/tickets/ticket-comments";
|
||||
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
|
||||
|
||||
export function TicketDetailView({ id }: { id: string }) {
|
||||
const isMockId = id.startsWith("ticket-");
|
||||
const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any }));
|
||||
let ticket: any | null = null;
|
||||
const t = useQuery(api.tickets.getById, isMockId ? "skip" : ({ tenantId: DEFAULT_TENANT_ID, id: id as Id<"tickets"> }));
|
||||
let ticket: TicketWithDetails | null = null;
|
||||
if (t) {
|
||||
ticket = mapTicketWithDetailsFromServer(t as any);
|
||||
ticket = mapTicketWithDetailsFromServer(t as unknown);
|
||||
} else if (isMockId) {
|
||||
ticket = getTicketById(id) ?? null;
|
||||
}
|
||||
|
|
@ -54,13 +56,13 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSummaryHeader ticket={ticket as any} />
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<TicketComments ticket={ticket as any} />
|
||||
<TicketTimeline ticket={ticket as any} />
|
||||
<TicketComments ticket={ticket} />
|
||||
<TicketTimeline ticket={ticket} />
|
||||
</div>
|
||||
<TicketDetailsPanel ticket={ticket as any} />
|
||||
<TicketDetailsPanel ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ interface TicketQueueSummaryProps {
|
|||
|
||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
|
||||
const data: TicketQueueSummary[] = (queues ?? fromServer ?? []) as any
|
||||
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
|
||||
if (!queues && fromServer === undefined) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import { toast } from "sonner"
|
|||
import { api } from "@/convex/_generated/api"
|
||||
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||
import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
|
|
@ -27,9 +28,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const agents = useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) ?? []
|
||||
const queues = useQuery(api.queues.summary, { tenantId: ticket.tenantId }) ?? []
|
||||
const [status, setStatus] = useState(ticket.status)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const statusPt: Record<string, string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
|
|
@ -47,16 +48,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
#{ticket.reference}
|
||||
</Badge>
|
||||
<TicketPriorityPill priority={ticket.priority} />
|
||||
<TicketStatusBadge status={status as any} />
|
||||
<TicketStatusBadge status={status} />
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={async (value) => {
|
||||
const prev = status
|
||||
setStatus(value) // otimista
|
||||
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
|
||||
if (!userId) return
|
||||
toast.loading("Atualizando status…", { id: "status" })
|
||||
try {
|
||||
await updateStatus({ ticketId: ticket.id as any, status: value as any, actorId: userId as any })
|
||||
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
|
||||
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
|
||||
} catch (e) {
|
||||
setStatus(prev)
|
||||
|
|
@ -95,7 +96,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
if (!userId) return
|
||||
toast.loading("Atribuindo responsável…", { id: "assignee" })
|
||||
try {
|
||||
await changeAssignee({ ticketId: ticket.id as any, assigneeId: value as any, actorId: userId as any })
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atribuir.", { id: "assignee" })
|
||||
|
|
@ -104,7 +105,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
>
|
||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((a: any) => (
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -118,11 +119,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
value={ticket.queue ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
const q = queues.find((qq: any) => qq.name === value)
|
||||
const q = queues.find((qq) => qq.name === value)
|
||||
if (!q) return
|
||||
toast.loading("Atualizando fila…", { id: "queue" })
|
||||
try {
|
||||
await changeQueue({ ticketId: ticket.id as any, queueId: q.id as any, actorId: userId as any })
|
||||
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
|
||||
toast.success("Fila atualizada!", { id: "queue" })
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
|
||||
|
|
@ -131,7 +132,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
>
|
||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{queues.map((q: any) => (
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -56,12 +56,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
{entry.payload?.actorName ? (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={entry.payload.actorAvatar} alt={entry.payload.actorName} />
|
||||
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
|
||||
<AvatarFallback>
|
||||
{entry.payload.actorName.split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
|
||||
{String(entry.payload?.actorName ?? '').split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
por {entry.payload.actorName}
|
||||
por {String(entry.payload?.actorName ?? '')}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
@ -69,7 +69,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const p: any = entry.payload || {}
|
||||
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string }
|
||||
let message: string | null = null
|
||||
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
|
||||
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useQuery } from "convex/react"
|
|||
import { api } from "@/convex/_generated/api"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||
import { TicketsTable } from "@/components/tickets/tickets-table"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
|
@ -14,7 +15,7 @@ import { Spinner } from "@/components/ui/spinner"
|
|||
export function TicketsView() {
|
||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
||||
|
||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
|
||||
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
|
||||
const ticketsRaw = useQuery(api.tickets.list, {
|
||||
tenantId: DEFAULT_TENANT_ID,
|
||||
status: filters.status ?? undefined,
|
||||
|
|
@ -24,16 +25,16 @@ export function TicketsView() {
|
|||
search: filters.search || undefined,
|
||||
})
|
||||
|
||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as any[]), [ticketsRaw])
|
||||
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
if (!filters.queue) return tickets
|
||||
return tickets.filter((t: any) => t.queue === filters.queue)
|
||||
return tickets.filter((t: Ticket) => t.queue === filters.queue)
|
||||
}, [tickets, filters.queue])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q: any) => q.name)} />
|
||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
|
||||
{ticketsRaw === undefined ? (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="grid gap-3">
|
||||
|
|
@ -46,7 +47,7 @@ export function TicketsView() {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TicketsTable tickets={filteredTickets as any} />
|
||||
<TicketsTable tickets={filteredTickets} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,39 +7,38 @@ const FieldSet = ({ className, ...props }: React.ComponentProps<"fieldset">) =>
|
|||
<fieldset role="group" className={cn("grid gap-3", className)} {...props} />
|
||||
)
|
||||
|
||||
const FieldLegend = ({ className, ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) => {
|
||||
const { variant = "legend", ...rest } = props as any
|
||||
return (
|
||||
<legend
|
||||
className={cn(
|
||||
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
|
||||
"text-foreground", className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const FieldLegend = (
|
||||
{ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }
|
||||
) => (
|
||||
<legend
|
||||
className={cn(
|
||||
variant === "label" ? "text-sm font-medium" : "text-sm font-semibold",
|
||||
"text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const FieldGroup = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||
<div className={cn("@container/field-group grid gap-4", className)} {...props} />
|
||||
)
|
||||
|
||||
const Field = ({ className, ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }) => {
|
||||
const { orientation = "vertical", ...rest } = props as any
|
||||
return (
|
||||
<div
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
orientation === "vertical" && "flex-col",
|
||||
orientation === "horizontal" && "items-center",
|
||||
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const Field = (
|
||||
{ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & { orientation?: "vertical" | "horizontal" | "responsive" }
|
||||
) => (
|
||||
<div
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
orientation === "vertical" && "flex-col",
|
||||
orientation === "horizontal" && "items-center",
|
||||
orientation === "responsive" && "@[480px]/field-group:flex-row @[480px]/field-group:items-center flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => (
|
||||
<div className={cn("flex flex-col", className)} {...props} />
|
||||
|
|
@ -78,4 +77,3 @@ const FieldSeparator = ({ className, ...props }: React.ComponentProps<"div">) =>
|
|||
)
|
||||
|
||||
export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator }
|
||||
|
||||
|
|
|
|||
218
web/src/components/ui/rich-text-editor.tsx
Normal file
218
web/src/components/ui/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } 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 { cn } from "@/lib/utils"
|
||||
import sanitize from "sanitize-html"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type RichTextEditorProps = {
|
||||
value?: string
|
||||
onChange?: (html: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "Escreva aqui...",
|
||||
disabled,
|
||||
minHeight = 120,
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
bulletList: { keepMarks: true },
|
||||
orderedList: { keepMarks: true },
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
protocols: ["http", "https", "mailto"],
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none text-foreground",
|
||||
},
|
||||
},
|
||||
content: value || "",
|
||||
onUpdate({ editor }) {
|
||||
onChange?.(editor.getHTML())
|
||||
},
|
||||
editable: !disabled,
|
||||
// Avoid SSR hydration mismatches per Tiptap recommendation
|
||||
immediatelyRender: false,
|
||||
})
|
||||
|
||||
// Keep external value in sync when it changes
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const current = editor.getHTML()
|
||||
if ((value ?? "") !== current) {
|
||||
editor.commands.setContent(value || "", { emitUpdate: false })
|
||||
}
|
||||
}, [value, editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-md border bg-background", className)}>
|
||||
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive("bold")}
|
||||
ariaLabel="Negrito"
|
||||
>
|
||||
<Bold className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive("italic")}
|
||||
ariaLabel="Itálico"
|
||||
>
|
||||
<Italic className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive("strike")}
|
||||
ariaLabel="Tachado"
|
||||
>
|
||||
<Strikethrough className="size-4" />
|
||||
</ToolbarButton>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive("bulletList")}
|
||||
ariaLabel="Lista"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive("orderedList")}
|
||||
ariaLabel="Lista ordenada"
|
||||
>
|
||||
<ListOrdered className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive("blockquote")}
|
||||
ariaLabel="Citação"
|
||||
>
|
||||
<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
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
|
||||
}}
|
||||
active={editor.isActive("link")}
|
||||
ariaLabel="Inserir link"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
|
||||
<Undo className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} ariaLabel="Refazer">
|
||||
<Redo className="size-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minHeight }} className="rich-text p-3">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
ariaLabel?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Utilitário simples para renderização segura do HTML do editor.
|
||||
// Remove tags <script>/<style> e atributos on*.
|
||||
export function sanitizeEditorHtml(html: string): string {
|
||||
try {
|
||||
return sanitize(html || "", {
|
||||
allowedTags: [
|
||||
"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"],
|
||||
code: ["class"],
|
||||
pre: ["class"],
|
||||
},
|
||||
allowedSchemes: ["http","https","mailto"],
|
||||
// prevent target=_self phishing
|
||||
transformTags: {
|
||||
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
||||
},
|
||||
// disallow inline event handlers
|
||||
allowVulnerableTags: false,
|
||||
})
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function RichTextContent({ html, className }: { html: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rich-text text-sm leading-relaxed", className)}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue