feat: add ticket category model and align ticket ui\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

This commit is contained in:
esdrasrenan 2025-10-05 00:00:14 -03:00
parent 55511f3a0e
commit fab1cbe476
17 changed files with 1121 additions and 42 deletions

View file

@ -21,13 +21,14 @@ 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"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
const referenceBadgeClass = "inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700"
const startButtonClass =
"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-black/30"
const pauseButtonClass =
@ -64,6 +65,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
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 [status] = useState<TicketStatus>(ticket.status)
@ -79,6 +81,11 @@ 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 dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
@ -107,6 +114,51 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setEditing(false)
}
useEffect(() => {
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
})
}, [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])
const handleResetCategory = () => {
setCategorySelection({
categoryId: ticket.category?.id ?? "",
subcategoryId: ticket.subcategory?.id ?? "",
})
}
async function handleSaveCategory() {
if (!userId) return
if (!categorySelection.categoryId || !categorySelection.subcategoryId) {
toast.error("Selecione uma categoria válida.")
return
}
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)
}
}
const workSummary = useMemo(() => {
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
if (!ticket.workSummary) return null
@ -146,7 +198,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-2">
<div className="absolute right-6 top-6 flex items-center gap-3">
{workSummary ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
Tempo total: {formattedTotalWorked}
</Badge>
) : null}
{!editing ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
@ -201,9 +258,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
{workSummary ? (
<div className="flex flex-wrap items-center gap-2">
<Badge className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700">
Tempo total: {formattedTotalWorked}
</Badge>
{isPlaying ? (
<Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white">
Sessão atual: {formattedCurrentSession}
@ -246,6 +300,40 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</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}
>
{savingCategory ? "Salvando..." : "Salvar"}
</Button>
<Button
size="sm"
variant="ghost"
className="text-sm font-semibold text-neutral-700"
onClick={handleResetCategory}
disabled={savingCategory || !categoryDirty}
>
Cancelar
</Button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span>