diff --git a/apps/admin.saladeaula.digital/app/components/data-table.tsx b/apps/admin.saladeaula.digital/app/components/data-table.tsx deleted file mode 100644 index 0a538c5..0000000 --- a/apps/admin.saladeaula.digital/app/components/data-table.tsx +++ /dev/null @@ -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 { - children?: ReactNode - columns: ColumnDef[] - data: TData[] - pageIndex: number - pageSize: number - rowCount: number - hiddenColumn?: string[] -} - -const TableContext = createContext<{ table: TTable } | null>(null) - -export function DataTable({ - children, - columns, - data, - pageIndex, - pageSize, - rowCount, - hiddenColumn = [] -}: DataTableProps) { - const [, setSearchParams] = useSearchParams() - const hiddenColumn_ = Object.fromEntries( - hiddenColumn.map((column) => [column, false]) - ) - const [columnVisibility, setColumnVisibility] = - useState(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 ( - -
- - - {children} - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ))} - - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - Nenhum resultado. - - - )} - -
-
-
- -
-
- - -
- -
- - - - {(pageIndex + 1) * pageSize - pageSize + 1}- - {Math.min((pageIndex + 1) * pageSize, rowCount)} - - - - - - - - - - - -
-
-
-
- ) -} - -export function CustomizeColumns({ className }: { className?: string }) { - const { table } = useContext(TableContext) - - return ( - - - - - - {table - .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== 'undefined' && column.getCanHide() - ) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.columnDef.header} - - ) - })} - - - ) -} diff --git a/apps/admin.saladeaula.digital/app/components/data-table/column-header.tsx b/apps/admin.saladeaula.digital/app/components/data-table/column-header.tsx new file mode 100644 index 0000000..bb79aeb --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/data-table/column-header.tsx @@ -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 + extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( +
+ + + + + + column.toggleSorting(false)}> + + Cres. + + column.toggleSorting(true)}> + + Decr. + + + column.toggleVisibility(false)}> + + Ocultar + + + +
+ ) +} diff --git a/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx new file mode 100644 index 0000000..f585702 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/data-table/data-table.tsx @@ -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 { + children?: ReactNode + columns: ColumnDef[] + data: TData[] + pageIndex: number + sort: SortingState + pageSize: number + rowCount: number + hiddenColumn?: string[] +} + +export const TableContext = createContext<{ table: Table } | null>(null) + +export function DataTable({ + children, + columns, + data, + sort, + pageIndex, + pageSize, + rowCount, + hiddenColumn = [] +}: DataTableProps) { + const [searchParams, setSearchParams] = useSearchParams() + const hiddenColumn_ = Object.fromEntries( + hiddenColumn.map((column) => [column, false]) + ) + const [columnVisibility, setColumnVisibility] = + useState(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 ( + +
+ + + {children} + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + Nenhum resultado. + + + )} + + + + + + +
+
+ ) +} diff --git a/apps/admin.saladeaula.digital/app/components/data-table/index.ts b/apps/admin.saladeaula.digital/app/components/data-table/index.ts new file mode 100644 index 0000000..b9cf952 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/data-table/index.ts @@ -0,0 +1,3 @@ +export { DataTableColumnHeader } from './column-header' +export { DataTable } from './data-table' +export { DataTableViewOptions } from './view-options' diff --git a/apps/admin.saladeaula.digital/app/components/data-table/pagination.tsx b/apps/admin.saladeaula.digital/app/components/data-table/pagination.tsx new file mode 100644 index 0000000..78c21fa --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/data-table/pagination.tsx @@ -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 { + table: Table +} + +export function DataTablePagination({ + table +}: DataTablePaginationProps) { + const { pageIndex, pageSize } = table.getState().pagination + const rowCount = table.getRowCount() + + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length} de{' '} + {table.getFilteredRowModel().rows.length} linha(s) selecionada(s). +
+ +
+
+

+ Itens por página +

+ +
+ +
+ {(pageIndex + 1) * pageSize - pageSize + 1}- + {Math.min((pageIndex + 1) * pageSize, rowCount)} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/admin.saladeaula.digital/app/components/data-table/view-options.tsx b/apps/admin.saladeaula.digital/app/components/data-table/view-options.tsx new file mode 100644 index 0000000..4b6d2d0 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/components/data-table/view-options.tsx @@ -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({ + className +}: { + className: string +}) { + const ctx = useContext(TableContext) as { table: Table } | null + + if (!ctx) { + throw new Error('TableContext is null') + } + + const { table } = ctx + + return ( + + + + + + {table + .getAllColumns() + .filter( + (column) => + typeof column.accessorFn !== 'undefined' && column.getCanHide() + ) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.columnDef?.meta?.title ?? column.id} + + ) + })} + + + ) +} diff --git a/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx b/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx index f3e28c0..d25221b 100644 --- a/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx +++ b/apps/admin.saladeaula.digital/app/components/range-calendar-filter.tsx @@ -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 } diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index 9cedb20..12d3608 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -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 = { - PENDING: 'Não iniciado', - IN_PROGRESS: 'Em andamento', - COMPLETED: 'Aprovado', - FAILED: 'Reprovado', - CANCELED: 'Cancelado' -} - export const columns: ColumnDef[] = [ { id: 'select', @@ -105,10 +58,11 @@ export const columns: ColumnDef[] = [ ) }, { + 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 (
@@ -127,22 +81,27 @@ export const columns: ColumnDef[] = [ } }, { - accessorKey: 'course.name', + accessorKey: 'course', header: 'Curso', enableHiding: false, - cell: ({ row: { original } }) => ( - - {original.course.name} - - ) + cell: ({ row }) => { + const { name } = row.getValue('course') as { name: string } + + return ( + + {name} + + ) + } }, { 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 ( @@ -156,44 +115,63 @@ export const columns: ColumnDef[] = [ accessorKey: 'progress', header: 'Progresso', enableHiding: false, - cell: ({ row: { original } }) => ( -
- - {original.progress}% -
- ) + cell: ({ row }) => { + const progress = row.getValue('progress') + + return ( +
+ + {String(progress)}% +
+ ) + } }, { accessorKey: 'created_at', - header: 'Matriculado em', + header: ({ column }) => ( + + ), + meta: { title: 'Matriculado em' }, enableSorting: true, enableHiding: true, cell: cellDate }, { accessorKey: 'started_at', - header: 'Iniciado em', + header: ({ column }) => ( + + ), + meta: { title: 'Iniciado em' }, enableSorting: true, enableHiding: true, cell: cellDate }, { accessorKey: 'completed_at', - header: 'Aprovado em', + header: ({ column }) => ( + + ), + meta: { title: 'Aprovado em' }, enableSorting: true, enableHiding: true, cell: cellDate }, { accessorKey: 'failed_at', - header: 'Reprovado em', + header: ({ column }) => ( + + ), + meta: { title: 'Reprovado em' }, enableSorting: true, enableHiding: true, cell: cellDate }, { accessorKey: 'canceled_at', - header: 'Cancelado em', + header: ({ column }) => ( + + ), + meta: { title: 'Cancelado em' }, enableSorting: true, enableHiding: true, cell: cellDate diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx new file mode 100644 index 0000000..ae66993 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/data.tsx @@ -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 = { + PENDING: 'Não iniciado', + IN_PROGRESS: 'Em andamento', + COMPLETED: 'Aprovado', + FAILED: 'Reprovado', + CANCELED: 'Cancelado' +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 3fe4e8a..9571d37 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -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( - searchParams.get('field') || 'created_at' - ) + // const [rangeField, setRangeField] = useState('created_at') + const { rangeField, dateRange } = useRangeParams('created_at') return ( }> @@ -95,6 +90,7 @@ export default function Route({ loaderData: { data } }) { {({ hits, page, hitsPerPage, totalHits }) => { return ( { 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 } }) {