From 90c3c8e4d6b9c714d9f74aa5ac219be138f2a581 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 00:52:56 -0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Spinner=20e=20Dialog=20shadcn;=20No?= =?UTF-8?q?vo=20ticket=20em=20Dialog=20com=20RHF+Zod;=20selects=20de=20res?= =?UTF-8?q?pons=C3=A1vel=20e=20fila=20com=20updates=20otimistas;=20spinner?= =?UTF-8?q?s=20e=20toasts;=20fix=20setState=20durante=20render=20nos=20fil?= =?UTF-8?q?tros;=20loading=20states\n\nchore(test):=20Vitest=20+=20testes?= =?UTF-8?q?=20de=20mapeadores\n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/convex/tickets.ts | 28 ++++ web/package.json | 18 ++- web/src/app/tickets/page.tsx | 4 +- .../components/tickets/new-ticket-dialog.tsx | 133 ++++++++++++++++++ .../tickets/play-next-ticket-card.tsx | 4 +- .../tickets/ticket-summary-header.tsx | 97 +++++++++---- .../components/tickets/tickets-filters.tsx | 21 +-- web/src/components/tickets/tickets-view.tsx | 15 +- web/src/components/ui/dialog.tsx | 67 +++++++++ web/src/components/ui/spinner.tsx | 16 +++ web/src/lib/mappers/__tests__/ticket.test.ts | 52 +++++++ web/vitest.config.ts | 10 ++ 12 files changed, 415 insertions(+), 50 deletions(-) create mode 100644 web/src/components/tickets/new-ticket-dialog.tsx create mode 100644 web/src/components/ui/dialog.tsx create mode 100644 web/src/components/ui/spinner.tsx create mode 100644 web/src/lib/mappers/__tests__/ticket.test.ts create mode 100644 web/vitest.config.ts diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 5433049..e46361b 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -281,6 +281,34 @@ export const updateStatus = mutation({ }, }); +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 }); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "ASSIGNEE_CHANGED", + payload: { assigneeId, 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 }); + await ctx.db.insert("ticketEvents", { + ticketId, + type: "QUEUE_CHANGED", + payload: { queueId, actorId }, + createdAt: now, + }); + }, +}); + export const playNext = mutation({ args: { tenantId: v.string(), diff --git a/web/package.json b/web/package.json index a688603..7b39897 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,8 @@ "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", @@ -40,13 +41,15 @@ "next-themes": "^0.4.6", "react": "19.1.0", "react-dom": "19.1.0", - "recharts": "^2.15.4", - "sonner": "^2.0.7", + "recharts": "^2.15.4", + "react-hook-form": "^7.53.0", + "@hookform/resolvers": "^3.9.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", "zod": "^4.1.9" }, - "devDependencies": { + "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", @@ -57,6 +60,7 @@ "prisma": "^6.16.2", "tailwindcss": "^4", "tw-animate-css": "^1.3.8", - "typescript": "^5" - } -} + "typescript": "^5", + "vitest": "^2.1.4" + } +} diff --git a/web/src/app/tickets/page.tsx b/web/src/app/tickets/page.tsx index 67972b0..7fafd1a 100644 --- a/web/src/app/tickets/page.tsx +++ b/web/src/app/tickets/page.tsx @@ -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={Exportar CSV} - primaryAction={Novo ticket} + primaryAction={} /> } > diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx new file mode 100644 index 0000000..7841413 --- /dev/null +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -0,0 +1,133 @@ +"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 { toast } from "sonner" +import { Spinner } from "@/components/ui/spinner" + +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 [values, setValues] = useState>({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }) + const { userId } = useAuth() + const queues = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) ?? [] + const create = useMutation(api.tickets.create) + + async function submit(e: React.FormEvent) { + 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 + 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, + }) + toast.success("Ticket criado!", { id: "new-ticket" }) + setOpen(false) + setValues({ subject: "", summary: "", priority: "MEDIUM", channel: "MANUAL", queueName: null }) + // 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 ( + + + + + + + Novo ticket + Preencha as informações básicas para abrir um chamado. + +
+
+ + setValues((v) => ({ ...v, subject: e.target.value }))} required /> +
+
+ +