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:
esdrasrenan 2025-10-04 14:25:10 -03:00
parent 9b0c0bd80a
commit ea60c3b841
26 changed files with 1390 additions and 245 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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}`)
}}
>

View file

@ -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>
);
}

View file

@ -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]

View file

@ -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>

View file

@ -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>
);

View file

@ -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">

View file

@ -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>

View file

@ -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}` : ""}`

View file

@ -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>
)

View file

@ -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 }

View 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) }}
/>
)
}