feat: paginate ticket timeline
This commit is contained in:
parent
96a6f73e30
commit
50f6796ffa
2 changed files with 228 additions and 5 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { useEffect, useMemo, useState, type ReactNode, type ComponentType } from "react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import type { ComponentType, ReactNode } from "react"
|
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import {
|
import {
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
|
|
@ -16,6 +16,15 @@ import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination"
|
||||||
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
||||||
|
|
||||||
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
|
|
@ -44,6 +53,8 @@ interface TicketTimelineProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
const formatDuration = (durationMs: number) => {
|
const formatDuration = (durationMs: number) => {
|
||||||
if (!durationMs || durationMs <= 0) return "0s"
|
if (!durationMs || durationMs <= 0) return "0s"
|
||||||
|
|
@ -60,15 +71,122 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
return `${seconds}s`
|
return `${seconds}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const totalItems = ticket.timeline.length
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE))
|
||||||
|
const currentPage = Math.min(page, totalPages)
|
||||||
|
const pageOffset = (currentPage - 1) * ITEMS_PER_PAGE
|
||||||
|
|
||||||
|
const currentEvents = useMemo(
|
||||||
|
() => ticket.timeline.slice(pageOffset, pageOffset + ITEMS_PER_PAGE),
|
||||||
|
[pageOffset, ticket.timeline]
|
||||||
|
)
|
||||||
|
|
||||||
|
const paginationRange = useMemo(() => {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||||
|
}
|
||||||
|
const range: Array<number | "ellipsis-left" | "ellipsis-right"> = [1]
|
||||||
|
const left = Math.max(2, currentPage - 1)
|
||||||
|
const right = Math.min(totalPages - 1, currentPage + 1)
|
||||||
|
if (left > 2) {
|
||||||
|
range.push("ellipsis-left")
|
||||||
|
}
|
||||||
|
for (let i = left; i <= right; i += 1) {
|
||||||
|
range.push(i)
|
||||||
|
}
|
||||||
|
if (right < totalPages - 1) {
|
||||||
|
range.push("ellipsis-right")
|
||||||
|
}
|
||||||
|
range.push(totalPages)
|
||||||
|
return range
|
||||||
|
}, [currentPage, totalPages])
|
||||||
|
|
||||||
|
const rangeStart = totalItems === 0 ? 0 : pageOffset + 1
|
||||||
|
const rangeEnd = totalItems === 0 ? 0 : Math.min(pageOffset + ITEMS_PER_PAGE, totalItems)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1)
|
||||||
|
}, [ticket.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages)
|
||||||
|
}
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
if (totalItems === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<CardContent className="px-4 py-10">
|
||||||
|
<p className="text-center text-sm text-neutral-500">
|
||||||
|
Nenhum evento registrado neste ticket ainda.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardContent className="space-y-5 px-4 pb-6">
|
<CardContent className="space-y-6 px-4 pb-6">
|
||||||
{ticket.timeline.map((entry, index) => {
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 pb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900">Linha do tempo</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Mostrando {rangeStart}-{rangeEnd} de {totalItems} eventos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 ? (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setPage((previous) => Math.max(1, previous - 1))}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{paginationRange.map((item, index) => {
|
||||||
|
if (typeof item === "number") {
|
||||||
|
return (
|
||||||
|
<PaginationItem key={`page-${item}`}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={item === currentPage}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setPage(item)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PaginationItem key={`ellipsis-${item}-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => setPage((previous) => Math.min(totalPages, previous + 1))}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentEvents.map((entry, index) => {
|
||||||
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
||||||
const isLast = index === ticket.timeline.length - 1
|
const isLastGlobal = pageOffset + index === totalItems - 1
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} className="relative pl-11">
|
<div key={entry.id} className="relative pl-11">
|
||||||
{!isLast && (
|
{!isLastGlobal && (
|
||||||
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
|
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
|
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
|
||||||
|
|
|
||||||
105
src/components/ui/pagination.tsx
Normal file
105
src/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type PaginationProps = React.ComponentProps<"nav">
|
||||||
|
|
||||||
|
export function Pagination({ className, ...props }: PaginationProps) {
|
||||||
|
return <nav role="navigation" aria-label="Paginação" className={cn("mx-auto flex justify-center", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return <ul className={cn("flex items-center gap-1", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li className={cn("list-none", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkBaseProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
href?: string
|
||||||
|
disabled?: boolean
|
||||||
|
} & React.ComponentProps<typeof Link>
|
||||||
|
|
||||||
|
export function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
disabled,
|
||||||
|
href = "#",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkBaseProps) {
|
||||||
|
const Component = disabled ? "span" : Link
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-[36px] items-center justify-center rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
isActive && "border-slate-900 bg-slate-900 text-white hover:border-slate-900 hover:bg-slate-900",
|
||||||
|
disabled && "cursor-not-allowed text-neutral-400 hover:border-slate-200 hover:bg-white",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
href={href}
|
||||||
|
{...(Component === Link ? props : undefined)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationControlProps = {
|
||||||
|
"aria-label": string
|
||||||
|
disabled?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ControlButton({ children, disabled, onClick, ...props }: PaginationControlProps & React.ComponentProps<"button">) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex size-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-700 shadow-sm transition hover:border-slate-300 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:text-neutral-400"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationPrevious({
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: { disabled?: boolean; onClick?: () => void } & React.ComponentProps<"button">) {
|
||||||
|
return (
|
||||||
|
<ControlButton aria-label="Anterior" disabled={disabled} onClick={onClick} {...props}>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</ControlButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationNext({
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: { disabled?: boolean; onClick?: () => void } & React.ComponentProps<"button">) {
|
||||||
|
return (
|
||||||
|
<ControlButton aria-label="Próxima" disabled={disabled} onClick={onClick} {...props}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</ControlButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex size-9 items-center justify-center text-neutral-500", className)} {...props}>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">Mais páginas</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue