Align admin tables with ticket styling and add board view

This commit is contained in:
Esdras Renan 2025-10-24 12:23:27 -03:00
parent 63cf9f9d45
commit a319aa0eff
8 changed files with 783 additions and 447 deletions

View file

@ -89,7 +89,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
}
const NO_COMPANY_VALUE = "__no_company__"
const AUTO_COMPANY_VALUE = "__auto__"
const schema = z.object({
subject: z.string().default(""),
@ -118,7 +117,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
channel: "MANUAL",
queueName: null,
assigneeId: null,
companyId: AUTO_COMPANY_VALUE,
companyId: NO_COMPANY_VALUE,
requesterId: "",
categoryId: "",
subcategoryId: "",
@ -180,7 +179,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
const companyValue = form.watch("companyId") ?? AUTO_COMPANY_VALUE
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId")
@ -188,18 +187,25 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
companies.forEach((company) => {
const trimmedName = company.name.trim()
const slugFallback = company.slug?.trim()
const label =
trimmedName.length > 0 ? trimmedName : slugFallback && slugFallback.length > 0 ? slugFallback : `Empresa ${company.id.slice(0, 8)}`
map.set(company.id, {
id: company.id,
name: company.name.trim().length > 0 ? company.name : "Empresa sem nome",
name: label,
isAvulso: false,
keywords: company.slug ? [company.slug] : [],
})
})
customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) {
const trimmedName = customer.companyName?.trim() ?? ""
const label =
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
map.set(customer.companyId, {
id: customer.companyId,
name: customer.companyName && customer.companyName.trim().length > 0 ? customer.companyName : "Empresa sem nome",
name: label,
isAvulso: customer.companyIsAvulso,
keywords: [],
})
@ -208,12 +214,12 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const base: Array<{ id: string; name: string; isAvulso?: boolean; keywords: string[] }> = [
{ id: NO_COMPANY_VALUE, name: "Sem empresa", keywords: ["sem empresa", "nenhuma"], isAvulso: false },
]
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
const sorted = Array.from(map.values())
.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [...base, ...sorted]
}, [companies, customers])
const filteredCustomers = useMemo(() => {
if (companyValue === AUTO_COMPANY_VALUE) return customers
if (companyValue === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId)
}
@ -236,11 +242,10 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companyOptions],
)
const selectedCompanyOption = useMemo(() => {
if (companyValue === AUTO_COMPANY_VALUE) return null
const key = companyValue === NO_COMPANY_VALUE ? NO_COMPANY_VALUE : companyValue
return companyOptionMap.get(key) ?? null
}, [companyOptionMap, companyValue])
const selectedCompanyOption = useMemo(
() => companyOptionMap.get(companyValue) ?? null,
[companyOptionMap, companyValue],
)
const requesterById = useMemo(
() => new Map(customers.map((customer) => [customer.id, customer])),
@ -275,7 +280,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => {
if (!open) {
setCustomersInitialized(false)
form.setValue("companyId", AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("requesterId", "", { shouldDirty: false, shouldTouch: false })
return
}
@ -294,7 +299,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
if (selected?.companyId) {
form.setValue("companyId", selected.companyId, { shouldDirty: false, shouldTouch: false })
} else {
form.setValue("companyId", selected ? NO_COMPANY_VALUE : AUTO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
form.setValue("companyId", NO_COMPANY_VALUE, { shouldDirty: false, shouldTouch: false })
}
setCustomersInitialized(true)
}, [open, customersInitialized, customers, convexUserId, form])
@ -558,15 +563,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<Field>
<FieldLabel>Empresa</FieldLabel>
<SearchableCombobox
value={companyValue === AUTO_COMPANY_VALUE ? null : companyValue}
value={companyValue}
onValueChange={(nextValue) => {
const normalizedValue = nextValue ?? AUTO_COMPANY_VALUE
const normalizedValue = nextValue ?? NO_COMPANY_VALUE
const nextCustomers =
normalizedValue === AUTO_COMPANY_VALUE
? customers
: normalizedValue === NO_COMPANY_VALUE
? customers.filter((customer) => !customer.companyId)
: customers.filter((customer) => customer.companyId === normalizedValue)
normalizedValue === NO_COMPANY_VALUE
? customers.filter((customer) => !customer.companyId)
: customers.filter((customer) => customer.companyId === normalizedValue)
form.setValue("companyId", normalizedValue, {
shouldDirty: normalizedValue !== companyValue,
shouldTouch: true,
@ -588,8 +591,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
}}
options={companyComboboxOptions}
placeholder="Selecionar empresa"
allowClear
clearLabel="Qualquer empresa"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>

View file

@ -256,8 +256,8 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
? "relative break-words rounded-xl border border-amber-200/80 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700 shadow-[0_0_0_1px_rgba(217,119,6,0.08)]"
: "relative break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700"
const bodyEditButtonClass = isPublic
? "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "absolute right-2 top-2 inline-flex size-7 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
? "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-amber-200 bg-white text-amber-700 opacity-0 transition hover:border-amber-500 hover:text-amber-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/30 group-hover/comment:opacity-100"
: "absolute right-3 top-1/2 inline-flex size-7 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white text-neutral-700 opacity-0 transition hover:border-black hover:text-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/20 group-hover/comment:opacity-100"
const addContentButtonClass = isPublic
? "inline-flex items-center gap-2 text-sm font-medium text-amber-700 transition hover:text-amber-900"
: "inline-flex items-center gap-2 text-sm font-medium text-neutral-600 transition hover:text-neutral-900"

View file

@ -39,6 +39,7 @@ import {
toServerTimestamp,
type SessionStartOrigin,
} from "./ticket-timer.utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
interface TicketHeaderProps {
ticket: TicketWithDetails
@ -109,7 +110,6 @@ type CustomerOption = {
avatarUrl: string | null
}
const ALL_COMPANIES_VALUE = "__all__"
const NO_COMPANY_VALUE = "__no_company__"
const NO_REQUESTER_VALUE = "__no_requester__"
@ -250,7 +250,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [closeOpen, setCloseOpen] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(ALL_COMPANIES_VALUE)
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
const [requesterError, setRequesterError] = useState<string | null>(null)
const [customersInitialized, setCustomersInitialized] = useState(false)
@ -278,32 +278,47 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
if (ticket.company?.id) return String(ticket.company.id)
return NO_COMPANY_VALUE
}, [currentRequesterRecord, ticket.company?.id])
const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean }>()
const companyMeta = useMemo(() => {
const map = new Map<string, { name: string; isAvulso?: boolean; keywords: string[] }>()
companies.forEach((company) => {
map.set(company.id, { id: company.id, name: company.name, isAvulso: false })
const trimmedName = company.name.trim()
const slugFallback = company.slug?.trim()
const label =
trimmedName.length > 0
? trimmedName
: slugFallback && slugFallback.length > 0
? slugFallback
: `Empresa ${company.id.slice(0, 8)}`
const keywords = slugFallback ? [slugFallback] : []
map.set(company.id, { name: label, isAvulso: false, keywords })
})
customers.forEach((customer) => {
if (customer.companyId && !map.has(customer.companyId)) {
const trimmedName = customer.companyName?.trim() ?? ""
const label =
trimmedName.length > 0 ? trimmedName : `Empresa ${customer.companyId.slice(0, 8)}`
map.set(customer.companyId, {
id: customer.companyId,
name: customer.companyName ?? "Empresa sem nome",
name: label,
isAvulso: customer.companyIsAvulso,
keywords: [],
})
}
})
const includeNoCompany = customers.some((customer) => !customer.companyId)
const result: Array<{ id: string; name: string; isAvulso?: boolean }> = [
{ id: ALL_COMPANIES_VALUE, name: "Todas as empresas" },
]
if (includeNoCompany) {
result.push({ id: NO_COMPANY_VALUE, name: "Sem empresa" })
}
const sorted = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [...result, ...sorted]
return map
}, [companies, customers])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
const entries = Array.from(companyMeta.entries())
.map(([id, meta]) => ({
value: id,
label: meta.name,
keywords: meta.keywords,
}))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
return [{ value: NO_COMPANY_VALUE, label: "Sem empresa", keywords: ["sem empresa", "nenhuma"] }, ...entries]
}, [companyMeta])
const filteredCustomers = useMemo(() => {
if (companySelection === ALL_COMPANIES_VALUE) return customers
if (companySelection === NO_COMPANY_VALUE) {
return customers.filter((customer) => !customer.companyId)
}
@ -312,6 +327,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty
const assigneeReasonRequired = assigneeDirty && !isManager
const assigneeReasonValid = !assigneeReasonRequired || assigneeChangeReason.trim().length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid
const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso"
@ -1238,23 +1256,35 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Empresa</span>
{editing && !isManager ? (
<Select
<SearchableCombobox
value={companySelection}
onValueChange={(value) => {
setCompanySelection(value)
setCompanySelection(value ?? NO_COMPANY_VALUE)
}}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="max-h-64 rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{companyOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
options={companyComboboxOptions}
placeholder="Selecionar empresa"
disabled={isManager}
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Selecionar empresa</span>
)
}
renderOption={(option) => {
const meta = companyMeta.get(option.value)
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-foreground">{option.label}</span>
{meta?.isAvulso ? (
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide">
Avulsa
</Badge>
) : null}
</div>
)
}}
/>
) : (
<span className={sectionValueClass}>{companyLabel}</span>
)}
@ -1350,6 +1380,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</p>
{assigneeReasonError ? (
<p className="text-xs font-semibold text-rose-600">{assigneeReasonError}</p>
) : assigneeReasonRequired && assigneeChangeReason.trim().length < 5 ? (
<p className="text-xs font-semibold text-rose-600">Descreva o motivo com pelo menos 5 caracteres.</p>
) : null}
</div>
) : null}
@ -1381,7 +1413,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={!formDirty || saving}>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={saveDisabled}>
Salvar
</Button>
</div>

View file

@ -0,0 +1,156 @@
"use client"
import Link from "next/link"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { LayoutGrid } from "lucide-react"
import type { Ticket, TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { cn } from "@/lib/utils"
type TicketsBoardProps = {
tickets: Ticket[]
}
const statusLabel: Record<TicketStatus, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const statusChipClass: Record<TicketStatus, string> = {
PENDING: "bg-amber-100 text-amber-800 ring-1 ring-amber-200",
AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700 ring-1 ring-sky-200",
PAUSED: "bg-violet-100 text-violet-700 ring-1 ring-violet-200",
RESOLVED: "bg-emerald-100 text-emerald-700 ring-1 ring-emerald-200",
}
const priorityLabel: Record<TicketPriority, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}
const priorityChipClass: Record<TicketPriority, string> = {
LOW: "bg-slate-100 text-slate-700",
MEDIUM: "bg-sky-100 text-sky-700",
HIGH: "bg-amber-100 text-amber-800",
URGENT: "bg-rose-100 text-rose-700",
}
function formatUpdated(date: Date) {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
}
export function TicketsBoard({ tickets }: TicketsBoardProps) {
if (!tickets.length) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
<Empty>
<EmptyHeader>
<LayoutGrid className="mx-auto size-9 text-neutral-400" />
</EmptyHeader>
<EmptyContent>
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou crie um novo ticket para visualizar aqui na visão em quadro.
</EmptyDescription>
</EmptyContent>
</Empty>
</div>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="group block h-full rounded-3xl border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-1 hover:border-slate-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-300"
>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="border-slate-200 bg-slate-100 px-2.5 py-1 text-[11px] font-semibold text-neutral-700"
>
#{ticket.reference}
</Badge>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-semibold transition",
statusChipClass[ticket.status],
)}
>
{statusLabel[ticket.status]}
</span>
</div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-neutral-400">
{formatUpdated(ticket.updatedAt)}
</span>
</div>
<h3 className="mt-3 line-clamp-2 text-sm font-semibold text-neutral-900">
{ticket.subject || "Sem assunto"}
</h3>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<span className="font-medium text-neutral-500">Fila:</span>
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-[11px] font-medium text-neutral-700">
{ticket.queue ?? "Sem fila"}
</span>
<span className="font-medium text-neutral-500">Prioridade:</span>
<span
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold shadow-sm",
priorityChipClass[ticket.priority],
)}
>
{priorityLabel[ticket.priority]}
</span>
</div>
<dl className="mt-4 space-y-2 text-xs text-neutral-600">
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Empresa</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.company?.name ?? "Sem empresa"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Responsável</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.assignee?.name ?? "Sem responsável"}
</dd>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="font-medium text-neutral-500">Solicitante</dt>
<dd className="truncate text-right text-neutral-700">
{ticket.requester?.name ?? ticket.requester?.email ?? "—"}
</dd>
</div>
</dl>
{ticket.tags.length > 0 ? (
<div className="mt-4 flex flex-wrap items-center gap-2">
{ticket.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-600"
>
{tag}
</span>
))}
{ticket.tags.length > 3 ? (
<span className="text-[11px] font-semibold text-neutral-400">
+{ticket.tags.length - 3}
</span>
) : null}
</div>
) : null}
</Link>
))}
</div>
)
}

View file

@ -10,8 +10,11 @@ import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { TicketsBoard } from "@/components/tickets/tickets-board"
import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react"
type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState>
@ -26,10 +29,31 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
[initialFilters]
)
const [filters, setFilters] = useState<TicketFiltersState>(mergedInitialFilters)
const [viewMode, setViewMode] = useState<"table" | "board">("table")
useEffect(() => {
setFilters(mergedInitialFilters)
}, [mergedInitialFilters])
useEffect(() => {
try {
const stored = localStorage.getItem("tickets:view-mode")
if (stored === "table" || stored === "board") {
setViewMode(stored)
}
} catch {
// ignore
}
}, [])
useEffect(() => {
try {
localStorage.setItem("tickets:view-mode", viewMode)
} catch {
// ignore
}
}, [viewMode])
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -145,21 +169,42 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
initialState={mergedInitialFilters}
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleSaveDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(next) => {
if (!next) return
setViewMode(next as "table" | "board")
}}
variant="outline"
className="inline-flex rounded-md border border-border/60 bg-muted/30"
>
Salvar filtro como padrão
</button>
<button
type="button"
onClick={handleClearDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
>
Limpar padrão
</button>
<ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[96px] justify-center gap-2">
<List className="size-4" />
<span>Tabela</span>
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[96px] justify-center gap-2">
<LayoutGrid className="size-4" />
<span>Quadro</span>
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleSaveDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
>
Salvar filtro como padrão
</button>
<button
type="button"
onClick={handleClearDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
>
Limpar padrão
</button>
</div>
</div>
{ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
@ -172,6 +217,8 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
))}
</div>
</div>
) : viewMode === "board" ? (
<TicketsBoard tickets={filteredTickets} />
) : (
<TicketsTable tickets={filteredTickets} />
)}