Merge pull request #2 from esdrasrenan/feat/convex-tickets-core
Feat/convex tickets core
This commit is contained in:
commit
5b19a0ff4e
30 changed files with 1803 additions and 294 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
799
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
156
web/src/components/tickets/new-ticket-dialog.tsx
Normal file
156
web/src/components/tickets/new-ticket-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
33
web/src/components/tickets/recent-tickets-panel.tsx
Normal file
33
web/src/components/tickets/recent-tickets-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
21
web/src/components/tickets/ticket-detail-static.tsx
Normal file
21
web/src/components/tickets/ticket-detail-static.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
67
web/src/components/ui/dialog.tsx
Normal file
67
web/src/components/ui/dialog.tsx
Normal 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 }
|
||||
|
||||
115
web/src/components/ui/dropzone.tsx
Normal file
115
web/src/components/ui/dropzone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
web/src/components/ui/field.tsx
Normal file
81
web/src/components/ui/field.tsx
Normal 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 }
|
||||
|
||||
16
web/src/components/ui/spinner.tsx
Normal file
16
web/src/components/ui/spinner.tsx
Normal 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 }
|
||||
|
||||
2
web/src/convex/_generated/api.ts
Normal file
2
web/src/convex/_generated/api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "../../../convex/_generated/api";
|
||||
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
52
web/src/lib/mappers/__tests__/ticket.test.ts
Normal file
52
web/src/lib/mappers/__tests__/ticket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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
10
web/vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue