feat: refine ticket header save flow

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
rever-tecnologia 2025-10-06 09:56:19 -03:00
parent 01b7103200
commit cebe1b9bf1
9 changed files with 616 additions and 587 deletions

View file

@ -1,6 +1,6 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
@ -41,6 +41,9 @@ const sectionValueClass = "font-medium text-neutral-900"
const subtleBadgeClass =
"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 EMPTY_CATEGORY_VALUE = "__none__"
const EMPTY_SUBCATEGORY_VALUE = "__none__"
function formatDuration(durationMs: number) {
if (durationMs <= 0) return "0s"
const totalSeconds = Math.floor(durationMs / 1000)
@ -91,21 +94,26 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const [categorySelection, setCategorySelection] = useState({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
})
const [savingCategory, setSavingCategory] = useState(false)
const lastSubmittedCategoryRef = useRef({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
})
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
{
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
}
)
const [saving, setSaving] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
)
const currentCategoryId = ticket.category?.id ?? ""
const currentSubcategoryId = ticket.subcategory?.id ?? ""
const categoryDirty = useMemo(() => {
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const formDirty = dirty || categoryDirty
const activeCategory = useMemo(
() => categories.find((category) => category.id === selectedCategoryId) ?? null,
[categories, selectedCategoryId]
@ -113,56 +121,81 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
async function handleSave() {
if (!convexUserId) return
toast.loading("Salvando alterações...", { id: "save-header" })
if (!convexUserId || !formDirty) {
setEditing(false)
return
}
setSaving(true)
try {
if (subject !== ticket.subject) {
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: convexUserId as Id<"users"> })
if (categoryDirty) {
toast.loading("Atualizando categoria...", { id: "ticket-category" })
try {
await updateCategories({
ticketId: ticket.id as Id<"tickets">,
categoryId: selectedCategoryId ? (selectedCategoryId as Id<"ticketCategories">) : null,
subcategoryId: selectedSubcategoryId ? (selectedSubcategoryId as Id<"ticketSubcategories">) : null,
actorId: convexUserId as Id<"users">,
})
toast.success("Categoria atualizada!", { id: "ticket-category" })
} catch (categoryError) {
toast.error("Não foi possível atualizar a categoria.", { id: "ticket-category" })
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
throw categoryError
}
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: convexUserId as Id<"users"> })
if (dirty) {
toast.loading("Salvando alterações...", { id: "save-header" })
if (subject !== ticket.subject) {
await updateSubject({
ticketId: ticket.id as Id<"tickets">,
subject: subject.trim(),
actorId: convexUserId as Id<"users">,
})
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({
ticketId: ticket.id as Id<"tickets">,
summary: (summary ?? "").trim(),
actorId: convexUserId as Id<"users">,
})
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
setEditing(false)
} catch {
toast.error("Não foi possível salvar.", { id: "save-header" })
} finally {
setSaving(false)
}
}
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setCategorySelection({
categoryId: currentCategoryId,
subcategoryId: currentSubcategoryId,
})
setEditing(false)
}
useEffect(() => {
const nextSelection = {
if (editing) return
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
}
setCategorySelection(nextSelection)
lastSubmittedCategoryRef.current = nextSelection
}, [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 first = categories[0]
const firstSecondary = first.secondary[0]
setCategorySelection({
categoryId: first.id,
subcategoryId: firstSecondary?.id ?? "",
})
}, [categories, categoriesLoading, editing, selectedCategoryId, ticket.category?.id])
}, [editing, ticket.category?.id, ticket.subcategory?.id])
useEffect(() => {
if (!editing) return
if (!selectedCategoryId) return
if (secondaryOptions.length === 0) {
if (!selectedCategoryId) {
if (selectedSubcategoryId) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
}
@ -170,73 +203,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
const stillValid = secondaryOptions.some((option) => option.id === selectedSubcategoryId)
if (stillValid) return
const fallback = secondaryOptions[0]
if (fallback) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: fallback.id }))
if (!stillValid && selectedSubcategoryId) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
}
}, [editing, secondaryOptions, selectedCategoryId, selectedSubcategoryId])
useEffect(() => {
if (!editing) return
if (!convexUserId) 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" })
;(async () => {
try {
await updateCategories({
ticketId: ticket.id as Id<"tickets">,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
actorId: convexUserId 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, convexUserId])
const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
if (!ticket.workSummary) return null
@ -362,20 +333,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionLabelClass}>Categoria primária</span>
{editing ? (
<Select
disabled={savingCategory || categoriesLoading || categories.length === 0}
value={selectedCategoryId || ""}
disabled={saving || categoriesLoading}
value={selectedCategoryId ? selectedCategoryId : EMPTY_CATEGORY_VALUE}
onValueChange={(value) => {
if (value === EMPTY_CATEGORY_VALUE) {
setCategorySelection({ categoryId: "", subcategoryId: "" })
return
}
const category = categories.find((item) => item.id === value)
setCategorySelection({
categoryId: value,
subcategoryId: category?.secondary[0]?.id ?? "",
subcategoryId: category?.secondary.find((option) => option.id === selectedSubcategoryId)?.id ?? "",
})
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Selecionar"} />
<SelectValue placeholder={categoriesLoading ? "Carregando..." : "Sem categoria"} />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value={EMPTY_CATEGORY_VALUE}>Sem categoria</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
@ -392,10 +368,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
{editing ? (
<Select
disabled={
savingCategory || categoriesLoading || !selectedCategoryId || secondaryOptions.length === 0
saving || categoriesLoading || !selectedCategoryId
}
value={selectedSubcategoryId || ""}
value={selectedSubcategoryId ? selectedSubcategoryId : EMPTY_SUBCATEGORY_VALUE}
onValueChange={(value) => {
if (value === EMPTY_SUBCATEGORY_VALUE) {
setCategorySelection((prev) => ({ ...prev, subcategoryId: "" }))
return
}
setCategorySelection((prev) => ({ ...prev, subcategoryId: value }))
}}
>
@ -405,12 +385,13 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
!selectedCategoryId
? "Selecione uma primária"
: secondaryOptions.length === 0
? "Sem secundárias"
? "Sem subcategoria"
: "Selecionar"
}
/>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value={EMPTY_SUBCATEGORY_VALUE}>Sem subcategoria</SelectItem>
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
@ -518,7 +499,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<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}>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!formDirty || saving}>
Salvar
</Button>
</div>