feat: improve ticket navigation and filters
This commit is contained in:
parent
ff41a8bd4e
commit
f5898153fe
7 changed files with 199 additions and 4 deletions
|
|
@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"
|
|||
interface SiteHeaderProps {
|
||||
title: string
|
||||
lead?: string
|
||||
breadcrumbs?: ReactNode
|
||||
primaryAction?: ReactNode
|
||||
secondaryAction?: ReactNode
|
||||
primaryAlignment?: "right" | "center"
|
||||
|
|
@ -18,6 +19,7 @@ interface SiteHeaderProps {
|
|||
function SiteHeaderBase({
|
||||
title,
|
||||
lead,
|
||||
breadcrumbs,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
primaryAlignment = "right",
|
||||
|
|
@ -32,6 +34,7 @@ function SiteHeaderBase({
|
|||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mx-3 hidden h-6 sm:block" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{breadcrumbs ? <div className="text-xs text-muted-foreground">{breadcrumbs}</div> : null}
|
||||
{lead ? <span className="text-sm text-muted-foreground">{lead}</span> : null}
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
</div>
|
||||
|
|
|
|||
32
src/components/tickets/ticket-breadcrumbs.tsx
Normal file
32
src/components/tickets/ticket-breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
type TicketBreadcrumbsProps = {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
export function TicketBreadcrumbs({ ticketId }: TicketBreadcrumbsProps) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/tickets">Tickets</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Ticket #{ticketId}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)
|
||||
}
|
||||
|
|
@ -70,12 +70,20 @@ interface TicketsFiltersProps {
|
|||
queues?: QueueOption[]
|
||||
companies?: string[]
|
||||
assignees?: Array<{ id: string; name: string }>
|
||||
categories?: Array<{ id: string; name: string }>
|
||||
initialState?: Partial<TicketFiltersState>
|
||||
}
|
||||
|
||||
const ALL_VALUE = "ALL"
|
||||
|
||||
export function TicketsFilters({ onChange, queues = [], companies = [], assignees = [], initialState }: TicketsFiltersProps) {
|
||||
export function TicketsFilters({
|
||||
onChange,
|
||||
queues = [],
|
||||
companies = [],
|
||||
assignees = [],
|
||||
categories = [],
|
||||
initialState,
|
||||
}: TicketsFiltersProps) {
|
||||
const mergedDefaults = useMemo(
|
||||
() => ({
|
||||
...defaultTicketFilters,
|
||||
|
|
@ -109,10 +117,16 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
|
|||
const found = assignees.find((a) => a.id === filters.assigneeId)
|
||||
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
|
||||
}
|
||||
if (filters.categoryId) {
|
||||
const category = categories.find((category) => category.id === filters.categoryId)
|
||||
chips.push(`Categoria: ${category?.name ?? filters.categoryId}`)
|
||||
}
|
||||
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
|
||||
if (filters.focusVisits) chips.push("Somente visitas/lab")
|
||||
if (filters.dateFrom || filters.dateTo) chips.push(formatDateRangeChip(filters.dateFrom, filters.dateTo))
|
||||
if (filters.sort === "oldest") chips.push("Ordenados por mais antigos")
|
||||
return chips
|
||||
}, [filters, assignees])
|
||||
}, [filters, assignees, categories])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -156,6 +170,22 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.categoryId ?? ALL_VALUE}
|
||||
onValueChange={(value) => setPartial({ categoryId: value === ALL_VALUE ? null : value })}
|
||||
>
|
||||
<SelectTrigger className="md:w-[220px]">
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>Todas as categorias</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
|
|
@ -186,6 +216,18 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
|
|||
<SelectItem value="completed">Concluídos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.sort}
|
||||
onValueChange={(value) => setPartial({ sort: value as TicketFiltersState["sort"] })}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Ordenar" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="recent">Mais recentes</SelectItem>
|
||||
<SelectItem value="oldest">Mais antigos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -263,6 +305,23 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase text-neutral-500">
|
||||
Período
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onChange={(event) => setPartial({ dateFrom: event.target.value || null })}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onChange={(event) => setPartial({ dateTo: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
|
|
@ -288,3 +347,32 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateRangeChip(from: string | null, to: string | null) {
|
||||
const fromLabel = formatDateValue(from)
|
||||
const toLabel = formatDateValue(to)
|
||||
|
||||
if (fromLabel && toLabel) {
|
||||
return `Período: ${fromLabel} até ${toLabel}`
|
||||
}
|
||||
if (fromLabel) {
|
||||
return `A partir de ${fromLabel}`
|
||||
}
|
||||
if (toLabel) {
|
||||
return `Até ${toLabel}`
|
||||
}
|
||||
return "Período personalizado"
|
||||
}
|
||||
|
||||
function formatDateValue(value: string | null) {
|
||||
if (!value) return null
|
||||
const [year, month, day] = value.split("-").map((part) => Number(part))
|
||||
if (!year || !month || !day) {
|
||||
return value
|
||||
}
|
||||
try {
|
||||
return new Date(year, month - 1, day).toLocaleDateString("pt-BR")
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useDefaultQueues } from "@/hooks/use-default-queues"
|
|||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { LayoutGrid, List } from "lucide-react"
|
||||
import { isVisitTicket } from "@/lib/ticket-matchers"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
|
||||
type TicketsViewProps = {
|
||||
initialFilters?: Partial<TicketFiltersState>
|
||||
|
|
@ -64,6 +65,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
}, [viewMode, viewModeStorageKey])
|
||||
|
||||
useDefaultQueues(tenantId)
|
||||
const { categories: ticketCategories } = useTicketCategories(tenantId)
|
||||
|
||||
const queuesEnabled = Boolean(isStaff && convexUserId)
|
||||
const queuesResult = useQuery(
|
||||
|
|
@ -167,9 +169,36 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
if (filters.focusVisits) {
|
||||
working = working.filter((t) => isVisitTicket(t))
|
||||
}
|
||||
if (filters.categoryId) {
|
||||
working = working.filter((t) => (t.category?.id ?? null) === filters.categoryId)
|
||||
}
|
||||
const fromDate = parseDateInput(filters.dateFrom)
|
||||
if (fromDate) {
|
||||
working = working.filter((t) => getTicketDate(t) >= fromDate.getTime())
|
||||
}
|
||||
const toDate = parseDateInput(filters.dateTo, { endOfDay: true })
|
||||
if (toDate) {
|
||||
working = working.filter((t) => getTicketDate(t) <= toDate.getTime())
|
||||
}
|
||||
|
||||
return working
|
||||
}, [tickets, filters.queue, filters.status, filters.view, filters.company, filters.focusVisits])
|
||||
const sorted = [...working].sort((a, b) => {
|
||||
const diff = getTicketDate(a) - getTicketDate(b)
|
||||
return filters.sort === "oldest" ? diff : -diff
|
||||
})
|
||||
|
||||
return sorted
|
||||
}, [
|
||||
tickets,
|
||||
filters.queue,
|
||||
filters.status,
|
||||
filters.view,
|
||||
filters.company,
|
||||
filters.focusVisits,
|
||||
filters.categoryId,
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.sort,
|
||||
])
|
||||
|
||||
const previousIdsRef = useRef<string[]>([])
|
||||
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
|
||||
|
|
@ -199,6 +228,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
queues={queues.map((q) => q.name)}
|
||||
companies={companies}
|
||||
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
|
||||
categories={ticketCategories.map((category) => ({ id: category.id, name: category.name }))}
|
||||
initialState={mergedInitialFilters}
|
||||
/>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
|
|
@ -257,3 +287,25 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseDateInput(value: string | null, options?: { endOfDay?: boolean }) {
|
||||
if (!value) return null
|
||||
const [year, month, day] = value.split("-").map((part) => Number(part))
|
||||
if (!year || !month || !day) return null
|
||||
const date = new Date(
|
||||
year,
|
||||
month - 1,
|
||||
day,
|
||||
options?.endOfDay ? 23 : 0,
|
||||
options?.endOfDay ? 59 : 0,
|
||||
options?.endOfDay ? 59 : 0,
|
||||
options?.endOfDay ? 999 : 0
|
||||
)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date
|
||||
}
|
||||
|
||||
function getTicketDate(ticket: Ticket) {
|
||||
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
|
||||
return createdAt.getTime()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue