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

Feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-04 01:40:57 -03:00 committed by GitHub
commit 5b19a0ff4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1803 additions and 294 deletions

View file

@ -217,10 +217,11 @@ export const create = mutation({
slaPolicyId: undefined,
dueAt: undefined,
});
const requester = await ctx.db.get(args.requesterId);
await ctx.db.insert("ticketEvents", {
ticketId: id,
type: "CREATED",
payload: { requesterId: args.requesterId },
payload: { requesterId: args.requesterId, requesterName: requester?.name, requesterAvatar: requester?.avatarUrl },
createdAt: now,
});
return id;
@ -272,10 +273,48 @@ export const updateStatus = mutation({
handler: async (ctx, { ticketId, status, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: now });
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
} as const;
await ctx.db.insert("ticketEvents", {
ticketId,
type: "STATUS_CHANGED",
payload: { to: status, actorId },
payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
createdAt: now,
});
},
});
export const changeAssignee = mutation({
args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") },
handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = await ctx.db.get(assigneeId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId, assigneeName: user?.name, actorId },
createdAt: now,
});
},
});
export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => {
const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = await ctx.db.get(queueId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "QUEUE_CHANGED",
payload: { queueId, queueName: queue?.name, actorId },
createdAt: now,
});
},
@ -311,10 +350,11 @@ export const playNext = mutation({
const chosen = candidates[0];
const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now });
const agent = await ctx.db.get(agentId);
await ctx.db.insert("ticketEvents", {
ticketId: chosen._id,
type: "ASSIGNEE_CHANGED",
payload: { assigneeId: agentId },
payload: { assigneeId: agentId, assigneeName: agent?.name },
createdAt: now,
});

View file

@ -8,20 +8,22 @@
"start": "next start",
"lint": "eslint",
"prisma:generate": "prisma generate",
"convex:dev": "convex dev"
"convex:dev": "convex dev",
"test": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
@ -40,6 +42,7 @@
"next-themes": "^0.4.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.64.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
@ -57,6 +60,7 @@
"prisma": "^6.16.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.8",
"typescript": "^5"
"typescript": "^5",
"vitest": "^2.1.4"
}
}

799
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,7 @@ import { AppShell } from "@/components/app-shell"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
import { SectionCards } from "@/components/section-cards"
import { SiteHeader } from "@/components/site-header"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { tickets } from "@/lib/mocks/tickets"
const recentTickets = tickets.slice(0, 10)
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
export default function Dashboard() {
return (
@ -22,9 +19,7 @@ export default function Dashboard() {
<SectionCards />
<div className="grid gap-6 px-4 lg:grid-cols-[1.1fr_0.9fr] lg:px-6">
<ChartAreaInteractive />
<div className="rounded-xl border bg-card">
<TicketsTable tickets={recentTickets} />
</div>
<RecentTicketsPanel />
</div>
</AppShell>
)

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useMutation } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../../convex/_generated/api";
import { api } from "@/convex/_generated/api";
export default function SeedPage() {
const seed = useMutation(api.seed.seedDemo);
@ -27,4 +27,3 @@ export default function SeedPage() {
</div>
);
}

View file

@ -1,6 +1,8 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketDetailView } from "@/components/tickets/ticket-detail-view"
import { TicketDetailStatic } from "@/components/tickets/ticket-detail-static"
import { getTicketById } from "@/lib/mocks/tickets"
type TicketDetailPageProps = {
params: Promise<{ id: string }>
@ -8,6 +10,8 @@ type TicketDetailPageProps = {
export default async function TicketDetailPage({ params }: TicketDetailPageProps) {
const { id } = await params
const isMock = id.startsWith("ticket-")
const mock = isMock ? getTicketById(id) : null
return (
<AppShell
@ -20,7 +24,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
/>
}
>
<TicketDetailView id={id} />
{isMock && mock ? <TicketDetailStatic ticket={mock as any} /> : <TicketDetailView id={id} />}
</AppShell>
)
}

View file

@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { useMutation, useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../../convex/_generated/api";
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { useAuth } from "@/lib/auth-client";

View file

@ -1,8 +1,8 @@
import Link from "next/link"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { TicketsView } from "@/components/tickets/tickets-view"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
export default function TicketsPage() {
return (
@ -12,7 +12,7 @@ export default function TicketsPage() {
title="Tickets"
lead="Visão consolidada de filas e SLAs"
secondaryAction={<SiteHeader.SecondaryButton>Exportar CSV</SiteHeader.SecondaryButton>}
primaryAction={<SiteHeader.PrimaryButton asChild><Link href="/tickets/new">Novo ticket</Link></SiteHeader.PrimaryButton>}
primaryAction={<NewTicketDialog />}
/>
}
>

View file

@ -0,0 +1,156 @@
"use client"
import { z } from "zod"
import { useState } from "react"
import { useMutation, useQuery } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { FieldSet, FieldGroup, Field, FieldLabel, FieldError } from "@/components/ui/field"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
const schema = z.object({
subject: z.string().min(3, "Informe um assunto"),
summary: 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(),
})
export function NewTicketDialog() {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
mode: "onTouched",
})
const { userId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
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 }>>([])
async function submit(values: z.infer<typeof schema>) {
if (!userId) return
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
try {
const sel = queues.find((q: any) => 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,
})
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 })
}
toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false)
form.reset()
setAttachments([])
// Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}`
} catch (err) {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">Novo ticket</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo ticket</DialogTitle>
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={form.handleSubmit(submit)}>
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="subject">Assunto</FieldLabel>
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
</Field>
<Field>
<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>Anexos</FieldLabel>
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</Field>
<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)}>
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Baixa</SelectItem>
<SelectItem value="MEDIUM">Média</SelectItem>
<SelectItem value="HIGH">Alta</SelectItem>
<SelectItem value="URGENT">Urgente</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Canal</FieldLabel>
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
<SelectContent>
<SelectItem value="EMAIL">E-mail</SelectItem>
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
<SelectItem value="CHAT">Chat</SelectItem>
<SelectItem value="PHONE">Telefone</SelectItem>
<SelectItem value="API">API</SelectItem>
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Fila</FieldLabel>
{(() => {
const NONE = "NONE";
const current = form.watch("queueName") ?? NONE;
return (
<Select value={current} onValueChange={(v) => form.setValue("queueName", v === NONE ? null : v)}>
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Sem fila</SelectItem>
{queues.map((q: any) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
)
})()}
</Field>
</div>
</FieldGroup>
</FieldSet>
<div className="flex justify-end">
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando</>) : "Criar"}</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -6,7 +6,7 @@ import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
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"
@ -16,6 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
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"
interface PlayNextTicketCardProps {
context?: TicketPlayContext
@ -94,8 +95,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
Iniciar atendimento
<IconPlayerPlayFilled className="size-4" />
{userId ? (<><IconPlayerPlayFilled className="size-4" /> Iniciar atendimento</>) : (<><Spinner className="me-2" /> Carregando</>)}
</Button>
<Button variant="ghost" asChild className="gap-2 text-sm">
<Link href="/tickets">

View file

@ -0,0 +1,33 @@
"use client";
import { useQuery } from "convex/react";
// @ts-ignore
import { api } from "@/convex/_generated/api";
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";
export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
if (ticketsRaw === undefined) {
return (
<div className="rounded-xl border bg-card p-4">
<div className="grid gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-56 animate-pulse rounded bg-muted" />
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
);
}
const tickets = mapTicketsFromServerList(ticketsRaw as any[]);
return (
<div className="rounded-xl border bg-card">
<TicketsTable tickets={tickets as any} />
</div>
);
}

View file

@ -4,10 +4,11 @@ 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 { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@ -15,6 +16,8 @@ import { Badge } from "@/components/ui/badge"
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 { Dialog, DialogContent } from "@/components/ui/dialog"
interface TicketCommentsProps {
ticket: TicketWithDetails
@ -25,7 +28,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("")
const [files, setFiles] = useState<File[]>([])
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 commentsAll = useMemo(() => {
@ -35,30 +39,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!userId) return
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = []
if (files.length) {
const url = await generateUploadUrl({})
for (const file of files) {
const form = new FormData()
form.append("file", file)
const res = await fetch(url, { method: "POST", body: form })
const { storageId } = await res.json()
attachments.push({ storageId, name: file.name, size: file.size, type: file.type })
}
}
const attachments = attachmentsToSend
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 } as any)),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl } as any)),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setBody("")
setFiles([])
setAttachmentsToSend([])
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 })
@ -71,13 +65,13 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
}
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-0">
<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?
@ -111,12 +105,33 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{comment.body}
</div>
{comment.attachments?.length ? (
<div className="flex flex-wrap gap-2">
{comment.attachments.map((a) => (
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline">
{(a as any).name}
<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
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)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
{att.name}
</div>
</button>
)
}
return (
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
<FileIcon className="size-3.5" /> {att.name}
{att.url ? <Download className="size-3.5" /> : null}
</a>
))}
)
})}
</div>
) : null}
</div>
@ -132,11 +147,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-center justify-between">
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-end">
<Button type="submit" size="sm">Enviar</Button>
</div>
</form>
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
) : null}
</DialogContent>
</Dialog>
</CardContent>
</Card>
)

View file

@ -0,0 +1,21 @@
"use client";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketTimeline } from "@/components/tickets/ticket-timeline";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
export function TicketDetailStatic({ ticket }: { ticket: TicketWithDetails }) {
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<TicketTimeline ticket={ticket} />
</div>
<TicketDetailsPanel ticket={ticket} />
</div>
</div>
);
}

View file

@ -3,18 +3,25 @@
import { useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api";
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { TicketComments } from "@/components/tickets/ticket-comments";
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 t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any });
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
const ticket = mapTicketWithDetailsFromServer(t as any)
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;
if (t) {
ticket = mapTicketWithDetailsFromServer(t as any);
} else if (isMockId) {
ticket = getTicketById(id) ?? null;
}
if (!ticket) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>;
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} />

View file

@ -13,11 +13,11 @@ interface TicketDetailsPanelProps {
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return (
<Card className="border-none shadow-none">
<CardHeader className="px-0">
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5 px-0 text-sm text-muted-foreground">
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-muted-foreground">
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>

View file

@ -3,7 +3,7 @@
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"

View file

@ -4,11 +4,11 @@ import { useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
import { useMutation } from "convex/react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
@ -25,6 +25,10 @@ interface TicketHeaderProps {
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
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 statusPt: Record<string, string> = {
NEW: "Novo",
@ -70,7 +74,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</SelectContent>
</Select>
</div>
<h1 className="text-2xl font-semibold text-foreground">{ticket.subject}</h1>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
</div>
</div>
@ -83,10 +87,55 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Responsavel:
<span className="font-medium text-foreground">
{ticket.assignee?.name ?? "Aguardando atribuicao"}
</span>
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
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 })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a: any) => (
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq: any) => 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 })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q: any) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />

View file

@ -11,6 +11,7 @@ import {
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
@ -25,6 +26,7 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
QUEUE_CHANGED: "Fila alterada",
}
interface TicketTimelineProps {
@ -33,8 +35,8 @@ interface TicketTimelineProps {
export function TicketTimeline({ ticket }: TicketTimelineProps) {
return (
<Card className="border-none shadow-none">
<CardContent className="space-y-6">
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-6 px-4 pb-6">
{ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1
@ -51,17 +53,35 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
<span className="text-sm font-medium text-foreground">
{timelineLabels[entry.type] ?? entry.type}
</span>
{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} />
<AvatarFallback>
{entry.payload.actorName.split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
</AvatarFallback>
</Avatar>
por {entry.payload.actorName}
</span>
) : null}
<span className="text-xs text-muted-foreground">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{entry.payload ? (
{(() => {
const p: any = entry.payload || {}
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}` : ""}`
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
if (!message) return null
return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<pre className="whitespace-pre-wrap leading-relaxed">
{JSON.stringify(entry.payload, null, 2)}
</pre>
{message}
</div>
) : null}
)
})()}
</div>
</div>
)

View file

@ -1,6 +1,6 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
@ -87,13 +87,14 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
function setPartial(partial: Partial<TicketFiltersState>) {
setFilters((prev) => {
const next = { ...prev, ...partial }
onChange?.(next)
return next
})
setFilters((prev) => ({ ...prev, ...partial }))
}
// Propaga as mudanças de filtros para o pai sem disparar durante render
useEffect(() => {
onChange?.(filters)
}, [filters, onChange])
const activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)

View file

@ -64,11 +64,11 @@ type TicketsTableProps = {
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="border-none shadow-none">
<Card className="border bg-card/90 shadow-sm">
<CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full">
<TableHeader>
<TableRow className="text-xs uppercase text-muted-foreground">
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground">
<TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead>
@ -81,7 +81,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} className="group">
<TableRow key={ticket.id} className="group hover:bg-muted/40">
<TableCell className={cellClass}>
<div className="flex flex-col gap-0.5">
<Link
@ -159,7 +159,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableBody>
</Table>
{tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
<p className="text-sm text-muted-foreground">
Ajuste os filtros ou selecione outra fila.

View file

@ -4,16 +4,17 @@ import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../../convex/_generated/api"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
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 })
const ticketsRaw = useQuery(api.tickets.list, {
tenantId: DEFAULT_TENANT_ID,
status: filters.status ?? undefined,
@ -21,9 +22,9 @@ export function TicketsView() {
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
search: filters.search || undefined,
}) ?? []
})
const tickets = useMemo(() => mapTicketsFromServerList(ticketsRaw as any[]), [ticketsRaw])
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as any[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
@ -32,8 +33,21 @@ export function TicketsView() {
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: any) => q.name)} />
{ticketsRaw === undefined ? (
<div className="rounded-xl border bg-card p-4">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
) : (
<TicketsTable tickets={filteredTickets as any} />
)}
</div>
)
}

View file

@ -0,0 +1,67 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[95vw] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border bg-popover p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col gap-1", className)} {...props} />
)
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }

View file

@ -0,0 +1,115 @@
"use client";
import { useAction } from "convex/react";
// @ts-ignore
import { api } from "@/convex/_generated/api";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "@/components/ui/spinner";
import { Upload } from "lucide-react";
import { Progress } from "@/components/ui/progress";
type Uploaded = { storageId: string; name: string; size?: number; type?: string; previewUrl?: string };
export function Dropzone({
onUploaded,
maxFiles = 5,
maxSize = 10 * 1024 * 1024,
multiple = true,
className,
}: {
onUploaded?: (files: Uploaded[]) => void;
maxFiles?: number;
maxSize?: number;
multiple?: boolean;
className?: string;
}) {
const generateUrl = useAction(api.files.generateUploadUrl);
const inputRef = useRef<HTMLInputElement>(null);
const [drag, setDrag] = useState(false);
const [items, setItems] = useState<Array<{ id: string; name: string; progress: number; status: "idle" | "uploading" | "done" | "error" }>>([]);
const startUpload = useCallback(async (files: FileList | File[]) => {
const list = Array.from(files).slice(0, maxFiles);
const url = await generateUrl({});
const uploaded: Uploaded[] = [];
for (const file of list) {
if (file.size > maxSize) continue;
const id = `${file.name}-${file.size}-${Date.now()}`;
const localPreview = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
setItems((prev) => [...prev, { id, name: file.name, progress: 0, status: "uploading" }]);
await new Promise<void>((resolve) => {
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const progress = Math.round((e.loaded / e.total) * 100);
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress } : it)));
};
xhr.onload = () => {
try {
const res = JSON.parse(xhr.responseText);
if (res?.storageId) {
uploaded.push({ storageId: res.storageId, name: file.name, size: file.size, type: file.type, previewUrl: localPreview });
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, progress: 100, status: "done" } : it)));
} else {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
}
} catch {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
}
resolve();
};
xhr.onerror = () => {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, status: "error" } : it)));
resolve();
};
xhr.send(form);
});
}
if (uploaded.length) onUploaded?.(uploaded);
}, [generateUrl, maxFiles, maxSize, onUploaded]);
return (
<div className={cn("space-y-3", className)}>
<div
className={cn(
"relative rounded-lg border border-dashed p-6 text-center transition-colors",
drag ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"
)}
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
>
<input ref={inputRef} type="file" className="sr-only" multiple={multiple} onChange={(e) => e.target.files && startUpload(e.target.files)} />
<div className="flex flex-col items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
{items.some((it) => it.status === "uploading") ? (
<Spinner />
) : (
<Upload className="size-5 text-muted-foreground" />
)}
</div>
<p className="text-sm">Arraste arquivos aqui ou <button type="button" className="text-primary underline" onClick={() => inputRef.current?.click()}>selecione</button></p>
<p className="text-xs text-muted-foreground">Máximo {Math.round(maxSize/1024/1024)}MB Até {maxFiles} arquivos</p>
</div>
</div>
{items.length > 0 && (
<div className="space-y-2">
{items.map((it) => (
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
<span className="truncate">{it.name}</span>
<div className="flex items-center gap-2 min-w-[140px]">
<Progress value={it.progress} className="h-1.5 w-24" />
<span className="text-xs text-muted-foreground w-10 text-right">{it.progress}%</span>
</div>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
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 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 FieldContent = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("flex flex-col", className)} {...props} />
)
const FieldLabel = ({ className, ...props }: React.ComponentProps<"label"> & { asChild?: boolean }) => (
<label className={cn("text-sm font-medium text-foreground", className)} {...props} />
)
const FieldTitle = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("text-sm font-medium", className)} {...props} />
)
const FieldDescription = ({ className, ...props }: React.ComponentProps<"p">) => (
<p className={cn("text-xs text-muted-foreground", className)} {...props} />
)
const FieldError = ({ className, children, errors, ...props }: React.ComponentProps<"div"> & { errors?: Array<{ message?: string }> | undefined }) => {
const items = (errors ?? []) as Array<{ message?: string }>
if (!children && (!items || items.length === 0)) return null
return (
<div className={cn("text-xs text-destructive", className)} {...props}>
{children ? children : (
<ul className="ml-4 list-disc">
{items.map((e, i) => (
<li key={i}>{e?.message}</li>
))}
</ul>
)}
</div>
)
}
const FieldSeparator = ({ className, ...props }: React.ComponentProps<"div">) => (
<div className={cn("my-2 h-px w-full bg-border", className)} {...props} />
)
export { FieldSet, FieldLegend, FieldGroup, Field, FieldContent, FieldLabel, FieldTitle, FieldDescription, FieldError, FieldSeparator }

View file

@ -0,0 +1,16 @@
import { LoaderIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<LoaderIcon
role="status"
aria-label="Carregando"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View file

@ -0,0 +1,2 @@
export * from "../../../convex/_generated/api";

View file

@ -6,7 +6,7 @@ import { useMutation } from "convex/react";
// Lazy import to avoid build errors before convex is generated
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "../../convex/_generated/api";
import { api } from "@/convex/_generated/api";
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;
@ -47,4 +47,3 @@ export function AuthProvider({ demoUser, tenantId, children }: { demoUser: DemoU
const value = useMemo(() => ({ demoUser: localDemoUser, setDemoUser: setLocalDemoUser, userId }), [localDemoUser, userId]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { mapTicketFromServer, mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
describe("ticket mappers", () => {
it("converte ticket básico (epoch -> Date)", () => {
const now = Date.now();
const ui = mapTicketFromServer({
id: "t1",
reference: 1,
tenantId: "tenant",
subject: "Teste",
status: "OPEN",
priority: "MEDIUM",
channel: "EMAIL",
queue: null,
requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] },
assignee: null,
updatedAt: now,
createdAt: now,
tags: [],
lastTimelineEntry: null,
});
expect(ui.createdAt).toBeInstanceOf(Date);
expect(ui.updatedAt).toBeInstanceOf(Date);
expect(ui.lastTimelineEntry).toBeUndefined();
});
it("converte ticket com detalhes", () => {
const now = Date.now();
const ui = mapTicketWithDetailsFromServer({
id: "t1",
reference: 1,
tenantId: "tenant",
subject: "Teste",
status: "OPEN",
priority: "MEDIUM",
channel: "EMAIL",
queue: "Suporte N1",
requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] },
assignee: { id: "u2", name: "Bruno", email: "b@b.com", teams: [] },
updatedAt: now,
createdAt: now,
tags: [],
lastTimelineEntry: null,
timeline: [{ id: "e1", type: "CREATED", createdAt: now }],
comments: [{ id: "c1", author: { id: "u1", name: "Ana", email: "a@a.com", teams: [] }, visibility: "PUBLIC", body: "Oi", createdAt: now, updatedAt: now }],
});
expect(ui.timeline[0]!.createdAt).toBeInstanceOf(Date);
expect(ui.comments[0]!.createdAt).toBeInstanceOf(Date);
});
});

View file

@ -1,14 +1,15 @@
import { z } from "zod";
import {
ticketSchema,
ticketWithDetailsSchema,
ticketEventSchema,
ticketCommentSchema,
userSummarySchema,
} from "@/lib/schemas/ticket";
import { ticketSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
// Server shapes: datas como number (epoch ms) e alguns nullables
const serverUserSchema = userSummarySchema;
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
const serverUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().optional(),
avatarUrl: z.string().optional(),
teams: z.array(z.string()).optional(),
});
const serverTicketSchema = z.object({
id: z.string(),
@ -75,7 +76,8 @@ export function mapTicketFromServer(input: unknown) {
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
};
return ticketSchema.parse(ui);
// Já validamos o formato recebido (serverTicketSchema). Retornamos no shape da UI.
return ui as unknown as z.infer<typeof ticketSchema>;
}
export function mapTicketsFromServerList(arr: unknown[]) {
@ -99,5 +101,5 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
updatedAt: new Date(c.updatedAt),
})),
};
return ticketWithDetailsSchema.parse(ui);
return ui as unknown as z.infer<typeof ticketWithDetailsSchema>;
}

10
web/vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
globals: true,
include: ["src/**/*.test.ts"],
},
});