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

@ -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<TicketStatus>(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 ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</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>
<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="flex flex-col gap-2 sm:col-span-2 lg:col-span-3">
<span className={sectionLabelClass}>Categorias</span>
<CategorySelectFields
tenantId={ticket.tenantId}
autoSelectFirst={!ticket.category}
categoryId={categorySelection.categoryId || null}
subcategoryId={categorySelection.subcategoryId || null}
onCategoryChange={(value) => {
setCategorySelection((prev) => ({ ...prev, categoryId: value }))
}}
onSubcategoryChange={(value) => {
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
}}
/>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
className={startButtonClass}
onClick={handleSaveCategory}
disabled={!categoryDirty || savingCategory}
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Categoria primária</span>
{editing ? (
<Select
disabled={savingCategory || categoriesLoading || categories.length === 0}
value={selectedCategoryId || ""}
onValueChange={(value) => {
const category = categories.find((item) => item.id === value)
setCategorySelection({
categoryId: value,
subcategoryId: category?.secondary[0]?.id ?? "",
})
}}
>
{savingCategory ? "Salvando..." : "Salvar"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-sm font-semibold text-neutral-700"
onClick={handleResetCategory}
disabled={savingCategory || !categoryDirty}
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Selecionar"} />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</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
</Button>
</div>
<SelectTrigger className={selectTriggerClass}>
<SelectValue
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 className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span>
@ -330,59 +451,38 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Responsável</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
}
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{agents.map((agent) => (
<SelectItem key={agent._id} value={agent._id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
{editing ? (
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
} catch {
toast.error("Não foi possível atribuir.", { id: "assignee" })
}
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{agents.map((agent) => (
<SelectItem key={agent._id} value={agent._id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className={sectionValueClass}>{ticket.assignee?.name ?? "Não atribuído"}</span>
)}
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Fila</span>
<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={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Atualizado em</span>
@ -391,10 +491,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={subtleBadgeClass}>{updatedRelative}</span>
</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 ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span>
@ -407,6 +503,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div>
) : 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>
)