update
This commit is contained in:
@@ -1,270 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type Table as TTable,
|
||||
type VisibilityState
|
||||
} from '@tanstack/react-table'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Columns2Icon
|
||||
} from 'lucide-react'
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import { Card, CardContent } from '@repo/ui/components/ui/card'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { Label } from '@repo/ui/components/ui/label'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem
|
||||
} from '@repo/ui/components/ui/pagination'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@repo/ui/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@repo/ui/components/ui/table'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
children?: ReactNode
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
rowCount: number
|
||||
hiddenColumn?: string[]
|
||||
}
|
||||
|
||||
const TableContext = createContext<{ table: TTable<any> } | null>(null)
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
children,
|
||||
columns,
|
||||
data,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
rowCount,
|
||||
hiddenColumn = []
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [, setSearchParams] = useSearchParams()
|
||||
const hiddenColumn_ = Object.fromEntries(
|
||||
hiddenColumn.map((column) => [column, false])
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useState<VisibilityState>(hiddenColumn_)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
pagination: {
|
||||
pageIndex,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: (updater) => {
|
||||
const newState =
|
||||
typeof updater === 'function'
|
||||
? updater({ pageIndex, pageSize })
|
||||
: updater
|
||||
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set('p', newState?.pageIndex.toString())
|
||||
searchParams.set('perPage', newState?.pageSize.toString())
|
||||
return searchParams
|
||||
})
|
||||
},
|
||||
manualPagination: true,
|
||||
rowCount
|
||||
})
|
||||
|
||||
return (
|
||||
<TableContext value={{ table }}>
|
||||
<div className="space-y-2.5 max-md:mb-2">
|
||||
<Card className="relative w-full overflow-auto">
|
||||
<CardContent>
|
||||
{children}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="hover:bg-transparent"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="p-4">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="p-4">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
Nenhum resultado.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-end gap-5">
|
||||
<div className="hidden items-center gap-2.5 lg:flex">
|
||||
<Label htmlFor="rows-per-page" className="text-sm font-medium">
|
||||
Itens por página
|
||||
</Label>
|
||||
<Select
|
||||
value={String(table.getState().pagination.pageSize)}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-20">
|
||||
<SelectValue
|
||||
placeholder={String(table.getState().pagination.pageSize)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[12, 25, 50, 100].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={String(pageSize)}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<Pagination>
|
||||
<PaginationContent className="gap-2">
|
||||
<PaginationItem>
|
||||
{(pageIndex + 1) * pageSize - pageSize + 1}-
|
||||
{Math.min((pageIndex + 1) * pageSize, rowCount)}
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomizeColumns({ className }: { className?: string }) {
|
||||
const { table } = useContext(TableContext)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn('cursor-pointer', className)}>
|
||||
<Columns2Icon />
|
||||
<span className="hidden lg:inline">Exibir colunas</span>
|
||||
<span className="lg:hidden">Colunas</span>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 *:cursor-pointer">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.columnDef.header}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="data-[state=open]:bg-accent -ml-3 h-8"
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDown />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUp />
|
||||
) : (
|
||||
<ChevronsUpDown />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUp />
|
||||
Cres.
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDown />
|
||||
Decr.
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeOff />
|
||||
Ocultar
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnSort,
|
||||
type SortingState,
|
||||
type Table,
|
||||
type VisibilityState
|
||||
} from '@tanstack/react-table'
|
||||
import { createContext, useState, type ReactNode } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import { Card, CardContent } from '@repo/ui/components/ui/card'
|
||||
import {
|
||||
Table as Table_,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@repo/ui/components/ui/table'
|
||||
import { DataTablePagination } from './pagination'
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
children?: ReactNode
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
pageIndex: number
|
||||
sort: SortingState
|
||||
pageSize: number
|
||||
rowCount: number
|
||||
hiddenColumn?: string[]
|
||||
}
|
||||
|
||||
export const TableContext = createContext<{ table: Table<any> } | null>(null)
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
children,
|
||||
columns,
|
||||
data,
|
||||
sort,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
rowCount,
|
||||
hiddenColumn = []
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const hiddenColumn_ = Object.fromEntries(
|
||||
hiddenColumn.map((column) => [column, false])
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
useState<VisibilityState>(hiddenColumn_)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const sortParam = searchParams.get('sort')
|
||||
const sorting = sortParam
|
||||
? sortParam.split(',').map((s) => {
|
||||
const [id, dir] = s.split(':')
|
||||
return { id, desc: dir === 'desc' }
|
||||
})
|
||||
: sort
|
||||
|
||||
const setPagination = (updater: any) => {
|
||||
const newState =
|
||||
typeof updater === 'function' ? updater({ pageIndex, pageSize }) : updater
|
||||
|
||||
setSearchParams((searchParams) => {
|
||||
searchParams.set('p', newState?.pageIndex.toString())
|
||||
searchParams.set('perPage', newState?.pageSize.toString())
|
||||
return searchParams
|
||||
})
|
||||
}
|
||||
|
||||
const setSorting = (updater: any) => {
|
||||
const newSorting =
|
||||
typeof updater === 'function' ? updater(sorting) : updater
|
||||
|
||||
setSearchParams((searchParams) => {
|
||||
if (newSorting.length) {
|
||||
const sort = newSorting
|
||||
.map((s: ColumnSort) => `${s.id}:${s.desc ? 'desc' : 'asc'}`)
|
||||
.join(',')
|
||||
searchParams.set('sort', sort)
|
||||
}
|
||||
return searchParams
|
||||
})
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
rowCount,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
pagination: {
|
||||
pageIndex,
|
||||
pageSize
|
||||
}
|
||||
},
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
enableRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination
|
||||
})
|
||||
|
||||
return (
|
||||
<TableContext value={{ table }}>
|
||||
<div className="space-y-2.5 max-md:mb-2">
|
||||
<Card className="relative w-full overflow-auto">
|
||||
<CardContent>
|
||||
{children}
|
||||
|
||||
<Table_>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
className="hover:bg-transparent"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="p-2.5">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="p-2.5">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
Nenhum resultado.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table_>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
</TableContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTable } from './data-table'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@repo/ui/components/ui/select'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { pageIndex, pageSize } = table.getState().pagination
|
||||
const rowCount = table.getRowCount()
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredSelectedRowModel().rows.length} de{' '}
|
||||
{table.getFilteredRowModel().rows.length} linha(s) selecionada(s).
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 lg:gap-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium hidden lg:block">
|
||||
Itens por página
|
||||
</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[12, 25, 50, 100].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">
|
||||
{(pageIndex + 1) * pageSize - pageSize + 1}-
|
||||
{Math.min((pageIndex + 1) * pageSize, rowCount)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 *:cursor-pointer">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para a página anterior</span>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Ir para a próxima página</span>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Columns2Icon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
import { TableContext } from './data-table'
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
className
|
||||
}: {
|
||||
className: string
|
||||
}) {
|
||||
const ctx = useContext(TableContext) as { table: Table<TData> } | null
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('TableContext is null')
|
||||
}
|
||||
|
||||
const { table } = ctx
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn('cursor-pointer', className)}>
|
||||
<Columns2Icon /> Colunas
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44 *:cursor-pointer">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.columnDef?.meta?.title ?? column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type RangeCalendarFilterProps = {
|
||||
|
||||
export function RangeCalendarFilter({
|
||||
children,
|
||||
value,
|
||||
value = undefined,
|
||||
className,
|
||||
onChange
|
||||
}: RangeCalendarFilterProps) {
|
||||
@@ -85,8 +85,8 @@ export function RangeCalendarFilter({
|
||||
return setDateRange(undefined)
|
||||
}
|
||||
|
||||
if (dateRange.to?.getTime() === dateRange.from?.getTime()) {
|
||||
const nextDay = new Date(String(dateRange?.from))
|
||||
if (dateRange.from?.getTime() === dateRange.to?.getTime()) {
|
||||
const nextDay = new Date(String(dateRange.from))
|
||||
nextDay.setDate(nextDay.getDate() + 6)
|
||||
dateRange.to = nextDay
|
||||
}
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { CellContext, ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
CircleOffIcon,
|
||||
CircleXIcon,
|
||||
HelpCircleIcon,
|
||||
TimerIcon,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import { HelpCircleIcon } from 'lucide-react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
|
||||
import { Badge } from '@repo/ui/components/ui/badge'
|
||||
@@ -17,6 +9,9 @@ import { Checkbox } from '@repo/ui/components/ui/checkbox'
|
||||
import { Progress } from '@repo/ui/components/ui/progress'
|
||||
import { cn, initials } from '@repo/ui/lib/utils'
|
||||
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { labels, statuses } from './data'
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
type Course = {
|
||||
@@ -41,48 +36,6 @@ const formatted = new Intl.DateTimeFormat('pt-BR', {
|
||||
minute: '2-digit'
|
||||
})
|
||||
|
||||
export const statuses: Record<
|
||||
string,
|
||||
{ icon: LucideIcon; color?: string; label: string }
|
||||
> = {
|
||||
PENDING: {
|
||||
icon: CircleIcon,
|
||||
label: 'Não iniciado'
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
icon: TimerIcon,
|
||||
color: 'text-blue-400 [&_svg]:text-blue-500',
|
||||
label: 'Em andamento'
|
||||
},
|
||||
COMPLETED: {
|
||||
icon: CircleCheckIcon,
|
||||
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
|
||||
label: 'Aprovado'
|
||||
},
|
||||
FAILED: {
|
||||
icon: CircleXIcon,
|
||||
color: 'text-red-400 [&_svg]:text-red-500',
|
||||
label: 'Reprovado'
|
||||
},
|
||||
CANCELED: {
|
||||
icon: CircleOffIcon,
|
||||
color: 'text-orange-400 [&_svg]:text-orange-500',
|
||||
label: 'Cancelado'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultIcon = {
|
||||
icon: HelpCircleIcon
|
||||
}
|
||||
|
||||
const statusTranslate: Record<string, string> = {
|
||||
PENDING: 'Não iniciado',
|
||||
IN_PROGRESS: 'Em andamento',
|
||||
COMPLETED: 'Aprovado',
|
||||
FAILED: 'Reprovado',
|
||||
CANCELED: 'Cancelado'
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Enrollment>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
@@ -105,10 +58,11 @@ export const columns: ColumnDef<Enrollment>[] = [
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: 'user',
|
||||
header: 'Colaborador',
|
||||
enableHiding: false,
|
||||
cell: ({ row: { original } }) => {
|
||||
const { user } = original
|
||||
cell: ({ row }) => {
|
||||
const user = row.getValue('user') as { name: string; email: string }
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 items-center">
|
||||
@@ -127,22 +81,27 @@ export const columns: ColumnDef<Enrollment>[] = [
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'course.name',
|
||||
accessorKey: 'course',
|
||||
header: 'Curso',
|
||||
enableHiding: false,
|
||||
cell: ({ row: { original } }) => (
|
||||
<abbr className="truncate max-w-62 block" title={original.course.name}>
|
||||
{original.course.name}
|
||||
</abbr>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const { name } = row.getValue('course') as { name: string }
|
||||
|
||||
return (
|
||||
<abbr className="truncate max-w-62 block" title={name}>
|
||||
{name}
|
||||
</abbr>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
enableHiding: false,
|
||||
cell: ({ row: { original } }) => {
|
||||
const status = statusTranslate[original.status] ?? original.status
|
||||
const { icon: Icon, color } = statuses?.[original.status] ?? defaultIcon
|
||||
cell: ({ row }) => {
|
||||
const s = row.getValue('status') as string
|
||||
const status = labels[s] ?? s
|
||||
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(color, ' px-1.5')}>
|
||||
@@ -156,44 +115,63 @@ export const columns: ColumnDef<Enrollment>[] = [
|
||||
accessorKey: 'progress',
|
||||
header: 'Progresso',
|
||||
enableHiding: false,
|
||||
cell: ({ row: { original } }) => (
|
||||
<div className="flex gap-2.5 items-center ">
|
||||
<Progress value={Number(original.progress)} className="w-32" />
|
||||
<span className="text-xs">{original.progress}%</span>
|
||||
</div>
|
||||
)
|
||||
cell: ({ row }) => {
|
||||
const progress = row.getValue('progress')
|
||||
|
||||
return (
|
||||
<div className="flex gap-2.5 items-center ">
|
||||
<Progress value={Number(progress)} className="w-32" />
|
||||
<span className="text-xs">{String(progress)}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Matriculado em',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Matriculado em" />
|
||||
),
|
||||
meta: { title: 'Matriculado em' },
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
cell: cellDate
|
||||
},
|
||||
{
|
||||
accessorKey: 'started_at',
|
||||
header: 'Iniciado em',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Iniciado em" />
|
||||
),
|
||||
meta: { title: 'Iniciado em' },
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
cell: cellDate
|
||||
},
|
||||
{
|
||||
accessorKey: 'completed_at',
|
||||
header: 'Aprovado em',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Aprovado em" />
|
||||
),
|
||||
meta: { title: 'Aprovado em' },
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
cell: cellDate
|
||||
},
|
||||
{
|
||||
accessorKey: 'failed_at',
|
||||
header: 'Reprovado em',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Reprovado em" />
|
||||
),
|
||||
meta: { title: 'Reprovado em' },
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
cell: cellDate
|
||||
},
|
||||
{
|
||||
accessorKey: 'canceled_at',
|
||||
header: 'Cancelado em',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Cancelado em" />
|
||||
),
|
||||
meta: { title: 'Cancelado em' },
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
cell: cellDate
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
CircleIcon,
|
||||
CircleOffIcon,
|
||||
CircleXIcon,
|
||||
TimerIcon,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
|
||||
export const statuses: Record<
|
||||
string,
|
||||
{ icon: LucideIcon; color?: string; label: string }
|
||||
> = {
|
||||
PENDING: {
|
||||
icon: CircleIcon,
|
||||
label: 'Não iniciado'
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
icon: TimerIcon,
|
||||
color: 'text-blue-400 [&_svg]:text-blue-500',
|
||||
label: 'Em andamento'
|
||||
},
|
||||
COMPLETED: {
|
||||
icon: CircleCheckIcon,
|
||||
color: 'text-green-400 [&_svg]:text-background [&_svg]:fill-green-500',
|
||||
label: 'Aprovado'
|
||||
},
|
||||
FAILED: {
|
||||
icon: CircleXIcon,
|
||||
color: 'text-red-400 [&_svg]:text-red-500',
|
||||
label: 'Reprovado'
|
||||
},
|
||||
CANCELED: {
|
||||
icon: CircleOffIcon,
|
||||
color: 'text-orange-400 [&_svg]:text-orange-500',
|
||||
label: 'Cancelado'
|
||||
}
|
||||
}
|
||||
|
||||
export const labels: Record<string, string> = {
|
||||
PENDING: 'Não iniciado',
|
||||
IN_PROGRESS: 'Em andamento',
|
||||
COMPLETED: 'Aprovado',
|
||||
FAILED: 'Reprovado',
|
||||
CANCELED: 'Cancelado'
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import { PlusCircleIcon, PlusIcon } from 'lucide-react'
|
||||
import { DateTime } from 'luxon'
|
||||
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { Await, Link, useSearchParams } from 'react-router'
|
||||
|
||||
import { CustomizeColumns, DataTable } from '@/components/data-table'
|
||||
import { DataTable, DataTableViewOptions } from '@/components/data-table'
|
||||
import { RangeCalendarFilter } from '@/components/range-calendar-filter'
|
||||
import { createSearch } from '@/lib/meili'
|
||||
|
||||
@@ -23,23 +22,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@repo/ui/components/ui/select'
|
||||
import { columns, statuses, type Enrollment } from './columns'
|
||||
import { columns, type Enrollment } from './columns'
|
||||
import { statuses } from './data'
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Matrículas' }]
|
||||
}
|
||||
|
||||
const dtOptions = {
|
||||
zone: 'America/Sao_Paulo'
|
||||
}
|
||||
|
||||
export async function loader({ params, context, request }: Route.LoaderArgs) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const { orgid } = params
|
||||
const query = searchParams.get('q') || ''
|
||||
const field = searchParams.get('field') || ''
|
||||
const from = searchParams.get('from') || ''
|
||||
const to = searchParams.get('to') || ''
|
||||
const sort = searchParams.get('sort') || 'created_at:desc'
|
||||
const status = searchParams.getAll('status') || []
|
||||
const page = Number(searchParams.get('p')) + 1
|
||||
const hitsPerPage = Number(searchParams.get('perPage')) || 25
|
||||
@@ -50,15 +46,16 @@ export async function loader({ params, context, request }: Route.LoaderArgs) {
|
||||
builder = builder.where('status', 'in', status)
|
||||
}
|
||||
|
||||
if (field && from && to) {
|
||||
builder = builder.where(field, 'between', [from, to])
|
||||
if (from && to) {
|
||||
const [field, from_] = from.split(':')
|
||||
builder = builder.where(field, 'between', [from_, to])
|
||||
}
|
||||
|
||||
return {
|
||||
data: createSearch({
|
||||
index: 'betaeducacao-prod-enrollments',
|
||||
sort: ['created_at:desc'],
|
||||
filter: builder.build(),
|
||||
sort: [sort],
|
||||
query,
|
||||
page,
|
||||
hitsPerPage,
|
||||
@@ -75,10 +72,8 @@ const formatted = new Intl.DateTimeFormat('en-CA', {
|
||||
|
||||
export default function Route({ loaderData: { data } }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [from, to] = [searchParams.get('from'), searchParams.get('to')]
|
||||
const [rangeField, setRangeField] = useState<string>(
|
||||
searchParams.get('field') || 'created_at'
|
||||
)
|
||||
// const [rangeField, setRangeField] = useState<string>('created_at')
|
||||
const { rangeField, dateRange } = useRangeParams('created_at')
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Skeleton />}>
|
||||
@@ -95,6 +90,7 @@ export default function Route({ loaderData: { data } }) {
|
||||
{({ hits, page, hitsPerPage, totalHits }) => {
|
||||
return (
|
||||
<DataTable
|
||||
sort={[{ id: 'created_at', desc: true }]}
|
||||
columns={columns}
|
||||
data={hits as Enrollment[]}
|
||||
pageIndex={page - 1}
|
||||
@@ -158,21 +154,13 @@ export default function Route({ loaderData: { data } }) {
|
||||
|
||||
<RangeCalendarFilter
|
||||
className="lg:flex-1"
|
||||
value={
|
||||
from && to
|
||||
? {
|
||||
from: DateTime.fromISO(from, dtOptions),
|
||||
to: DateTime.fromISO(to, dtOptions)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
value={dateRange}
|
||||
onChange={(dateRange) => {
|
||||
setSearchParams((searchParams) => {
|
||||
if (dateRange) {
|
||||
searchParams.set('field', rangeField)
|
||||
searchParams.set(
|
||||
'from',
|
||||
formatted.format(dateRange.from)
|
||||
`${rangeField}:${formatted.format(dateRange.from)}`
|
||||
)
|
||||
searchParams.set(
|
||||
'to',
|
||||
@@ -181,7 +169,6 @@ export default function Route({ loaderData: { data } }) {
|
||||
} else {
|
||||
searchParams.delete('from')
|
||||
searchParams.delete('to')
|
||||
searchParams.delete('field')
|
||||
}
|
||||
|
||||
return searchParams
|
||||
@@ -191,11 +178,13 @@ export default function Route({ loaderData: { data } }) {
|
||||
<div className="p-2.5">
|
||||
<Select
|
||||
defaultValue={rangeField}
|
||||
onValueChange={(field) => {
|
||||
setRangeField(field)
|
||||
onValueChange={(value) => {
|
||||
setSearchParams((searchParams) => {
|
||||
if (searchParams.has('from')) {
|
||||
searchParams.set('field', field)
|
||||
searchParams.set(
|
||||
'from',
|
||||
`${value}:${dateRange?.from?.getTime()}`
|
||||
)
|
||||
}
|
||||
|
||||
return searchParams
|
||||
@@ -230,7 +219,7 @@ export default function Route({ loaderData: { data } }) {
|
||||
</div>
|
||||
|
||||
<div className="lg:ml-auto flex gap-2.5">
|
||||
<CustomizeColumns className="flex-1" />
|
||||
<DataTableViewOptions className="flex-1" />
|
||||
|
||||
<Button className="flex-1" asChild>
|
||||
<Link to="add">
|
||||
@@ -247,3 +236,22 @@ export default function Route({ loaderData: { data } }) {
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function useRangeParams(initRangeField: string) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [from, to] = [searchParams.get('from'), searchParams.get('to')]
|
||||
|
||||
if (!from || !to) {
|
||||
return { rangeField: initRangeField, dateRange: undefined }
|
||||
}
|
||||
|
||||
const [rangeField, from_] = from.split(':')
|
||||
|
||||
return {
|
||||
rangeField,
|
||||
dateRange: {
|
||||
from: new Date(from_),
|
||||
to: new Date(to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"cookie": "^1.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"isbot": "^5.1.31",
|
||||
@@ -27,6 +28,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.9.5",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -116,10 +116,10 @@ export default function Component({
|
||||
<div className="flex gap-2.5">
|
||||
<div className="w-full xl:w-93">
|
||||
<SearchForm
|
||||
defaultValue={searchParams.get('term') || ''}
|
||||
defaultValue={term || ''}
|
||||
placeholder={
|
||||
<>
|
||||
Pressione <Kbd>/</Kbd> para filtrar...
|
||||
Digite <Kbd>/</Kbd> para pesquisar
|
||||
</>
|
||||
}
|
||||
onChange={(value) =>
|
||||
@@ -159,7 +159,7 @@ export default function Component({
|
||||
<div className="grid lg:grid-cols-4 gap-5">
|
||||
<Await resolve={data}>
|
||||
{({ hits = [] }) => {
|
||||
return <List term={term} hits={hits} />
|
||||
return <List term={term} hits={hits as Enrollment[]} />
|
||||
}}
|
||||
</Await>
|
||||
</div>
|
||||
|
||||
@@ -14,4 +14,4 @@ OAUTH2_SCOPES_SUPPORTED: list[str] = [
|
||||
'apps:insights',
|
||||
]
|
||||
|
||||
SESSION_EXPIRES_IN = 86400 * 30 # 30 days
|
||||
SESSION_EXPIRES_IN = 86_400 * 30 # 30 days
|
||||
|
||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"cookie": "^1.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"fuse.js": "^7.1.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"isbot": "^5.1.31",
|
||||
@@ -40,6 +41,7 @@
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router": "^7.9.5",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -5332,6 +5334,12 @@
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -8077,6 +8085,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -3,13 +3,13 @@ import { debounce } from 'lodash'
|
||||
import { SearchIcon, XIcon } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput
|
||||
} from '@repo/ui/components/ui/input-group'
|
||||
import { cn } from '@repo/ui/lib/utils'
|
||||
} from './ui/input-group'
|
||||
|
||||
export function SearchForm({
|
||||
placeholder,
|
||||
|
||||
Reference in New Issue
Block a user