feat: preview de imagens com modal, download com nome correto; cartões (Conversa/Detalhes/Timeline) com sombra e padding; alias '@/convex/_generated/api'; payloads legíveis (nome de fila/responsável, label de status) e timeline amigável; Dropzone no 'Novo ticket' com comentário inicial; microtipografia refinada

This commit is contained in:
esdrasrenan 2025-10-04 01:23:34 -03:00
parent 90c3c8e4d6
commit 44c98fec4a
24 changed files with 1409 additions and 301 deletions

View file

@ -272,10 +272,18 @@ export const updateStatus = mutation({
handler: async (ctx, { ticketId, status, actorId }) => { handler: async (ctx, { ticketId, status, actorId }) => {
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { status, updatedAt: 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", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "STATUS_CHANGED", type: "STATUS_CHANGED",
payload: { to: status, actorId }, payload: { to: status, toLabel: statusPt[status] ?? status, actorId },
createdAt: now, createdAt: now,
}); });
}, },
@ -286,10 +294,11 @@ export const changeAssignee = mutation({
handler: async (ctx, { ticketId, assigneeId, actorId }) => { handler: async (ctx, { ticketId, assigneeId, actorId }) => {
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { assigneeId, updatedAt: now }); await ctx.db.patch(ticketId, { assigneeId, updatedAt: now });
const user = await ctx.db.get(assigneeId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
payload: { assigneeId, actorId }, payload: { assigneeId, assigneeName: user?.name, actorId },
createdAt: now, createdAt: now,
}); });
}, },
@ -300,10 +309,11 @@ export const changeQueue = mutation({
handler: async (ctx, { ticketId, queueId, actorId }) => { handler: async (ctx, { ticketId, queueId, actorId }) => {
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { queueId, updatedAt: now }); await ctx.db.patch(ticketId, { queueId, updatedAt: now });
const queue = await ctx.db.get(queueId);
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "QUEUE_CHANGED", type: "QUEUE_CHANGED",
payload: { queueId, actorId }, payload: { queueId, queueName: queue?.name, actorId },
createdAt: now, createdAt: now,
}); });
}, },
@ -339,10 +349,11 @@ export const playNext = mutation({
const chosen = candidates[0]; const chosen = candidates[0];
const now = Date.now(); const now = Date.now();
await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: 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", { await ctx.db.insert("ticketEvents", {
ticketId: chosen._id, ticketId: chosen._id,
type: "ASSIGNEE_CHANGED", type: "ASSIGNEE_CHANGED",
payload: { assigneeId: agentId }, payload: { assigneeId: agentId, assigneeName: agent?.name },
createdAt: now, createdAt: now,
}); });

View file

@ -16,13 +16,14 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@ -41,9 +42,8 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.64.0",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"react-hook-form": "^7.53.0",
"@hookform/resolvers": "^3.9.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"vaul": "^1.1.2", "vaul": "^1.1.2",

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

View file

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

View file

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

View file

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

View file

@ -4,15 +4,19 @@ import { z } from "zod"
import { useState } from "react" import { useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { Dropzone } from "@/components/ui/dropzone"
const schema = z.object({ const schema = z.object({
subject: z.string().min(3, "Informe um assunto"), subject: z.string().min(3, "Informe um assunto"),
@ -25,18 +29,18 @@ const schema = z.object({
export function NewTicketDialog() { export function NewTicketDialog() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [values, setValues] = useState<z.infer<typeof schema>>({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }) 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 { userId } = useAuth()
const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [] const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? []
const create = useMutation(api.tickets.create) 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(e: React.FormEvent) { async function submit(values: z.infer<typeof schema>) {
e.preventDefault()
const parsed = schema.safeParse(values)
if (!parsed.success) {
toast.error(parsed.error.issues[0]?.message ?? "Preencha o formulário")
return
}
if (!userId) return if (!userId) return
setLoading(true) setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" }) toast.loading("Criando ticket…", { id: "new-ticket" })
@ -51,9 +55,13 @@ export function NewTicketDialog() {
queueId: sel?.id, queueId: sel?.id,
requesterId: userId as any, 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" }) toast.success("Ticket criado!", { id: "new-ticket" })
setOpen(false) setOpen(false)
setValues({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }) form.reset()
setAttachments([])
// Navegar para o ticket recém-criado // Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}` window.location.href = `/tickets/${id}`
} catch (err) { } catch (err) {
@ -73,19 +81,27 @@ export function NewTicketDialog() {
<DialogTitle>Novo ticket</DialogTitle> <DialogTitle>Novo ticket</DialogTitle>
<DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription> <DialogDescription>Preencha as informações básicas para abrir um chamado.</DialogDescription>
</DialogHeader> </DialogHeader>
<form className="space-y-4" onSubmit={submit}> <form className="space-y-4" onSubmit={form.handleSubmit(submit)}>
<div className="space-y-2"> <FieldSet>
<label className="text-sm" htmlFor="subject">Assunto</label> <FieldGroup>
<Input id="subject" value={values.subject} onChange={(e) => setValues((v) => ({ ...v, subject: e.target.value }))} required /> <Field>
</div> <FieldLabel htmlFor="subject">Assunto</FieldLabel>
<div className="space-y-2"> <Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
<label className="text-sm" htmlFor="summary">Resumo</label> <FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
<textarea id="summary" className="w-full rounded-md border bg-background p-2 text-sm" rows={3} value={values.summary} onChange={(e) => setValues((v) => ({ ...v, summary: e.target.value }))} /> </Field>
</div> <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"> <div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2"> <Field>
<label className="text-sm">Prioridade</label> <FieldLabel>Prioridade</FieldLabel>
<Select value={values.priority} onValueChange={(v) => setValues((s) => ({ ...s, priority: v as any }))}> <Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as any)}>
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="LOW">Baixa</SelectItem> <SelectItem value="LOW">Baixa</SelectItem>
@ -94,10 +110,10 @@ export function NewTicketDialog() {
<SelectItem value="URGENT">Urgente</SelectItem> <SelectItem value="URGENT">Urgente</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </Field>
<div className="space-y-2"> <Field>
<label className="text-sm">Canal</label> <FieldLabel>Canal</FieldLabel>
<Select value={values.channel} onValueChange={(v) => setValues((s) => ({ ...s, channel: v as any }))}> <Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as any)}>
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="EMAIL">E-mail</SelectItem> <SelectItem value="EMAIL">E-mail</SelectItem>
@ -108,10 +124,10 @@ export function NewTicketDialog() {
<SelectItem value="MANUAL">Manual</SelectItem> <SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </Field>
<div className="space-y-2"> <Field>
<label className="text-sm">Fila</label> <FieldLabel>Fila</FieldLabel>
<Select value={values.queueName ?? ""} onValueChange={(v) => setValues((s) => ({ ...s, queueName: v || null }))}> <Select value={form.watch("queueName") ?? ""} onValueChange={(v) => form.setValue("queueName", v || null)}>
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">Sem fila</SelectItem> <SelectItem value="">Sem fila</SelectItem>
@ -120,8 +136,10 @@ export function NewTicketDialog() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</Field>
</div> </div>
</div> </FieldGroup>
</FieldSet>
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando</>) : "Criar"}</Button> <Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando</>) : "Criar"}</Button>
</div> </div>
@ -130,4 +148,3 @@ export function NewTicketDialog() {
</Dialog> </Dialog>
) )
} }

View file

@ -6,7 +6,7 @@ import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { TicketPlayContext } from "@/lib/schemas/ticket" import type { TicketPlayContext } from "@/lib/schemas/ticket"

View file

@ -0,0 +1,26 @@
"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-6 text-sm text-muted-foreground">
<Spinner className="me-2" /> Carregando tickets
</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 { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, ImageIcon, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react" import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { toast } from "sonner" import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone"
import { Dialog, DialogContent } from "@/components/ui/dialog"
interface TicketCommentsProps { interface TicketCommentsProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -25,7 +28,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl) const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("") 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 [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const commentsAll = useMemo(() => { const commentsAll = useMemo(() => {
@ -35,30 +39,20 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!userId) return if (!userId) return
let attachments: Array<{ storageId: string; name: string; size?: number; type?: string }> = [] const attachments = attachmentsToSend
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 now = new Date() const now = new Date()
const optimistic = { const optimistic = {
id: `temp-${now.getTime()}`, id: `temp-${now.getTime()}`,
author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário author: ticket.requester, // placeholder; poderia buscar o próprio usuário se necessário
visibility: "PUBLIC" as const, visibility: "PUBLIC" as const,
body, 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, createdAt: now,
updatedAt: now, updatedAt: now,
} }
setPending((p) => [optimistic, ...p]) setPending((p) => [optimistic, ...p])
setBody("") setBody("")
setFiles([]) setAttachmentsToSend([])
toast.loading("Enviando comentário…", { id: "comment" }) toast.loading("Enviando comentário…", { id: "comment" })
try { try {
await addComment({ ticketId: ticket.id as any, authorId: userId as any, visibility: "PUBLIC", body: optimistic.body, attachments }) 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 ( return (
<Card className="border-none shadow-none"> <Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-0"> <CardHeader className="px-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold"> <CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa <IconMessage className="size-5" /> Conversa
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6 px-0"> <CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? ( {commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Ainda sem comentarios. Que tal registrar o proximo passo? Ainda sem comentarios. Que tal registrar o proximo passo?
@ -111,12 +105,33 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
{comment.body} {comment.body}
</div> </div>
{comment.attachments?.length ? ( {comment.attachments?.length ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-3">
{comment.attachments.map((a) => ( {comment.attachments.map((a) => {
<a key={(a as any).id} href={(a as any).url} target="_blank" className="text-xs underline"> const att = a as any
{(a as any).name} 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> </a>
))} )
})}
</div> </div>
) : null} ) : null}
</div> </div>
@ -132,11 +147,19 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
/> />
<div className="flex items-center justify-between"> <Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} /> <div className="flex items-center justify-end">
<Button type="submit" size="sm">Enviar</Button> <Button type="submit" size="sm">Enviar</Button>
</div> </div>
</form> </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> </CardContent>
</Card> </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"; import { useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"; import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { TicketComments } from "@/components/tickets/ticket-comments"; import { TicketComments } from "@/components/tickets/ticket-comments";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel"; import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header"; import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
import { TicketTimeline } from "@/components/tickets/ticket-timeline"; import { TicketTimeline } from "@/components/tickets/ticket-timeline";
export function TicketDetailView({ id }: { id: string }) { export function TicketDetailView({ id }: { id: string }) {
const t = useQuery(api.tickets.getById, { tenantId: DEFAULT_TENANT_ID, id: id as any }); const isMockId = id.startsWith("ticket-");
if (!t) return <div className="px-4 py-8 text-sm text-muted-foreground">Carregando ticket...</div>; const t = useQuery(api.tickets.getById, isMockId ? undefined : ({ tenantId: DEFAULT_TENANT_ID, id: id as any }));
const ticket = mapTicketWithDetailsFromServer(t 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 ( return (
<div className="flex flex-col gap-6 px-4 lg:px-6"> <div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketSummaryHeader ticket={ticket as any} /> <TicketSummaryHeader ticket={ticket as any} />

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { TicketWithDetails } from "@/lib/schemas/ticket" import type { TicketWithDetails } from "@/lib/schemas/ticket"

View file

@ -25,6 +25,7 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado", STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado", ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado", COMMENT_ADDED: "Comentário adicionado",
QUEUE_CHANGED: "Fila alterada",
} }
interface TicketTimelineProps { interface TicketTimelineProps {
@ -33,8 +34,8 @@ interface TicketTimelineProps {
export function TicketTimeline({ ticket }: TicketTimelineProps) { export function TicketTimeline({ ticket }: TicketTimelineProps) {
return ( return (
<Card className="border-none shadow-none"> <Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-6"> <CardContent className="space-y-6 px-4 pb-6">
{ticket.timeline.map((entry, index) => { {ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4 const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1 const isLast = index === ticket.timeline.length - 1
@ -55,13 +56,19 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })} {format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span> </span>
</div> </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 (!message) return null
return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground"> <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"> {message}
{JSON.stringify(entry.payload, null, 2)}
</pre>
</div> </div>
) : null} )
})()}
</div> </div>
</div> </div>
) )

View file

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

View file

@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
import { api } from "../../../convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket" import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters" import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"

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

View file

@ -1,14 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import { import { ticketSchema, ticketWithDetailsSchema } from "@/lib/schemas/ticket";
ticketSchema,
ticketWithDetailsSchema,
ticketEventSchema,
ticketCommentSchema,
userSummarySchema,
} from "@/lib/schemas/ticket";
// Server shapes: datas como number (epoch ms) e alguns nullables // 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({ const serverTicketSchema = z.object({
id: z.string(), id: z.string(),
@ -75,7 +76,8 @@ export function mapTicketFromServer(input: unknown) {
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : 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[]) { export function mapTicketsFromServerList(arr: unknown[]) {
@ -99,5 +101,5 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
updatedAt: new Date(c.updatedAt), updatedAt: new Date(c.updatedAt),
})), })),
}; };
return ticketWithDetailsSchema.parse(ui); return ui as unknown as z.infer<typeof ticketWithDetailsSchema>;
} }