This commit is contained in:
2025-11-06 19:17:50 -03:00
parent 14f8b65df5
commit 58e2bf0c32
15 changed files with 559 additions and 382 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,3 @@
export { DataTableColumnHeader } from './column-header'
export { DataTable } from './data-table'
export { DataTableViewOptions } from './view-options'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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'
}

View File

@@ -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)
}
}
}

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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
View File

@@ -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",

View File

@@ -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,