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

@ -0,0 +1,135 @@
"use client"
import { useEffect, useMemo } from "react"
import { IconFolders, IconFolder } from "@tabler/icons-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import type { TicketCategory } from "@/lib/schemas/category"
const triggerClass =
"flex h-9 w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm transition focus:ring-0 data-[state=open]:border-[#00d6eb]"
const contentClass = "rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md"
const itemClass =
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
interface CategorySelectProps {
tenantId: string
categoryId: string | null
subcategoryId: string | null
onCategoryChange: (categoryId: string) => void
onSubcategoryChange: (subcategoryId: string) => void
autoSelectFirst?: boolean
disabled?: boolean
categoryLabel?: string
subcategoryLabel?: string
className?: string
secondaryEmptyLabel?: string
}
function findCategory(categories: TicketCategory[], categoryId: string | null) {
if (!categoryId) return null
return categories.find((category) => category.id === categoryId) ?? null
}
export function CategorySelectFields({
tenantId,
categoryId,
subcategoryId,
onCategoryChange,
onSubcategoryChange,
autoSelectFirst = true,
disabled = false,
categoryLabel = "Primária",
subcategoryLabel = "Secundária",
secondaryEmptyLabel = "Selecione uma categoria primária",
className,
}: CategorySelectProps) {
const { categories, isLoading } = useTicketCategories(tenantId)
const activeCategory = useMemo(() => findCategory(categories, categoryId), [categories, categoryId])
const secondaryOptions = useMemo(() => activeCategory?.secondary ?? [], [activeCategory])
useEffect(() => {
if (!autoSelectFirst || isLoading) return
if (categories.length === 0) return
if (categoryId) return
const first = categories[0]
if (first) {
onCategoryChange(first.id)
const firstSecondary = first.secondary[0]
if (firstSecondary) {
onSubcategoryChange(firstSecondary.id)
}
}
}, [autoSelectFirst, categories, categoryId, isLoading, onCategoryChange, onSubcategoryChange])
useEffect(() => {
if (!categoryId || secondaryOptions.length === 0) return
const stillValid = secondaryOptions.some((item) => item.id === subcategoryId)
if (!stillValid) {
const first = secondaryOptions[0]
if (first) {
onSubcategoryChange(first.id)
}
}
}, [categoryId, secondaryOptions, subcategoryId, onSubcategoryChange])
return (
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolders className="size-3.5" /> {categoryLabel}
</label>
<Select
disabled={disabled || isLoading || categories.length === 0}
value={categoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onCategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={isLoading ? "Carregando..." : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolders className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{category.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
<IconFolder className="size-3.5" /> {subcategoryLabel}
</label>
<Select
disabled={disabled || secondaryOptions.length === 0}
value={subcategoryId ?? undefined}
onValueChange={(value) => {
if (!value) return
onSubcategoryChange(value)
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue placeholder={secondaryOptions.length === 0 ? secondaryEmptyLabel : "Selecione"} />
</SelectTrigger>
<SelectContent className={contentClass}>
{secondaryOptions.map((option) => (
<SelectItem key={option.id} value={option.id} className={itemClass}>
<span className="flex items-center gap-2">
<IconFolder className="size-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-800">{option.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import { z } from "zod"
import { useState } from "react"
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"
@ -29,6 +29,7 @@ import {
priorityStyles,
priorityTriggerClass,
} from "@/components/tickets/priority-select"
import { CategorySelectFields } from "@/components/tickets/category-select"
const schema = z.object({
subject: z.string().min(3, "Informe um assunto"),
@ -37,6 +38,8 @@ const schema = z.object({
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(),
categoryId: z.string().min(1, "Selecione uma categoria"),
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
})
export function NewTicketDialog() {
@ -44,17 +47,29 @@ export function NewTicketDialog() {
const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { subject: "", summary: "", description: "", priority: "MEDIUM", channel: "MANUAL", queueName: null },
defaultValues: {
subject: "",
summary: "",
description: "",
priority: "MEDIUM",
channel: "MANUAL",
queueName: null,
categoryId: "",
subcategoryId: "",
},
mode: "onTouched",
})
const { userId } = useAuth()
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
const queuesRaw = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined
const queues = useMemo(() => queuesRaw ?? [], [queuesRaw])
const create = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE"
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
@ -77,6 +92,8 @@ export function NewTicketDialog() {
channel: values.channel,
queueId: sel?.id as Id<"queues"> | undefined,
requesterId: userId as Id<"users">,
categoryId: values.categoryId as Id<"ticketCategories">,
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
})
const hasDescription = (values.description ?? "").replace(/<[^>]*>/g, "").trim().length > 0
const bodyHtml = hasDescription ? (values.description as string) : (values.summary || "")
@ -95,7 +112,7 @@ export function NewTicketDialog() {
setAttachments([])
// Navegar para o ticket recém-criado
window.location.href = `/tickets/${id}`
} catch (err) {
} catch {
toast.error("Não foi possível criar o ticket.", { id: "new-ticket" })
} finally {
setLoading(false)
@ -142,6 +159,27 @@ export function NewTicketDialog() {
<Dropzone onUploaded={(files) => setAttachments((prev) => [...prev, ...files])} />
<FieldError className="mt-1">Formatos comuns de imagem e documentos são aceitos.</FieldError>
</Field>
<Field>
<CategorySelectFields
tenantId={DEFAULT_TENANT_ID}
categoryId={categoryIdValue || null}
subcategoryId={subcategoryIdValue || null}
onCategoryChange={(value) => {
form.setValue("categoryId", value, { shouldDirty: true, shouldValidate: true })
}}
onSubcategoryChange={(value) => {
form.setValue("subcategoryId", value, { shouldDirty: true, shouldValidate: true })
}}
/>
{form.formState.errors.categoryId?.message || form.formState.errors.subcategoryId?.message ? (
<FieldError className="mt-1 space-y-0.5">
<>
{form.formState.errors.categoryId?.message ? <div>{form.formState.errors.categoryId?.message}</div> : null}
{form.formState.errors.subcategoryId?.message ? <div>{form.formState.errors.subcategoryId?.message}</div> : null}
</>
</FieldError>
) : null}
</Field>
<div className="grid gap-3 sm:grid-cols-3">
<Field>
<FieldLabel>Prioridade</FieldLabel>

View file

@ -10,20 +10,25 @@ import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
LOW: { label: "Baixa", badgeClass: "bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "bg-[#fbd9dd] text-[#8b0f1c]" },
}
export const priorityTriggerClass = "h-8 w-[160px] rounded-full 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]"
export const priorityTriggerClass =
"h-8 w-[160px] rounded-full 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]"
export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const iconClass = "size-4 text-neutral-700"
export const priorityBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
export const priorityBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
const headerTriggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
export function PriorityIcon({ value }: { value: TicketPriority }) {
if (value === "LOW") return <ArrowDown className={iconClass} />
@ -55,11 +60,12 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
}
}}
>
<SelectTrigger className={priorityTriggerClass}>
<SelectValue>
<SelectTrigger className={headerTriggerClass} aria-label="Atualizar prioridade">
<SelectValue asChild>
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>

View file

@ -11,19 +11,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" },
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
}
const triggerClass = "h-8 w-[180px] rounded-full 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 triggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
const baseBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
@ -48,10 +51,11 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
}
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>

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>

View file

@ -3,6 +3,7 @@ import type { ComponentType } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
IconFolders,
IconNote,
IconPaperclip,
IconSquareCheck,
@ -26,6 +27,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
QUEUE_CHANGED: IconSquareCheck,
PRIORITY_CHANGED: IconSquareCheck,
ATTACHMENT_REMOVED: IconPaperclip,
CATEGORY_CHANGED: IconFolders,
}
const timelineLabels: Record<string, string> = {
@ -40,6 +42,7 @@ const timelineLabels: Record<string, string> = {
QUEUE_CHANGED: "Fila alterada",
PRIORITY_CHANGED: "Prioridade alterada",
ATTACHMENT_REMOVED: "Anexo removido",
CATEGORY_CHANGED: "Categoria alterada",
}
interface TicketTimelineProps {
@ -110,6 +113,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
from?: string
attachmentName?: string
sessionDurationMs?: number
categoryName?: string
subcategoryName?: string
}
let message: string | null = null
@ -143,6 +148,11 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
}
if (entry.type === "CATEGORY_CHANGED" && (payload.categoryName || payload.subcategoryName)) {
message = `Categoria alterada para ${payload.categoryName ?? ""}${
payload.subcategoryName ? `${payload.subcategoryName}` : ""
}`
}
if (!message) return null
return (

View file

@ -36,6 +36,7 @@ const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:
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"
function formatDuration(ms?: number) {
@ -169,6 +170,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</span>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
{ticket.category ? (
<Badge className={categoryBadgeClass}>
{ticket.category.name}
{ticket.subcategory ? `${ticket.subcategory.name}` : ""}
</Badge>
) : null}
{ticket.tags?.map((tag) => (
<Badge key={tag} className={tagBadgeClass}>
{tag}

View file

@ -11,18 +11,41 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: {
background: "#000",
color: "#fff",
border: "1px solid #000",
},
classNames: {
toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold",
success: "border border-black bg-black text-white",
error: "border border-black bg-black text-white",
info: "border border-black bg-black text-white",
warning: "border border-black bg-black text-white",
loading: "border border-black bg-black text-white",
title: "font-medium",
description: "text-white/80",
icon: "text-[#00e8ff]",
actionButton: "bg-white text-black border border-black rounded-lg",
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
icon: "size-[14px] text-white [&>svg]:size-[14px] [&>svg]:text-white",
},
descriptionClassName: "text-white/80",
actionButtonClassName: "bg-white text-black border border-black rounded-lg",
cancelButtonClassName: "bg-transparent text-white border border-white/40 rounded-lg",
iconTheme: {
primary: "#ffffff",
secondary: "#000000",
},
success: {
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
error: {
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
info: {
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
warning: {
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
loading: {
className: "flex items-center gap-3 rounded-xl border border-black bg-black px-4 py-3 text-sm font-medium text-white shadow-lg",
style: { background: "#000", color: "#fff", border: "1px solid #000" },
},
}}
style={