+
+
+ Solicitante:
+ {ticket.requester.name}
+
+
+
+ Responsável:
+ {ticket.assignee?.name ?? "Aguardando atribuição"}
+
+
+
+
+ Fila:
+ {ticket.queue ?? "Sem fila"}
+
+
+
+
+ Atualizado em:
+
+ {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
+
+
+
Criado em:
diff --git a/web/src/components/tickets/tickets-filters.tsx b/web/src/components/tickets/tickets-filters.tsx
index 1c00114..f4a2227 100644
--- a/web/src/components/tickets/tickets-filters.tsx
+++ b/web/src/components/tickets/tickets-filters.tsx
@@ -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 {
@@ -84,15 +84,16 @@ interface TicketsFiltersProps {
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
- const [filters, setFilters] = useState(defaultTicketFilters)
-
- function setPartial(partial: Partial) {
- setFilters((prev) => {
- const next = { ...prev, ...partial }
- onChange?.(next)
- return next
- })
- }
+ const [filters, setFilters] = useState(defaultTicketFilters)
+
+ function setPartial(partial: Partial) {
+ 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[] = []
diff --git a/web/src/components/tickets/tickets-view.tsx b/web/src/components/tickets/tickets-view.tsx
index d77d0fe..4231934 100644
--- a/web/src/components/tickets/tickets-view.tsx
+++ b/web/src/components/tickets/tickets-view.tsx
@@ -9,11 +9,12 @@ 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(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,12 @@ export function TicketsView() {
return (
-
q.name)} />
-
+ q.name)} />
+ {ticketsRaw === undefined ? (
+ Carregando tickets…
+ ) : (
+
+ )}
)
}
diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..1f7d14c
--- /dev/null
+++ b/web/src/components/ui/dialog.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+)
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription }
+
diff --git a/web/src/components/ui/spinner.tsx b/web/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..922c4d0
--- /dev/null
+++ b/web/src/components/ui/spinner.tsx
@@ -0,0 +1,16 @@
+import { LoaderIcon } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+
+ )
+}
+
+export { Spinner }
+
diff --git a/web/src/lib/mappers/__tests__/ticket.test.ts b/web/src/lib/mappers/__tests__/ticket.test.ts
new file mode 100644
index 0000000..342b321
--- /dev/null
+++ b/web/src/lib/mappers/__tests__/ticket.test.ts
@@ -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);
+ });
+});
+
diff --git a/web/vitest.config.ts b/web/vitest.config.ts
new file mode 100644
index 0000000..4744c85
--- /dev/null
+++ b/web/vitest.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ globals: true,
+ include: ["src/**/*.test.ts"],
+ },
+});
+