From cad42f8b8cf90f78eab2a741df1acf789616c3c4 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:06:20 -0300 Subject: [PATCH 1/8] fix: let toast width auto-size --- web/src/components/ui/sonner.tsx | 45 ++++++++++++++------------------ 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index d13c151..aaaf63f 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -6,17 +6,24 @@ import { Toaster as Sonner, ToasterProps } from "sonner" const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme() + const baseClass = + "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg" + const baseStyle = { + background: "#000", + color: "#fff", + border: "1px solid #000", + width: "fit-content" as const, + minWidth: 0, + maxWidth: "min(24rem, calc(100vw - 2rem))", + } + return ( svg]:size-[14px] [&>svg]:text-white", }, @@ -27,32 +34,18 @@ const Toaster = ({ ...props }: ToasterProps) => { primary: "#ffffff", secondary: "#000000", }, - success: { - className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg", - style: { background: "#000", color: "#fff", border: "1px solid #000" }, - }, - error: { - className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg", - style: { background: "#000", color: "#fff", border: "1px solid #000" }, - }, - info: { - className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg", - style: { background: "#000", color: "#fff", border: "1px solid #000" }, - }, - warning: { - className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg", - style: { background: "#000", color: "#fff", border: "1px solid #000" }, - }, - loading: { - className: "inline-flex w-auto min-w-0 items-center justify-center gap-2 self-center rounded-xl border border-black bg-black px-3 py-2 text-sm font-medium text-white shadow-lg", - style: { background: "#000", color: "#fff", border: "1px solid #000" }, - }, + success: { className: baseClass, style: baseStyle }, + error: { className: baseClass, style: baseStyle }, + info: { className: baseClass, style: baseStyle }, + warning: { className: baseClass, style: baseStyle }, + loading: { className: baseClass, style: baseStyle }, }} style={ { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", + "--width": "fit-content", } as React.CSSProperties } {...props} From c5a537a833c0b896b7a3d10ca4e8e9b1cbc527ff Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:12:54 -0300 Subject: [PATCH 2/8] style: stabilize toast sizing --- web/src/components/ui/sonner.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index aaaf63f..e769a6e 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -12,9 +12,11 @@ const Toaster = ({ ...props }: ToasterProps) => { background: "#000", color: "#fff", border: "1px solid #000", - width: "fit-content" as const, - minWidth: 0, + width: "max-content" as const, + minWidth: "fit-content" as const, maxWidth: "min(24rem, calc(100vw - 2rem))", + whiteSpace: "normal" as const, + overflowWrap: "break-word" as const, } return ( @@ -45,7 +47,7 @@ const Toaster = ({ ...props }: ToasterProps) => { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)", - "--width": "fit-content", + "--width": "max-content", } as React.CSSProperties } {...props} From d56bb3e7b3b941d5629c0eafe63651ca13bd08da Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:15:30 -0300 Subject: [PATCH 3/8] style: align active session badge with timer --- .../components/tickets/ticket-summary-header.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 199eb4f..37f4389 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" -import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" +import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -199,6 +199,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { return (
+ {isPlaying ? ( + + Sessão atual: {formattedCurrentSession} + + ) : null} {workSummary ? ( Tempo total: {formattedTotalWorked} @@ -256,15 +261,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { )}
- {workSummary ? ( -
- {isPlaying ? ( - - Sessão atual: {formattedCurrentSession} - - ) : null} -
- ) : null} {editing ? (
Date: Sun, 5 Oct 2025 00:20:00 -0300 Subject: [PATCH 4/8] style: simplify timer badge --- web/src/components/tickets/ticket-summary-header.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 37f4389..df65bf3 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -190,7 +190,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs]) - const formattedCurrentSession = useMemo(() => formatDuration(currentSessionMs), [currentSessionMs]) const updatedRelative = useMemo( () => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }), [ticket.updatedAt] @@ -199,14 +198,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { return (
- {isPlaying ? ( - - Sessão atual: {formattedCurrentSession} - - ) : null} {workSummary ? ( - Tempo total: {formattedTotalWorked} + Tempo total: {formattedTotalWorked} ) : null} {!editing ? ( From fcac720f8303fed9b7beeb25a519f206c86b9146 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:23:27 -0300 Subject: [PATCH 5/8] style: flush tickets table header --- web/src/components/tickets/tickets-table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/tickets/tickets-table.tsx b/web/src/components/tickets/tickets-table.tsx index 51251d2..3f997fd 100644 --- a/web/src/components/tickets/tickets-table.tsx +++ b/web/src/components/tickets/tickets-table.tsx @@ -107,7 +107,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) { } return ( - + From dee31117d3c8d35298e05b483c2b268ba507a7db Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:28:45 -0300 Subject: [PATCH 6/8] style: rename support queues and align category badges --- web/convex/bootstrap.ts | 4 ++-- web/convex/seed.ts | 4 ++-- web/src/components/tickets/tickets-table.tsx | 19 +++++++---------- web/src/lib/mappers/__tests__/ticket.test.ts | 2 +- web/src/lib/mocks/tickets.ts | 22 ++++++++++---------- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/web/convex/bootstrap.ts b/web/convex/bootstrap.ts index f837bca..0eadd01 100644 --- a/web/convex/bootstrap.ts +++ b/web/convex/bootstrap.ts @@ -10,8 +10,8 @@ export const ensureDefaults = mutation({ .collect(); if (existing.length === 0) { const queues = [ - { name: "Suporte N1", slug: "suporte-n1" }, - { name: "Suporte N2", slug: "suporte-n2" }, + { name: "Chamados", slug: "chamados" }, + { name: "Laboratório", slug: "laboratorio" }, { name: "Field Services", slug: "field-services" }, ]; for (const q of queues) { diff --git a/web/convex/seed.ts b/web/convex/seed.ts index a1c799c..2498ab1 100644 --- a/web/convex/seed.ts +++ b/web/convex/seed.ts @@ -13,8 +13,8 @@ export const seedDemo = mutation({ ? existingQueues : await Promise.all( [ - { name: "Suporte N1", slug: "suporte-n1" }, - { name: "Suporte N2", slug: "suporte-n2" }, + { name: "Chamados", slug: "chamados" }, + { name: "Laboratório", slug: "laboratorio" }, ].map((q) => ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined })) ).then((ids) => Promise.all(ids.map((id) => ctx.db.get(id)))) ; diff --git a/web/src/components/tickets/tickets-table.tsx b/web/src/components/tickets/tickets-table.tsx index 3f997fd..c168069 100644 --- a/web/src/components/tickets/tickets-table.tsx +++ b/web/src/components/tickets/tickets-table.tsx @@ -35,7 +35,6 @@ const channelLabel: Record = { const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8" const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700" -const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600" const categoryBadgeClass = "inline-flex items-center gap-1 rounded-full border border-[#00e8ff]/50 bg-[#00e8ff]/10 px-2.5 py-0.5 text-[11px] font-semibold text-[#02414d]" const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none" @@ -170,17 +169,13 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
{ticket.requester.name} - {ticket.category ? ( - - {ticket.category.name} - {ticket.subcategory ? ` • ${ticket.subcategory.name}` : ""} - - ) : null} - {ticket.tags?.map((tag) => ( - - {tag} - - ))} + + {ticket.category + ? `${ticket.category.name}${ticket.subcategory ? ` • ${ticket.subcategory.name}` : ""}` + : "Sem categoria"} +
diff --git a/web/src/lib/mappers/__tests__/ticket.test.ts b/web/src/lib/mappers/__tests__/ticket.test.ts index 342b321..11d0fe8 100644 --- a/web/src/lib/mappers/__tests__/ticket.test.ts +++ b/web/src/lib/mappers/__tests__/ticket.test.ts @@ -35,7 +35,7 @@ describe("ticket mappers", () => { status: "OPEN", priority: "MEDIUM", channel: "EMAIL", - queue: "Suporte N1", + queue: "Chamados", requester: { id: "u1", name: "Ana", email: "a@a.com", teams: [] }, assignee: { id: "u2", name: "Bruno", email: "b@b.com", teams: [] }, updatedAt: now, diff --git a/web/src/lib/mocks/tickets.ts b/web/src/lib/mocks/tickets.ts index 87fd384..62ba03a 100644 --- a/web/src/lib/mocks/tickets.ts +++ b/web/src/lib/mocks/tickets.ts @@ -22,21 +22,21 @@ const users: Record = { name: "Ana Souza", email: "ana.souza@example.com", avatarUrl: "https://avatar.vercel.sh/ana", - teams: ["Suporte N1"], + teams: ["Chamados"], }, bruno: { id: "user-bruno", name: "Bruno Lima", email: "bruno.lima@example.com", avatarUrl: "https://avatar.vercel.sh/bruno", - teams: ["Suporte N1"], + teams: ["Chamados"], }, carla: { id: "user-carla", name: "Carla Menezes", email: "carla.menezes@example.com", avatarUrl: "https://avatar.vercel.sh/carla", - teams: ["Suporte N2"], + teams: ["Laboratório"], }, diego: { id: "user-diego", @@ -54,9 +54,9 @@ const users: Record = { }, } -const queues = [ - { id: "queue-suporte-n1", name: "Suporte N1", pending: 18, waiting: 4, breached: 2 }, - { id: "queue-suporte-n2", name: "Suporte N2", pending: 9, waiting: 3, breached: 1 }, +const queues = [ + { id: "queue-chamados", name: "Chamados", pending: 18, waiting: 4, breached: 2 }, + { id: "queue-laboratorio", name: "Laboratório", pending: 9, waiting: 3, breached: 1 }, { id: "queue-field", name: "Field Services", pending: 5, waiting: 2, breached: 0 }, ] @@ -70,7 +70,7 @@ const baseTickets = [ status: ticketStatusSchema.enum.OPEN, priority: ticketPrioritySchema.enum.URGENT, channel: ticketChannelSchema.enum.EMAIL, - queue: "Suporte N1", + queue: "Chamados", requester: users.eduarda, assignee: users.ana, slaPolicy: { @@ -100,7 +100,7 @@ const baseTickets = [ status: ticketStatusSchema.enum.PENDING, priority: ticketPrioritySchema.enum.HIGH, channel: ticketChannelSchema.enum.WHATSAPP, - queue: "Suporte N2", + queue: "Laboratório", requester: users.eduarda, assignee: users.carla, slaPolicy: { @@ -130,7 +130,7 @@ const baseTickets = [ status: ticketStatusSchema.enum.NEW, priority: ticketPrioritySchema.enum.MEDIUM, channel: ticketChannelSchema.enum.MANUAL, - queue: "Field Services", + queue: "Field Services", requester: users.eduarda, assignee: null, slaPolicy: null, @@ -151,7 +151,7 @@ const baseTickets = [ status: ticketStatusSchema.enum.ON_HOLD, priority: ticketPrioritySchema.enum.HIGH, channel: ticketChannelSchema.enum.CHAT, - queue: "Suporte N2", + queue: "Laboratório", requester: users.eduarda, assignee: users.carla, slaPolicy: { @@ -181,7 +181,7 @@ const baseTickets = [ status: ticketStatusSchema.enum.RESOLVED, priority: ticketPrioritySchema.enum.MEDIUM, channel: ticketChannelSchema.enum.EMAIL, - queue: "Suporte N1", + queue: "Chamados", requester: users.eduarda, assignee: users.bruno, slaPolicy: { From e833888a3abd0ecb898ec9df82d760899d00eb7a Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 00:36:59 -0300 Subject: [PATCH 7/8] fix: normalize queue labels --- web/convex/bootstrap.ts | 15 ++++++++- web/convex/queues.ts | 16 +++++++++- web/convex/seed.ts | 17 +++++++++- web/convex/tickets.ts | 71 +++++++++++++++++++++++++++++++++-------- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/web/convex/bootstrap.ts b/web/convex/bootstrap.ts index 0eadd01..6292237 100644 --- a/web/convex/bootstrap.ts +++ b/web/convex/bootstrap.ts @@ -4,10 +4,23 @@ import { v } from "convex/values"; export const ensureDefaults = mutation({ args: { tenantId: v.string() }, handler: async (ctx, { tenantId }) => { - const existing = await ctx.db + let existing = await ctx.db .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); + existing = await Promise.all( + existing.map(async (queue) => { + if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") { + await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") { + await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + return queue; + }) + ); if (existing.length === 0) { const queues = [ { name: "Chamados", slug: "chamados" }, diff --git a/web/convex/queues.ts b/web/convex/queues.ts index fa5f975..9b5ace4 100644 --- a/web/convex/queues.ts +++ b/web/convex/queues.ts @@ -1,6 +1,20 @@ import { query } from "./_generated/server"; import { v } from "convex/values"; +const QUEUE_RENAME_LOOKUP: Record = { + "Suporte N1": "Chamados", + "suporte-n1": "Chamados", + "Suporte N2": "Laboratório", + "suporte-n2": "Laboratório", +}; + +function renameQueueString(value: string) { + const direct = QUEUE_RENAME_LOOKUP[value]; + if (direct) return direct; + const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); + return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; +} + export const summary = query({ args: { tenantId: v.string() }, handler: async (ctx, { tenantId }) => { @@ -15,7 +29,7 @@ export const summary = query({ const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length; const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length; const breached = 0; // Placeholder, SLAs later - return { id: qItem._id, name: qItem.name, pending: open, waiting, breached }; + return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached }; }) ); return result; diff --git a/web/convex/seed.ts b/web/convex/seed.ts index 2498ab1..f6e9ff3 100644 --- a/web/convex/seed.ts +++ b/web/convex/seed.ts @@ -9,7 +9,7 @@ export const seedDemo = mutation({ .query("queues") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); - const queues = existingQueues.length + let queues = existingQueues.length ? existingQueues : await Promise.all( [ @@ -19,6 +19,21 @@ export const seedDemo = mutation({ ).then((ids) => Promise.all(ids.map((id) => ctx.db.get(id)))) ; + queues = await Promise.all( + queues.map(async (queue) => { + if (!queue) return queue; + if (queue.name === "Suporte N1" || queue.slug === "suporte-n1") { + await ctx.db.patch(queue._id, { name: "Chamados", slug: "chamados" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + if (queue.name === "Suporte N2" || queue.slug === "suporte-n2") { + await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); + return (await ctx.db.get(queue._id)) ?? queue; + } + return queue; + }) + ); + // Ensure users async function ensureUser(name: string, email: string, role = "AGENT") { const found = await ctx.db diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index e609ff2..9ce81bd 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -4,6 +4,39 @@ import { Id, type Doc } from "./_generated/dataModel"; const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; +const QUEUE_RENAME_LOOKUP: Record = { + "Suporte N1": "Chamados", + "suporte-n1": "Chamados", + "Suporte N2": "Laboratório", + "suporte-n2": "Laboratório", +}; + +function renameQueueString(value?: string | null): string | null { + if (!value) return value ?? null; + const direct = QUEUE_RENAME_LOOKUP[value]; + if (direct) return direct; + const normalizedKey = value.replace(/\s+/g, "-").toLowerCase(); + return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; +} + +function normalizeQueueName(queue?: Doc<"queues"> | null): string | null { + if (!queue) return null; + const normalized = renameQueueString(queue.name); + if (normalized && normalized !== queue.name) { + return normalized; + } + if (queue.slug) { + const fromSlug = renameQueueString(queue.slug); + if (fromSlug) return fromSlug; + } + return normalized ?? queue.name; +} + +function normalizeTeams(teams?: string[] | null): string[] { + if (!teams) return []; + return teams.map((team) => renameQueueString(team) ?? team); +} + export const list = query({ args: { tenantId: v.string(), @@ -55,6 +88,7 @@ export const list = query({ const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const queueName = normalizeQueueName(queue); const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null; @@ -85,13 +119,13 @@ export const list = query({ status: t.status, priority: t.priority, channel: t.channel, - queue: queue?.name ?? null, + queue: queueName, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, - teams: requester.teams ?? [], + teams: normalizeTeams(requester.teams), }, assignee: assignee ? { @@ -99,7 +133,7 @@ export const list = query({ name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, - teams: assignee.teams ?? [], + teams: normalizeTeams(assignee.teams), } : null, slaPolicy: null, @@ -139,6 +173,7 @@ export const getById = query({ const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; + const queueName = normalizeQueueName(queue); const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; const comments = await ctx.db @@ -191,13 +226,13 @@ export const getById = query({ status: t.status, priority: t.priority, channel: t.channel, - queue: queue?.name ?? null, + queue: queueName, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, - teams: requester.teams ?? [], + teams: normalizeTeams(requester.teams), }, assignee: assignee ? { @@ -205,7 +240,7 @@ export const getById = query({ name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, - teams: assignee.teams ?? [], + teams: normalizeTeams(assignee.teams), } : null, slaPolicy: null, @@ -242,12 +277,21 @@ export const getById = query({ }, description: undefined, customFields: {}, - timeline: timeline.map((ev) => ({ - id: ev._id, - type: ev.type, - payload: ev.payload, - createdAt: ev.createdAt, - })), + timeline: timeline.map((ev) => { + let payload = ev.payload; + if (ev.type === "QUEUE_CHANGED" && payload && typeof payload === "object" && "queueName" in payload) { + const normalized = renameQueueString((payload as { queueName?: string }).queueName ?? null); + if (normalized && normalized !== (payload as { queueName?: string }).queueName) { + payload = { ...payload, queueName: normalized }; + } + } + return { + id: ev._id, + type: ev.type, + payload, + createdAt: ev.createdAt, + }; + }), comments: commentsHydrated, }; }, @@ -451,10 +495,11 @@ export const changeQueue = mutation({ const now = Date.now(); await ctx.db.patch(ticketId, { queueId, updatedAt: now }); const queue = (await ctx.db.get(queueId)) as Doc<"queues"> | null; + const queueName = normalizeQueueName(queue); await ctx.db.insert("ticketEvents", { ticketId, type: "QUEUE_CHANGED", - payload: { queueId, queueName: queue?.name, actorId }, + payload: { queueId, queueName, actorId }, createdAt: now, }); }, From f5a54f28141dbb22562efd45e2449788c0c41e48 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 01:23:31 -0300 Subject: [PATCH 8/8] feat: align ticket header editing flow Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- agents.md | 12 +- web/convex/tickets.ts | 15 +- web/convex/users.ts | 1 - web/eslint.config.mjs | 1 + web/src/app/ConvexClientProvider.tsx | 2 +- web/src/app/dev/seed/page.tsx | 3 +- web/src/app/tickets/new/page.tsx | 3 +- .../tickets/delete-ticket-dialog.tsx | 9 +- .../components/tickets/new-ticket-dialog.tsx | 2 +- .../tickets/play-next-ticket-card.tsx | 2 +- .../components/tickets/priority-select.tsx | 2 +- .../tickets/recent-tickets-panel.tsx | 2 +- web/src/components/tickets/status-select.tsx | 2 +- .../tickets/ticket-comments.rich.tsx | 11 +- .../components/tickets/ticket-detail-view.tsx | 3 +- .../tickets/ticket-queue-summary.tsx | 2 +- .../tickets/ticket-summary-header.tsx | 370 +++++++++++------- web/src/components/tickets/tickets-view.tsx | 3 +- web/src/components/ui/dropzone.tsx | 2 +- web/src/hooks/use-ticket-categories.ts | 3 +- web/src/lib/auth-client.tsx | 3 +- 21 files changed, 282 insertions(+), 171 deletions(-) diff --git a/agents.md b/agents.md index 526ea39..ae40eae 100644 --- a/agents.md +++ b/agents.md @@ -101,6 +101,7 @@ Este repositório foi atualizado para usar Convex como backend em tempo real par - UI com shadcn/ui; priorize componentes existentes e consistência visual. - Labels e mensagens em PT‑BR (status, timeline, toasts, etc.). - Atualizações otimistas com rollback em erro + toasts de feedback. +- Comentários de supressão: prefira `@ts-expect-error` com justificativa curta para módulos gerados do Convex; evite `@ts-ignore`. ## Estrutura útil - `web/convex/*` — API backend Convex. @@ -240,6 +241,7 @@ Arquivos principais tocados: - Header do ticket - Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir) - Tipografia: título forte, resumo como texto auxiliar, metadados em texto pequeno. + - Combos de Categoria/ Subcategoria exibidos como selects dependentes com salvamento automático (sem botões dedicados). - Comentários - Composer com rich text + Dropzone; seletor de visibilidade. - Lista com avatar, nome, carimbo relativo e conteúdo rich text. @@ -295,5 +297,11 @@ Próximos passos sugeridos - Testes (Vitest): adicionar casos de mappers e smoke tests de páginas. Observações de codificação -- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex. -- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod. +- Evitar `any`; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex. +- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod. + +## Atualizações recentes (out/2025) +- Cabeçalho de ticket agora persiste automaticamente mudanças de categoria/subcategoria, mostrando toasts e bloqueando os selects enquanto a mutação é processada. +- Normalização de nomes de fila/time aplicada também ao retorno de `tickets.playNext`, garantindo rótulos "Chamados"/"Laboratório" em todos os fluxos. +- ESLint ignora `convex/_generated/**` e supressões migradas para `@ts-expect-error` com justificativa explícita. +- Mutação `tickets.remove` não requer mais `actorId`; o diálogo de exclusão apenas envia `ticketId`. diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 9ce81bd..f3c18bb 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -1,9 +1,7 @@ -import { internalMutation, mutation, query } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; -const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; - const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", @@ -768,6 +766,7 @@ export const playNext = mutation({ const requester = (await ctx.db.get(chosen.requesterId)) as Doc<"users"> | null const assignee = chosen.assigneeId ? ((await ctx.db.get(chosen.assigneeId)) as Doc<"users"> | null) : null const queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null + const queueName = normalizeQueueName(queue) return { id: chosen._id, reference: chosen.reference, @@ -777,13 +776,13 @@ export const playNext = mutation({ status: chosen.status, priority: chosen.priority, channel: chosen.channel, - queue: queue?.name ?? null, + queue: queueName, requester: requester && { id: requester._id, name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl, - teams: requester.teams ?? [], + teams: normalizeTeams(requester.teams), }, assignee: assignee ? { @@ -791,7 +790,7 @@ export const playNext = mutation({ name: assignee.name, email: assignee.email, avatarUrl: assignee.avatarUrl, - teams: assignee.teams ?? [], + teams: normalizeTeams(assignee.teams), } : null, slaPolicy: null, @@ -808,8 +807,8 @@ export const playNext = mutation({ }); export const remove = mutation({ - args: { ticketId: v.id("tickets"), actorId: v.id("users") }, - handler: async (ctx, { ticketId, actorId }) => { + args: { ticketId: v.id("tickets") }, + handler: async (ctx, { ticketId }) => { // delete comments (and attachments) const comments = await ctx.db .query("ticketComments") diff --git a/web/convex/users.ts b/web/convex/users.ts index 76b463a..dbcd54f 100644 --- a/web/convex/users.ts +++ b/web/convex/users.ts @@ -16,7 +16,6 @@ export const ensureUser = mutation({ .withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email)) .first(); if (existing) return existing; - const now = Date.now(); const id = await ctx.db.insert("users", { tenantId: args.tenantId, email: args.email, diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 8502ebc..7c7f05b 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -18,6 +18,7 @@ const eslintConfig = [ "out/**", "build/**", "next-env.d.ts", + "convex/_generated/**", ], }, { diff --git a/web/src/app/ConvexClientProvider.tsx b/web/src/app/ConvexClientProvider.tsx index e85039a..99b2c1b 100644 --- a/web/src/app/ConvexClientProvider.tsx +++ b/web/src/app/ConvexClientProvider.tsx @@ -1,7 +1,7 @@ "use client"; import { ConvexProvider, ConvexReactClient } from "convex/react"; -import { ReactNode, useMemo } from "react"; +import { ReactNode } from "react"; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; diff --git a/web/src/app/dev/seed/page.tsx b/web/src/app/dev/seed/page.tsx index bced027..d74cfc5 100644 --- a/web/src/app/dev/seed/page.tsx +++ b/web/src/app/dev/seed/page.tsx @@ -2,8 +2,7 @@ import { useState } from "react"; import { useMutation } from "convex/react"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api"; export default function SeedPage() { diff --git a/web/src/app/tickets/new/page.tsx b/web/src/app/tickets/new/page.tsx index 667bc27..3217d6c 100644 --- a/web/src/app/tickets/new/page.tsx +++ b/web/src/app/tickets/new/page.tsx @@ -8,8 +8,7 @@ import type { Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" diff --git a/web/src/components/tickets/delete-ticket-dialog.tsx b/web/src/components/tickets/delete-ticket-dialog.tsx index ea5601d..92891c0 100644 --- a/web/src/components/tickets/delete-ticket-dialog.tsx +++ b/web/src/components/tickets/delete-ticket-dialog.tsx @@ -3,10 +3,9 @@ import { useRouter } from "next/navigation" import { useState } from "react" import { useMutation } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TS declarations until build import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import { useAuth } from "@/lib/auth-client" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { AlertTriangle, Trash2 } from "lucide-react" @@ -17,14 +16,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { const remove = useMutation(api.tickets.remove) const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) - const { userId } = useAuth() async function confirm() { setLoading(true) toast.loading("Excluindo ticket...", { id: "del" }) try { - if (!userId) throw new Error("No user") - await remove({ ticketId, actorId: userId as Id<"users"> }) + await remove({ ticketId }) toast.success("Ticket excluído.", { id: "del" }) setOpen(false) router.push("/tickets") @@ -41,7 +38,7 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { diff --git a/web/src/components/tickets/new-ticket-dialog.tsx b/web/src/components/tickets/new-ticket-dialog.tsx index 928e6bb..e070859 100644 --- a/web/src/components/tickets/new-ticket-dialog.tsx +++ b/web/src/components/tickets/new-ticket-dialog.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from "react" import type { Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { useMutation, useQuery } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" diff --git a/web/src/components/tickets/play-next-ticket-card.tsx b/web/src/components/tickets/play-next-ticket-card.tsx index 693953f..faa0113 100644 --- a/web/src/components/tickets/play-next-ticket-card.tsx +++ b/web/src/components/tickets/play-next-ticket-card.tsx @@ -5,7 +5,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx index 1f11141..692fe79 100644 --- a/web/src/components/tickets/priority-select.tsx +++ b/web/src/components/tickets/priority-select.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useMutation } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import type { TicketPriority } from "@/lib/schemas/ticket" diff --git a/web/src/components/tickets/recent-tickets-panel.tsx b/web/src/components/tickets/recent-tickets-panel.tsx index 207da13..17d587b 100644 --- a/web/src/components/tickets/recent-tickets-panel.tsx +++ b/web/src/components/tickets/recent-tickets-panel.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "convex/react"; -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TS declarations import { api } from "@/convex/_generated/api"; import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { mapTicketsFromServerList } from "@/lib/mappers/ticket"; diff --git a/web/src/components/tickets/status-select.tsx b/web/src/components/tickets/status-select.tsx index e53c299..d8c4fa6 100644 --- a/web/src/components/tickets/status-select.tsx +++ b/web/src/components/tickets/status-select.tsx @@ -2,7 +2,7 @@ import { useState } from "react" import { useMutation } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import type { TicketStatus } from "@/lib/schemas/ticket" diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index f145b9b..af3029a 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -6,7 +6,7 @@ import { ptBR } from "date-fns/locale" import { IconLock, IconMessage } from "@tabler/icons-react" import { FileIcon, Trash2, X } from "lucide-react" import { useMutation } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" @@ -203,6 +203,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { onClick={() => setPreview(url || null)} className="block w-full overflow-hidden rounded-md" > + {/* eslint-disable-next-line @next/next/no-img-element */} {name} ) : ( @@ -256,6 +257,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) { onClick={() => setPreview(previewUrl || null)} className="block w-full overflow-hidden rounded-md" > + {/* eslint-disable-next-line @next/next/no-img-element */} {name} ) : ( @@ -339,7 +341,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) { !open && setPreview(null)}> - {preview ? Preview : null} + {preview ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + Preview + + ) : null} diff --git a/web/src/components/tickets/ticket-detail-view.tsx b/web/src/components/tickets/ticket-detail-view.tsx index ce66e8a..ce09edd 100644 --- a/web/src/components/tickets/ticket-detail-view.tsx +++ b/web/src/components/tickets/ticket-detail-view.tsx @@ -1,8 +1,7 @@ "use client"; import { useQuery } from "convex/react"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api"; import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"; diff --git a/web/src/components/tickets/ticket-queue-summary.tsx b/web/src/components/tickets/ticket-queue-summary.tsx index fbc60a2..0bd462a 100644 --- a/web/src/components/tickets/ticket-queue-summary.tsx +++ b/web/src/components/tickets/ticket-queue-summary.tsx @@ -1,7 +1,7 @@ "use client" import { useQuery } from "convex/react" -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { TicketQueueSummary } from "@/lib/schemas/ticket" diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index df65bf3..bac0434 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -1,13 +1,12 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex generates JS module without TS definitions import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" @@ -21,7 +20,7 @@ import { StatusSelect } from "@/components/tickets/status-select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { CategorySelectFields } from "@/components/tickets/category-select" +import { useTicketCategories } from "@/hooks/use-ticket-categories" interface TicketHeaderProps { ticket: TicketWithDetails @@ -35,8 +34,8 @@ const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" -const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" -const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const selectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" +const smallSelectTriggerClass = "h-8 w-full rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]" const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500" const sectionValueClass = "font-medium text-neutral-900" const subtleBadgeClass = @@ -68,6 +67,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { const updateCategories = useMutation(api.tickets.updateCategories) const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? [] + const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId) const [status] = useState(ticket.status) const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as | { @@ -86,10 +86,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { subcategoryId: ticket.subcategory?.id ?? "", }) const [savingCategory, setSavingCategory] = useState(false) + const lastSubmittedCategoryRef = useRef({ + categoryId: ticket.category?.id ?? "", + subcategoryId: ticket.subcategory?.id ?? "", + }) + const selectedCategoryId = categorySelection.categoryId + const selectedSubcategoryId = categorySelection.subcategoryId const dirty = useMemo( () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary] ) + const activeCategory = useMemo( + () => categories.find((category) => category.id === selectedCategoryId) ?? null, + [categories, selectedCategoryId] + ) + const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory]) async function handleSave() { if (!userId) return @@ -115,49 +126,106 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { } useEffect(() => { - setCategorySelection({ + const nextSelection = { categoryId: ticket.category?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "", - }) + } + setCategorySelection(nextSelection) + lastSubmittedCategoryRef.current = nextSelection }, [ticket.category?.id, ticket.subcategory?.id]) - const categoryDirty = useMemo(() => { - const currentCategory = ticket.category?.id ?? "" - const currentSubcategory = ticket.subcategory?.id ?? "" - return ( - categorySelection.categoryId !== currentCategory || categorySelection.subcategoryId !== currentSubcategory - ) - }, [categorySelection.categoryId, categorySelection.subcategoryId, ticket.category?.id, ticket.subcategory?.id]) + useEffect(() => { + if (!editing) return + if (categoriesLoading) return + if (categories.length === 0) return + if (selectedCategoryId) return + if (ticket.category?.id) return - const handleResetCategory = () => { + const first = categories[0] + const firstSecondary = first.secondary[0] setCategorySelection({ - categoryId: ticket.category?.id ?? "", - subcategoryId: ticket.subcategory?.id ?? "", + categoryId: first.id, + subcategoryId: firstSecondary?.id ?? "", }) - } + }, [categories, categoriesLoading, editing, selectedCategoryId, ticket.category?.id]) - async function handleSaveCategory() { - if (!userId) return - if (!categorySelection.categoryId || !categorySelection.subcategoryId) { - toast.error("Selecione uma categoria válida.") + useEffect(() => { + if (!editing) return + if (!selectedCategoryId) return + if (secondaryOptions.length === 0) { + if (selectedSubcategoryId) { + setCategorySelection((prev) => ({ ...prev, subcategoryId: "" })) + } return } + + const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId) + if (stillValid) return + + const fallback = secondaryOptions[0] + if (fallback) { + setCategorySelection((prev) => ({ ...prev, subcategoryId: fallback.id })) + } + }, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId]) + + useEffect(() => { + if (!editing) return + if (!userId) return + const categoryId = selectedCategoryId + const subcategoryId = selectedSubcategoryId + if (!categoryId || !subcategoryId) return + + const currentCategory = ticket.category?.id ?? "" + const currentSubcategory = ticket.subcategory?.id ?? "" + + if (categoryId === currentCategory && subcategoryId === currentSubcategory) { + return + } + + if ( + categoryId === lastSubmittedCategoryRef.current.categoryId && + subcategoryId === lastSubmittedCategoryRef.current.subcategoryId + ) { + return + } + + let cancelled = false + lastSubmittedCategoryRef.current = { categoryId, subcategoryId } setSavingCategory(true) toast.loading("Atualizando categoria...", { id: "ticket-category" }) - try { - await updateCategories({ - ticketId: ticket.id as Id<"tickets">, - categoryId: categorySelection.categoryId as Id<"ticketCategories">, - subcategoryId: categorySelection.subcategoryId as Id<"ticketSubcategories">, - actorId: userId as Id<"users">, - }) - toast.success("Categoria atualizada!", { id: "ticket-category" }) - } catch { - toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" }) - } finally { - setSavingCategory(false) + + ;(async () => { + try { + await updateCategories({ + ticketId: ticket.id as Id<"tickets">, + categoryId: categoryId as Id<"ticketCategories">, + subcategoryId: subcategoryId as Id<"ticketSubcategories">, + actorId: userId as Id<"users">, + }) + if (!cancelled) { + toast.success("Categoria atualizada!", { id: "ticket-category" }) + } + } catch { + if (!cancelled) { + toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" }) + const fallback = { + categoryId: currentCategory, + subcategoryId: currentSubcategory, + } + setCategorySelection(fallback) + lastSubmittedCategoryRef.current = fallback + } + } finally { + if (!cancelled) { + setSavingCategory(false) + } + } + })() + + return () => { + cancelled = true } - } + }, [editing, selectedCategoryId, selectedSubcategoryId, ticket.category?.id, ticket.subcategory?.id, ticket.id, updateCategories, userId]) const workSummary = useMemo(() => { if (workSummaryRemote !== undefined) return workSummaryRemote ?? null @@ -276,53 +344,106 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { {summary ?

{summary}

: null} )} - {editing ? ( -
- - -
- ) : null}
-
- Categorias - { - setCategorySelection((prev) => ({ ...prev, categoryId: value })) - }} - onSubcategoryChange={(value) => { - setCategorySelection((prev) => ({ ...prev, subcategoryId: value })) - }} - /> -
-
+
+ Categoria secundária + {editing ? ( + + ) : ( + {ticket.subcategory?.name ?? "Sem subcategoria"} + )} +
+
+ Fila + {editing ? ( + + ) : ( + {ticket.queue ?? "Sem fila"} + )}
Solicitante @@ -330,59 +451,38 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
Responsável - + {editing ? ( + + ) : ( + {ticket.assignee?.name ?? "Não atribuído"} + )}
- Fila - + Criado em + {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
Atualizado em @@ -391,10 +491,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { {updatedRelative}
-
- Criado em - {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })} -
{ticket.dueAt ? (
SLA até @@ -407,6 +503,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { {ticket.slaPolicy.name}
) : null} + {editing ? ( +
+ + +
+ ) : null}
) diff --git a/web/src/components/tickets/tickets-view.tsx b/web/src/components/tickets/tickets-view.tsx index 7ff2f27..6645b5f 100644 --- a/web/src/components/tickets/tickets-view.tsx +++ b/web/src/components/tickets/tickets-view.tsx @@ -2,8 +2,7 @@ import { useMemo, useState } from "react" import { useQuery } from "convex/react" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { mapTicketsFromServerList } from "@/lib/mappers/ticket" diff --git a/web/src/components/ui/dropzone.tsx b/web/src/components/ui/dropzone.tsx index 527b41f..635bd43 100644 --- a/web/src/components/ui/dropzone.tsx +++ b/web/src/components/ui/dropzone.tsx @@ -1,7 +1,7 @@ "use client"; import { useAction } from "convex/react"; -// @ts-ignore +// @ts-expect-error Convex generates runtime API without TS metadata import { api } from "@/convex/_generated/api"; import { useCallback, useRef, useState } from "react"; import { cn } from "@/lib/utils"; diff --git a/web/src/hooks/use-ticket-categories.ts b/web/src/hooks/use-ticket-categories.ts index ad60571..5258310 100644 --- a/web/src/hooks/use-ticket-categories.ts +++ b/web/src/hooks/use-ticket-categories.ts @@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef } from "react" import { useMutation, useQuery } from "convex/react" -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Convex generates runtime API without TS declarations import { api } from "@/convex/_generated/api" import type { TicketCategory } from "@/lib/schemas/category" diff --git a/web/src/lib/auth-client.tsx b/web/src/lib/auth-client.tsx index e5027e8..71972a2 100644 --- a/web/src/lib/auth-client.tsx +++ b/web/src/lib/auth-client.tsx @@ -5,8 +5,7 @@ import type { Doc } from "@/convex/_generated/dataModel"; 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 +// @ts-expect-error Convex generates runtime API without types until build import { api } from "@/convex/_generated/api"; export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;