feat: align ticket header editing flow

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
esdrasrenan 2025-10-05 01:23:31 -03:00
parent e833888a3a
commit f5a54f2814
21 changed files with 282 additions and 171 deletions

View file

@ -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. - UI com shadcn/ui; priorize componentes existentes e consistência visual.
- Labels e mensagens em PTBR (status, timeline, toasts, etc.). - Labels e mensagens em PTBR (status, timeline, toasts, etc.).
- Atualizações otimistas com rollback em erro + toasts de feedback. - 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 ## Estrutura útil
- `web/convex/*` — API backend Convex. - `web/convex/*` — API backend Convex.
@ -240,6 +241,7 @@ Arquivos principais tocados:
- Header do ticket - Header do ticket
- Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir) - Ordem: `#ref` • PrioritySelect (badge) • Status (badge/select) • Ações (Excluir)
- Tipografia: título forte, resumo como texto auxiliar, metadados em texto pequeno. - 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 - Comentários
- Composer com rich text + Dropzone; seletor de visibilidade. - Composer com rich text + Dropzone; seletor de visibilidade.
- Lista com avatar, nome, carimbo relativo e conteúdo rich text. - 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. - Testes (Vitest): adicionar casos de mappers e smoke tests de páginas.
Observações de codificação Observações de codificação
- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex. - Evitar `any`; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod. - 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`.

View file

@ -1,9 +1,7 @@
import { internalMutation, mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Id, type Doc } from "./_generated/dataModel"; import { Id, type Doc } from "./_generated/dataModel";
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
const QUEUE_RENAME_LOOKUP: Record<string, string> = { const QUEUE_RENAME_LOOKUP: Record<string, string> = {
"Suporte N1": "Chamados", "Suporte N1": "Chamados",
"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 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 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 queue = chosen.queueId ? ((await ctx.db.get(chosen.queueId)) as Doc<"queues"> | null) : null
const queueName = normalizeQueueName(queue)
return { return {
id: chosen._id, id: chosen._id,
reference: chosen.reference, reference: chosen.reference,
@ -777,13 +776,13 @@ export const playNext = mutation({
status: chosen.status, status: chosen.status,
priority: chosen.priority, priority: chosen.priority,
channel: chosen.channel, channel: chosen.channel,
queue: queue?.name ?? null, queue: queueName,
requester: requester && { requester: requester && {
id: requester._id, id: requester._id,
name: requester.name, name: requester.name,
email: requester.email, email: requester.email,
avatarUrl: requester.avatarUrl, avatarUrl: requester.avatarUrl,
teams: requester.teams ?? [], teams: normalizeTeams(requester.teams),
}, },
assignee: assignee assignee: assignee
? { ? {
@ -791,7 +790,7 @@ export const playNext = mutation({
name: assignee.name, name: assignee.name,
email: assignee.email, email: assignee.email,
avatarUrl: assignee.avatarUrl, avatarUrl: assignee.avatarUrl,
teams: assignee.teams ?? [], teams: normalizeTeams(assignee.teams),
} }
: null, : null,
slaPolicy: null, slaPolicy: null,
@ -808,8 +807,8 @@ export const playNext = mutation({
}); });
export const remove = mutation({ export const remove = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") }, args: { ticketId: v.id("tickets") },
handler: async (ctx, { ticketId, actorId }) => { handler: async (ctx, { ticketId }) => {
// delete comments (and attachments) // delete comments (and attachments)
const comments = await ctx.db const comments = await ctx.db
.query("ticketComments") .query("ticketComments")

View file

@ -16,7 +16,6 @@ export const ensureUser = mutation({
.withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email)) .withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email))
.first(); .first();
if (existing) return existing; if (existing) return existing;
const now = Date.now();
const id = await ctx.db.insert("users", { const id = await ctx.db.insert("users", {
tenantId: args.tenantId, tenantId: args.tenantId,
email: args.email, email: args.email,

View file

@ -18,6 +18,7 @@ const eslintConfig = [
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
"convex/_generated/**",
], ],
}, },
{ {

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ConvexProvider, ConvexReactClient } from "convex/react"; import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode, useMemo } from "react"; import { ReactNode } from "react";
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;

View file

@ -2,8 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex runtime API lacks TypeScript declarations
// @ts-ignore
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
export default function SeedPage() { export default function SeedPage() {

View file

@ -8,8 +8,7 @@ import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex runtime API lacks TypeScript declarations
// @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

View file

@ -3,10 +3,9 @@
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { useMutation } from "convex/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 { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { AlertTriangle, Trash2 } from "lucide-react" import { AlertTriangle, Trash2 } from "lucide-react"
@ -17,14 +16,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
const remove = useMutation(api.tickets.remove) const remove = useMutation(api.tickets.remove)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const { userId } = useAuth()
async function confirm() { async function confirm() {
setLoading(true) setLoading(true)
toast.loading("Excluindo ticket...", { id: "del" }) toast.loading("Excluindo ticket...", { id: "del" })
try { try {
if (!userId) throw new Error("No user") await remove({ ticketId })
await remove({ ticketId, actorId: userId as Id<"users"> })
toast.success("Ticket excluído.", { id: "del" }) toast.success("Ticket excluído.", { id: "del" })
setOpen(false) setOpen(false)
router.push("/tickets") router.push("/tickets")
@ -41,7 +38,7 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
<Button <Button
size="icon" size="icon"
aria-label="Excluir ticket" aria-label="Excluir ticket"
className="border border-[#fca5a5] bg-[#fecaca] text-[#7f1d1d] shadow-sm transition hover:bg-[#fca5a5] focus-visible:ring-[#fca5a5]/30" className="h-9 w-9 rounded-lg border border-transparent bg-transparent text-[#ef4444] transition hover:border-[#fecaca] hover:bg-[#fee2e2] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#fecaca]/50"
> >
<Trash2 className="size-4 text-current" /> <Trash2 className="size-4 text-current" />
</Button> </Button>

View file

@ -5,7 +5,7 @@ import { useMemo, useState } from "react"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// @ts-ignore // @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"

View file

@ -5,7 +5,7 @@ import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/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 { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"

View file

@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useMutation } from "convex/react" import { useMutation } from "convex/react"
// @ts-ignore // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket" import type { TicketPriority } from "@/lib/schemas/ticket"

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useQuery } from "convex/react"; import { useQuery } from "convex/react";
// @ts-ignore // @ts-expect-error Convex runtime API lacks TS declarations
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"; import { mapTicketsFromServerList } from "@/lib/mappers/ticket";

View file

@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useMutation } from "convex/react" import { useMutation } from "convex/react"
// @ts-ignore // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket" import type { TicketStatus } from "@/lib/schemas/ticket"

View file

@ -6,7 +6,7 @@ import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage } from "@tabler/icons-react"
import { FileIcon, Trash2, X } from "lucide-react" import { FileIcon, Trash2, X } from "lucide-react"
import { useMutation } from "convex/react" import { useMutation } from "convex/react"
// @ts-ignore // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
@ -203,6 +203,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
onClick={() => setPreview(url || null)} onClick={() => setPreview(url || null)}
className="block w-full overflow-hidden rounded-md" className="block w-full overflow-hidden rounded-md"
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" /> <img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
</button> </button>
) : ( ) : (
@ -256,6 +257,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
onClick={() => setPreview(previewUrl || null)} onClick={() => setPreview(previewUrl || null)}
className="block w-full overflow-hidden rounded-md" className="block w-full overflow-hidden rounded-md"
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" /> <img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
</button> </button>
) : ( ) : (
@ -339,7 +341,12 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
</Dialog> </Dialog>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}> <Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0"> <DialogContent className="max-w-3xl p-0">
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null} {preview ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
</>
) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardContent> </CardContent>

View file

@ -1,8 +1,7 @@
"use client"; "use client";
import { useQuery } from "convex/react"; import { useQuery } from "convex/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex runtime API lacks TypeScript definitions
// @ts-ignore
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"; import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
// @ts-ignore // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket" import type { TicketQueueSummary } from "@/lib/schemas/ticket"

View file

@ -1,13 +1,12 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow } from "date-fns" import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex generates JS module without TS definitions
// @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -21,7 +20,7 @@ import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { CategorySelectFields } from "@/components/tickets/category-select" import { useTicketCategories } from "@/hooks/use-ticket-categories"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails 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" "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 = 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" "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 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-[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 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 sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
const sectionValueClass = "font-medium text-neutral-900" const sectionValueClass = "font-medium text-neutral-900"
const subtleBadgeClass = const subtleBadgeClass =
@ -68,6 +67,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateCategories = useMutation(api.tickets.updateCategories) const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] 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 queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const { categories, isLoading: categoriesLoading } = useTicketCategories(ticket.tenantId)
const [status] = useState<TicketStatus>(ticket.status) const [status] = useState<TicketStatus>(ticket.status)
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as 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 ?? "", subcategoryId: ticket.subcategory?.id ?? "",
}) })
const [savingCategory, setSavingCategory] = useState(false) 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( const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), () => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, 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() { async function handleSave() {
if (!userId) return if (!userId) return
@ -115,49 +126,106 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
} }
useEffect(() => { useEffect(() => {
setCategorySelection({ const nextSelection = {
categoryId: ticket.category?.id ?? "", categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "", subcategoryId: ticket.subcategory?.id ?? "",
}) }
setCategorySelection(nextSelection)
lastSubmittedCategoryRef.current = nextSelection
}, [ticket.category?.id, ticket.subcategory?.id]) }, [ticket.category?.id, ticket.subcategory?.id])
const categoryDirty = useMemo(() => { useEffect(() => {
const currentCategory = ticket.category?.id ?? "" if (!editing) return
const currentSubcategory = ticket.subcategory?.id ?? "" if (categoriesLoading) return
return ( if (categories.length === 0) return
categorySelection.categoryId !== currentCategory || categorySelection.subcategoryId !== currentSubcategory if (selectedCategoryId) return
) if (ticket.category?.id) return
}, [categorySelection.categoryId, categorySelection.subcategoryId, ticket.category?.id, ticket.subcategory?.id])
const handleResetCategory = () => { const first = categories[0]
const firstSecondary = first.secondary[0]
setCategorySelection({ setCategorySelection({
categoryId: ticket.category?.id ?? "", categoryId: first.id,
subcategoryId: ticket.subcategory?.id ?? "", subcategoryId: firstSecondary?.id ?? "",
}) })
} }, [categories, categoriesLoading, editing, selectedCategoryId, ticket.category?.id])
async function handleSaveCategory() { useEffect(() => {
if (!userId) return if (!editing) return
if (!categorySelection.categoryId || !categorySelection.subcategoryId) { if (!selectedCategoryId) return
toast.error("Selecione uma categoria válida.") if (secondaryOptions.length === 0) {
if (selectedSubcategoryId) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
}
return 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) setSavingCategory(true)
toast.loading("Atualizando categoria...", { id: "ticket-category" }) toast.loading("Atualizando categoria...", { id: "ticket-category" })
try {
await updateCategories({ ;(async () => {
ticketId: ticket.id as Id<"tickets">, try {
categoryId: categorySelection.categoryId as Id<"ticketCategories">, await updateCategories({
subcategoryId: categorySelection.subcategoryId as Id<"ticketSubcategories">, ticketId: ticket.id as Id<"tickets">,
actorId: userId as Id<"users">, categoryId: categoryId as Id<"ticketCategories">,
}) subcategoryId: subcategoryId as Id<"ticketSubcategories">,
toast.success("Categoria atualizada!", { id: "ticket-category" }) actorId: userId as Id<"users">,
} catch { })
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" }) if (!cancelled) {
} finally { toast.success("Categoria atualizada!", { id: "ticket-category" })
setSavingCategory(false) }
} 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(() => { const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
@ -276,53 +344,106 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null} {summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div> </div>
)} )}
{editing ? (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
Salvar
</Button>
</div>
) : null}
</div> </div>
</div> </div>
<Separator className="bg-slate-200" /> <Separator className="bg-slate-200" />
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-2 sm:col-span-2 lg:col-span-3"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Categorias</span> <span className={sectionLabelClass}>Categoria primária</span>
<CategorySelectFields {editing ? (
tenantId={ticket.tenantId} <Select
autoSelectFirst={!ticket.category} disabled={savingCategory || categoriesLoading || categories.length === 0}
categoryId={categorySelection.categoryId || null} value={selectedCategoryId || ""}
subcategoryId={categorySelection.subcategoryId || null} onValueChange={(value) => {
onCategoryChange={(value) => { const category = categories.find((item) => item.id === value)
setCategorySelection((prev) => ({ ...prev, categoryId: value })) setCategorySelection({
}} categoryId: value,
onSubcategoryChange={(value) => { subcategoryId: category?.secondary[0]?.id ?? "",
setCategorySelection((prev) => ({ ...prev, subcategoryId: value })) })
}} }}
/>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
className={startButtonClass}
onClick={handleSaveCategory}
disabled={!categoryDirty || savingCategory}
> >
{savingCategory ? "Salvando..." : "Salvar"} <SelectTrigger className={selectTriggerClass}>
</Button> <SelectValue placeholder={categoriesLoading ? "Carregando..." : "Selecionar"} />
<Button </SelectTrigger>
size="sm" <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
variant="ghost" {categories.map((category) => (
className="text-sm font-semibold text-neutral-700" <SelectItem key={category.id} value={category.id}>
onClick={handleResetCategory} {category.name}
disabled={savingCategory || !categoryDirty} </SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.category?.name ?? "Sem categoria"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Categoria secundária</span>
{editing ? (
<Select
disabled={
savingCategory || categoriesLoading || !selectedCategoryId || secondaryOptions.length === 0
}
value={selectedSubcategoryId || ""}
onValueChange={(value) => {
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
}}
> >
Cancelar <SelectTrigger className={selectTriggerClass}>
</Button> <SelectValue
</div> placeholder={
!selectedCategoryId
? "Selecione uma primária"
: secondaryOptions.length === 0
? "Sem secundárias"
: "Selecionar"
}
/>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.subcategory?.name ?? "Sem subcategoria"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Fila</span>
{editing ? (
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const queue = queues.find((item) => item.name === value)
if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className={smallSelectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{queues.map((queue) => (
<SelectItem key={queue.id} value={queue.name}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.queue ?? "Sem fila"}</span>
)}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span> <span className={sectionLabelClass}>Solicitante</span>
@ -330,59 +451,38 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Responsável</span> <span className={sectionLabelClass}>Responsável</span>
<Select {editing ? (
value={ticket.assignee?.id ?? ""} <Select
onValueChange={async (value) => { value={ticket.assignee?.id ?? ""}
if (!userId) return onValueChange={async (value) => {
toast.loading("Atribuindo responsável...", { id: "assignee" }) if (!userId) return
try { toast.loading("Atribuindo responsável...", { id: "assignee" })
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> }) try {
toast.success("Responsável atualizado!", { id: "assignee" }) await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
} catch { toast.success("Responsável atualizado!", { id: "assignee" })
toast.error("Não foi possível atribuir.", { id: "assignee" }) } catch {
} toast.error("Não foi possível atribuir.", { id: "assignee" })
}} }
> }}
<SelectTrigger className={selectTriggerClass}> >
<SelectValue placeholder="Selecionar" /> <SelectTrigger className={selectTriggerClass}>
</SelectTrigger> <SelectValue placeholder="Selecionar" />
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm"> </SelectTrigger>
{agents.map((agent) => ( <SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem key={agent._id} value={agent._id}> {agents.map((agent) => (
{agent.name} <SelectItem key={agent._id} value={agent._id}>
</SelectItem> {agent.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span>
)}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Fila</span> <span className={sectionLabelClass}>Criado em</span>
<Select <span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const queue = queues.find((item) => item.name === value)
if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className={smallSelectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{queues.map((queue) => (
<SelectItem key={queue.id} value={queue.name}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Atualizado em</span> <span className={sectionLabelClass}>Atualizado em</span>
@ -391,10 +491,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={subtleBadgeClass}>{updatedRelative}</span> <span className={subtleBadgeClass}>{updatedRelative}</span>
</div> </div>
</div> </div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
{ticket.dueAt ? ( {ticket.dueAt ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span> <span className={sectionLabelClass}>SLA até</span>
@ -407,6 +503,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span> <span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div> </div>
) : null} ) : null}
{editing ? (
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
Salvar
</Button>
</div>
) : null}
</div> </div>
</div> </div>
) )

View file

@ -2,8 +2,7 @@
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useQuery } from "convex/react" import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex runtime API lacks TypeScript definitions
// @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket" import { mapTicketsFromServerList } from "@/lib/mappers/ticket"

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
// @ts-ignore // @ts-expect-error Convex generates runtime API without TS metadata
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View file

@ -2,8 +2,7 @@
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex generates runtime API without TS declarations
// @ts-ignore
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { TicketCategory } from "@/lib/schemas/category" import type { TicketCategory } from "@/lib/schemas/category"

View file

@ -5,8 +5,7 @@ import type { Doc } from "@/convex/_generated/dataModel";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
// Lazy import to avoid build errors before convex is generated // Lazy import to avoid build errors before convex is generated
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error Convex generates runtime API without types until build
// @ts-ignore
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
export type DemoUser = { name: string; email: string; avatarUrl?: string } | null; export type DemoUser = { name: string; email: string; avatarUrl?: string } | null;