update package

This commit is contained in:
2025-11-19 15:13:00 -03:00
parent a180e269f2
commit d37db405c8
51 changed files with 5870 additions and 2490 deletions

View File

@@ -68,6 +68,7 @@ Resources:
JwtConfiguration: JwtConfiguration:
issuer: 'https://id.saladeaula.digital' issuer: 'https://id.saladeaula.digital'
audience: audience:
- '6fd6a7ec-c956-4f0b-96d7-337ffec6eabb'
- '1a5483ab-4521-4702-9115-5857ac676851' - '1a5483ab-4521-4702-9115-5857ac676851'
- '1db63660-063d-4280-b2ea-388aca4a9459' - '1db63660-063d-4280-b2ea-388aca4a9459'
- '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8' - '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8'

View File

@@ -18,7 +18,8 @@ import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter, SidebarFooter,
SidebarHeader SidebarHeader,
SidebarRail
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
const data = { const data = {
@@ -75,13 +76,14 @@ const data = {
] ]
} }
export function AppSidebar({ orgs = [] }) { export function AppSidebar() {
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<WorkspaceSwitcher /> <WorkspaceSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarRail />
<NavMain data={data} /> <NavMain data={data} />
</SidebarContent> </SidebarContent>
<SidebarFooter /> <SidebarFooter />

View File

@@ -44,9 +44,7 @@ export function NavMain({
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarGroupLabel className="uppercase"> <SidebarGroupLabel>Colaboradores</SidebarGroupLabel>
Colaboradores
</SidebarGroupLabel>
{data.navUser.map((props, idx) => ( {data.navUser.map((props, idx) => (
<SidebarMenuItemLink key={idx} {...props} /> <SidebarMenuItemLink key={idx} {...props} />
))} ))}
@@ -57,9 +55,7 @@ export function NavMain({
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<SidebarGroupLabel className="uppercase"> <SidebarGroupLabel>Gestão de matrículas</SidebarGroupLabel>
Gestão de matrículas
</SidebarGroupLabel>
{data.navEnrollment.map((props, idx) => ( {data.navEnrollment.map((props, idx) => (
<SidebarMenuItemLink key={idx} {...props} /> <SidebarMenuItemLink key={idx} {...props} />
))} ))}
@@ -80,7 +76,7 @@ function SidebarMenuItemLink({ title, url, icon: Icon }: NavItem) {
return ( return (
<SidebarMenuItem key={title} onClick={onToggle}> <SidebarMenuItem key={title} onClick={onToggle}>
<NavLink to={`/${orgid}${url}`}> <NavLink to={`/${orgid}${url}`}>
{({ isActive, isPending }) => ( {({ isActive }) => (
<SidebarMenuButton <SidebarMenuButton
asChild asChild
className="data-[active=true]:text-lime-500" className="data-[active=true]:text-lime-500"

View File

@@ -18,7 +18,6 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail,
useSidebar useSidebar
} from '@repo/ui/components/ui/sidebar' } from '@repo/ui/components/ui/sidebar'
import { initials } from '@repo/ui/lib/utils' import { initials } from '@repo/ui/lib/utils'
@@ -153,7 +152,6 @@ export function WorkspaceSwitcher() {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarRail />
</SidebarMenu> </SidebarMenu>
) )
} }

View File

@@ -10,8 +10,7 @@ import { Suspense } from 'react'
import { Await, NavLink, useParams, useRevalidator } from 'react-router' import { Await, NavLink, useParams, useRevalidator } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Abbr } from '@/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { import {
AlertDialog, AlertDialog,
@@ -180,8 +179,11 @@ function RevokeItem({ id }: { id: string }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle> <AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. Isso revogará permanentemente os Esta ação não pode ser desfeita. Isso{' '}
privilégios deste gestor. <span className="font-bold">
revogará permanentemente os privilégios
</span>{' '}
deste gestor.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer"> <AlertDialogFooter className="*:cursor-pointer">

View File

@@ -12,6 +12,8 @@ import {
import type { ComponentProps, MouseEvent } from 'react' import type { ComponentProps, MouseEvent } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Abbr } from '@repo/ui/components/abbr'
import { DataTableColumnHeader } from '@repo/ui/components/data-table'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -38,8 +40,6 @@ import { Progress } from '@repo/ui/components/ui/progress'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { cn, initials } from '@repo/ui/lib/utils' import { cn, initials } from '@repo/ui/lib/utils'
import { Abbr } from '@/components/abbr'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { labels, statuses } from './data' import { labels, statuses } from './data'
// This type is used to define the shape of our data. // This type is used to define the shape of our data.
@@ -384,8 +384,11 @@ function RemoveDedupItem({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle> <AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. Isso remove a proteção contra Esta ação não pode ser desfeita. Isso{' '}
duplicação permanentemente desta matrícula. <span className="font-bold">
remove a proteção contra duplicação permanentemente
</span>{' '}
desta matrícula.
</AlertDialogDescription> </AlertDialogDescription>
{daysRemaining && ( {daysRemaining && (
<AlertDialogDescription> <AlertDialogDescription>
@@ -453,8 +456,11 @@ function CancelItem({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle> <AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. Isso cancelar permanentemente a Esta ação não pode ser desfeita. Isso{' '}
matrícula deste colaborador. <span className="font-bold">
cancelar permanentemente a matrícula
</span>{' '}
deste colaborador.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer"> <AlertDialogFooter className="*:cursor-pointer">

View File

@@ -16,10 +16,9 @@ import { Await, Link, Outlet, useParams, useSearchParams } from 'react-router'
import type { BookType } from 'xlsx' import type { BookType } from 'xlsx'
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import { DataTable, DataTableViewOptions } from '@/components/data-table' import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table'
import { RangeCalendarFilter } from '@/components/range-calendar-filter'
import { FacetedFilter } from '@repo/ui/components/faceted-filter' import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
import { SearchForm } from '@repo/ui/components/search-form' import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
@@ -32,6 +31,7 @@ import {
} from '@repo/ui/components/ui/dropdown-menu' } from '@repo/ui/components/ui/dropdown-menu'
import { Kbd } from '@repo/ui/components/ui/kbd' import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { columns, type Enrollment } from './columns' import { columns, type Enrollment } from './columns'
import { headers, sortings, statuses } from './data' import { headers, sortings, statuses } from './data'

View File

@@ -3,10 +3,10 @@ import type { Route } from './+types'
import { Suspense } from 'react' import { Suspense } from 'react'
import { Await } from 'react-router' import { Await } from 'react-router'
import { DataTable } from '@/components/data-table' import { DataTable } from '@repo/ui/components/data-table'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { createSearch } from '@repo/util/meili' import { createSearch } from '@repo/util/meili'
import { columns, type Order } from './columns' import { columns, type Order } from './columns'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {

View File

@@ -11,8 +11,8 @@ import {
import { NavLink, useParams } from 'react-router' import { NavLink, useParams } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Abbr } from '@/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
import { useDataTable } from '@/components/data-table/data-table' import { useDataTable } from '@repo/ui/components/data-table'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -183,8 +183,11 @@ function UnlinkItem({ id }: { id: string }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle> <AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Esta ação não pode ser desfeita. Isso removerá permanentemente o Esta ação não pode ser desfeita. Isso{' '}
vínculo deste colaborador. <span className="font-bold">
removerá permanentemente o vínculo
</span>{' '}
deste colaborador.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer"> <AlertDialogFooter className="*:cursor-pointer">

View File

@@ -4,9 +4,7 @@ import { PlusIcon } from 'lucide-react'
import { Suspense } from 'react' import { Suspense } from 'react'
import { Await, Link, useSearchParams } from 'react-router' import { Await, Link, useSearchParams } from 'react-router'
import { DataTable } from '@/components/data-table' import { DataTable } from '@repo/ui/components/data-table'
import { columns, type User } from './columns'
import { SearchForm } from '@repo/ui/components/search-form' import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
@@ -23,6 +21,8 @@ export function meta({}: Route.MetaArgs) {
] ]
} }
import { columns, type User } from './columns'
export async function loader({ params, context, request }: Route.LoaderArgs) { export async function loader({ params, context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const { orgid } = params const { orgid } = params

View File

@@ -2,8 +2,7 @@ import type { Route } from './+types'
import { useLoaderData } from 'react-router' import { useLoaderData } from 'react-router'
import { DataTable } from '@/components/data-table' import { type Webhook } from './columns'
import { columns, type Webhook } from './columns'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Webhooks' }] return [{ title: 'Webhooks' }]
@@ -24,8 +23,6 @@ export default function Page() {
Adicione webhooks para sua organização. Adicione webhooks para sua organização.
</p> </p>
</div> </div>
<DataTable columns={columns} data={data} />
</> </>
) )
} }

View File

@@ -22,7 +22,7 @@ export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ params, context, request }: Route.ActionArgs) { export async function loader({ params, context, request }: Route.ActionArgs) {
const user = context.get(userContext) const user = context.get(userContext)
const rawCookie = request.headers.get('cookie') const rawCookie = request.headers.get('cookie') || ''
const parsedCookies = cookie.parse(rawCookie) const parsedCookies = cookie.parse(rawCookie)
const { sidebar_state } = parsedCookies const { sidebar_state } = parsedCookies

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
@import 'tailwindcss';
@import '@repo/ui/globals.css';
/**
* This is necessary to load the @repo/ui package when moving from
* @tailwindcss/vite v4.0.7 to v4.0.8.
*
* For more details, see:
* https://github.com/tailwindlabs/tailwindcss/issues/16733
*/
@source '../../../packages/ui';

View File

@@ -0,0 +1,63 @@
'use client'
import {
Building2Icon,
DollarSign,
GraduationCap,
LayoutDashboard,
UsersIcon,
WebhookIcon
} from 'lucide-react'
import { NavMain } from '@/components/nav-main'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarRail
} from '@repo/ui/components/ui/sidebar'
const navMain = [
{
title: 'Visão geral',
url: '/',
icon: LayoutDashboard
},
{
title: 'Pagamentos',
url: '/payments',
icon: DollarSign
},
{
title: 'Matrículas',
url: '/enrollments',
icon: GraduationCap
},
{
title: 'Usuários',
url: '/users',
icon: UsersIcon
},
{
title: 'Empresas',
url: '/orgs',
icon: Building2Icon
},
{
title: 'Webhooks',
url: '/webhooks',
icon: WebhookIcon
}
]
export function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarRail />
<NavMain navMain={navMain} />
</SidebarContent>
<SidebarFooter />
</Sidebar>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar
} from '@repo/ui/components/ui/sidebar'
import { useIsMobile } from '@repo/ui/hooks/use-mobile'
import { type LucideIcon } from 'lucide-react'
import { NavLink, useParams } from 'react-router'
type NavItem = {
title: string
url: string
icon?: LucideIcon
}
export function NavMain({ navMain }: { navMain: NavItem[] }) {
return (
<>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navMain.map((props, idx) => (
<SidebarMenuItemLink key={idx} {...props} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)
}
function SidebarMenuItemLink({ title, url, icon: Icon }: NavItem) {
const { toggleSidebar } = useSidebar()
const isMobile = useIsMobile()
const onToggle = () => (isMobile ? toggleSidebar() : null)
return (
<SidebarMenuItem key={title} onClick={onToggle}>
<NavLink to={url}>
{({ isActive }) => (
<SidebarMenuButton
asChild
className="data-[active=true]:text-lime-500"
isActive={isActive}
tooltip={title}
>
<span>
{Icon ? <Icon /> : null}
<span>{title}</span>
</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
)
}

View File

@@ -41,3 +41,6 @@ export default async function handleRequest(
status: responseStatusCode status: responseStatusCode
}) })
} }
// https://reactrouter.com/how-to/suspense#timeouts
export const streamTimeout = 6_000

View File

@@ -1,3 +1,5 @@
import type { Route } from './+types/root'
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
@@ -7,22 +9,34 @@ import {
ScrollRestoration ScrollRestoration
} from 'react-router' } from 'react-router'
import '@repo/ui/globals.css' import { loggingMiddleware } from '@repo/auth/middleware/logging'
import type { Route } from './+types/root' import { ThemeProvider } from '@repo/ui/components/theme-provider'
import './app.css'
export const middleware: Route.MiddlewareFunction[] = [loggingMiddleware]
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="pt-br" className="dark"> <html lang="pt-br" className="h-full" suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>saladeaula.digital</title>
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
<body> <body className="h-full">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children} {children}
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
</ThemeProvider>
</body> </body>
</html> </html>
) )

View File

@@ -1,5 +1,6 @@
import { index, layout, type RouteConfig } from '@react-router/dev/routes' import { type RouteConfig } from '@react-router/dev/routes'
import { flatRoutes } from '@react-router/fs-routes'
export default [ export default flatRoutes({
layout('routes/layout.tsx', [index('routes/home.tsx')]) ignoredRouteFiles: ['**/.*'] // Ignore dot files (like .DS_Store)
] satisfies RouteConfig }) satisfies RouteConfig

View File

@@ -13,10 +13,10 @@ export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE } return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE }
} }
export default function Home({ loaderData }: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
return ( return (
<> <>
<Button>{loaderData.message}</Button> <Button>a</Button>
</> </>
) )
} }

View File

@@ -0,0 +1,269 @@
'use client'
import type { CellContext, ColumnDef } from '@tanstack/react-table'
import { useToggle } from 'ahooks'
import {
EllipsisVerticalIcon,
FileBadgeIcon,
HelpCircleIcon
} from 'lucide-react'
import type { ComponentProps } from 'react'
import { Abbr } from '@repo/ui/components/abbr'
import { DataTableColumnHeader } from '@repo/ui/components/data-table'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import { Checkbox } from '@repo/ui/components/ui/checkbox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { Progress } from '@repo/ui/components/ui/progress'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { cn, initials } from '@repo/ui/lib/utils'
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 = {
id: string
name: string
}
export type Enrollment = {
id: string
name: string
course: Course
status: string
progress: string
created_at: string
}
const formatted = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
export const columns: ColumnDef<Enrollment>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
className="cursor-pointer"
aria-label="Selecionar tudo"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
className="cursor-pointer"
aria-label="Selecionar linha"
/>
)
},
{
accessorKey: 'user',
header: 'Colaborador',
enableHiding: false,
cell: ({ row }) => {
const user = row.getValue('user') as { name: string; email: string }
return (
<div className="flex gap-2.5 items-center">
<Avatar className="size-10 hidden lg:block">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">
<Abbr>{user.name}</Abbr>
</li>
<li className="text-muted-foreground text-sm">
<Abbr>{user.email}</Abbr>
</li>
</ul>
</div>
)
}
},
{
accessorKey: 'course',
header: 'Curso',
enableHiding: false,
cell: ({ row }) => {
const { name } = row.getValue('course') as { name: string }
return <Abbr>{name}</Abbr>
}
},
{
accessorKey: 'status',
header: 'Status',
enableHiding: false,
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')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}
},
{
accessorKey: 'progress',
header: 'Progresso',
enableHiding: false,
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: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { title: 'Cadastrado em' },
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'started_at',
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { title: 'Iniciado em' },
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'completed_at',
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { title: 'Concluído em' },
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'failed_at',
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { title: 'Reprovado em' },
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'canceled_at',
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { title: 'Cancelado em' },
enableSorting: true,
enableHiding: true,
cell: cellDate
},
{
id: 'actions',
cell: ({ row }) => <ActionMenu row={row} />
}
]
function cellDate<TData>({
row: { original },
cell: { column }
}: CellContext<TData, unknown>) {
const accessorKey = column.columnDef.accessorKey as keyof TData
const value = original?.[accessorKey]
if (value) {
return formatted.format(new Date(value as string))
}
return <></>
}
function ActionMenu({ row }: { row: any }) {
const [open, { set: setOpen }] = useToggle(false)
const cert = row.original?.cert
return (
<div className="flex justify-end items-center">
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
setOpen(open)
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-46 *:cursor-pointer">
<DownloadItem
id={row.id}
disabled={!cert}
onSuccess={() => setOpen(false)}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
id: string
onSuccess?: () => void
}
function DownloadItem({ id, onSuccess, ...props }: ItemProps) {
const [loading, { set }] = useToggle(false)
const download = async (e: Event) => {
e.preventDefault()
set(true)
const r = await fetch(`/~/api/enrollments/${id}/download`, {
method: 'GET'
})
if (r.ok) {
const { presigned_url } = (await r.json()) as { presigned_url: string }
window.open(presigned_url, '_blank')
set(false)
onSuccess?.()
}
}
return (
<DropdownMenuItem onSelect={download} {...props}>
{loading ? <Spinner /> : <FileBadgeIcon />} Baixar certificado
</DropdownMenuItem>
)
}

View File

@@ -0,0 +1,69 @@
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: 'Concluído'
},
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: 'Concluído',
FAILED: 'Reprovado',
CANCELED: 'Cancelado'
}
export const sortings: Record<string, string> = {
created_at: 'Cadastrado em',
started_at: 'Iniciado em',
completed_at: 'Concluído em',
failed_at: 'Reprovado em',
canceled_at: 'Cancelado em'
}
export const headers = {
id: 'ID',
'user.name': 'Nome',
'user.email': 'Email',
'user.cpf': 'CPF',
'course.name': 'Curso',
status: 'Status',
progress: 'Progresso',
created_at: 'Cadastrado em',
started_at: 'Iniciado em',
completed_at: 'Concluído em',
failed_at: 'Reprovado em',
canceled_at: 'Cancelado em'
}

View File

@@ -0,0 +1,295 @@
import type { Route } from './+types'
import { flatten } from 'flat'
import {
CalendarIcon,
ChevronDownIcon,
DownloadIcon,
FileSpreadsheetIcon,
FileTextIcon,
PlusCircleIcon,
PlusIcon
} from 'lucide-react'
import { MeiliSearchFilterBuilder } from 'meilisearch-helper'
import { Suspense, useState } from 'react'
import { Await, Link, Outlet, useParams, useSearchParams } from 'react-router'
import type { BookType } from 'xlsx'
import * as XLSX from 'xlsx'
import { DataTable, DataTableViewOptions } from '@repo/ui/components/data-table'
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
import { RangeCalendarFilter } from '@repo/ui/components/range-calendar-filter'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili'
import { columns, type Enrollment } from './columns'
import { headers, sortings, statuses } from './data'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas' }]
}
export async function loader({ params, context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const { orgid } = params
const query = searchParams.get('q') || ''
const from = searchParams.get('from')
const to = searchParams.get('to')
const sort = searchParams.get('sort') || 'created_at:desc'
const status = searchParams.get('status')
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder()
if (status) {
builder = builder.where('status', 'in', status.split(','))
}
if (from && to) {
const [field, from_] = from.split(':')
builder = builder.where(field, 'between', [from_, to])
}
return {
data: createSearch({
index: 'betaeducacao-prod-enrollments',
filter: builder.build(),
sort: [sort],
query,
page,
hitsPerPage,
env: context.cloudflare.env
})
}
}
const formatted = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
export default function Route({ loaderData: { data } }) {
const [searchParams, setSearchParams] = useSearchParams()
const [selectedRows, setSelectedRows] = useState<Enrollment[]>([])
const status = searchParams.get('status')
const rangeParams = useRangeParams()
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Gerenciar matrículas
</h1>
<p className="text-muted-foreground">
Matricule colaboradores de forma rápida e acompanhe seu progresso.
</p>
</div>
<Await resolve={data}>
{({ hits, page, hitsPerPage, totalHits }) => (
<DataTable
sort={[{ id: 'created_at', desc: true }]}
columns={columns}
data={hits as Enrollment[]}
pageIndex={page - 1}
pageSize={hitsPerPage}
setSelectedRows={setSelectedRows}
rowCount={totalHits}
columnVisibilityInit={{
created_at: true,
completed_at: false,
started_at: false,
failed_at: false,
canceled_at: false
}}
>
<div className="flex gap-2.5 mb-2.5">
{selectedRows.length ? (
<>
<div className="flex gap-2.5 items-center">
<ExportMenu headers={headers} selectedRows={selectedRows} />
</div>
</>
) : (
<>
<div className="w-full 2xl:w-1/3">
<SearchForm
defaultValue={searchParams.get('q') || ''}
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd> para
pesquisar
</>
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('q', String(value))
searchParams.delete('p')
return searchParams
})
}
/>
</div>
<div className="flex gap-2.5 max-lg:flex-col w-full">
<div className="flex gap-2.5 max-lg:flex-col">
<FacetedFilter
title="Status"
icon={PlusCircleIcon}
className="lg:flex-1"
value={status ? status.split(',') : []}
onChange={(statuses) => {
setSearchParams((searchParams) => {
searchParams.delete('status')
searchParams.delete('p')
if (statuses.length) {
searchParams.set('status', statuses.join(','))
}
return searchParams
})
}}
options={Object.entries(statuses).map(
([key, value]) => ({
value: key,
...value
})
)}
/>
<RangeCalendarFilter
title="Período"
icon={CalendarIcon}
value={rangeParams}
className="lg:flex-1"
options={Object.entries(sortings).map(
([value, label]) => ({
value,
label
})
)}
onChange={(props) => {
setSearchParams((searchParams) => {
if (!props) {
searchParams.delete('from')
searchParams.delete('to')
return searchParams
}
const { rangeField, dateRange } = props
searchParams.set(
'from',
`${rangeField}:${formatted.format(dateRange?.from)}`
)
searchParams.set(
'to',
formatted.format(dateRange?.to)
)
return searchParams
})
}}
/>
</div>
<div className="lg:ml-auto flex gap-2.5">
<DataTableViewOptions className="flex-1" />
</div>
</div>
</>
)}
</div>
</DataTable>
)}
</Await>
<Outlet />
</Suspense>
)
}
function useRangeParams() {
const [searchParams] = useSearchParams()
const [from, to] = [searchParams.get('from'), searchParams.get('to')]
if (!from || !to) {
return {}
}
const [rangeField, from_] = from.split(':')
return {
rangeField,
dateRange: {
from: new Date(from_),
to: new Date(to)
}
}
}
export function ExportMenu({
headers,
selectedRows = []
}: {
headers: Record<string, string>
selectedRows: object[]
}) {
const { orgid } = useParams()
const exportFile = (bookType: BookType) => () => {
if (!selectedRows.length) {
return
}
const now = new Date()
const header = Object.keys(headers)
const data = selectedRows.map((data) => {
const obj: Record<string, string> = flatten(data)
return Object.fromEntries(header.map((k) => [k, obj?.[k]]))
})
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.json_to_sheet(data, { header })
XLSX.utils.sheet_add_aoa(worksheet, [Object.values(headers)], {
origin: 'A1'
})
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1')
XLSX.writeFile(workbook, `${orgid}_users_${+now}.${bookType}`, {
bookType,
compression: true
})
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="cursor-pointer">
<DownloadIcon /> Baixar <ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start">
<DropdownMenuGroup className="*:cursor-pointer">
<DropdownMenuItem onClick={exportFile('xlsx')}>
<FileSpreadsheetIcon /> Microsoft Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onClick={exportFile('csv')}>
<FileTextIcon /> CSV (.csv)
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { formatCNPJ } from '@brazilian-utils/brazilian-utils'
import { type ColumnDef } from '@tanstack/react-table'
import { Abbr } from '@repo/ui/components/abbr'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { initials } from '@repo/ui/lib/utils'
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Org = {
id: string
name: string
email: string
cnpj?: string
}
const formatted = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
export const columns: ColumnDef<Org>[] = [
{
header: 'Empresa',
cell: ({ row }) => {
const { name, email } = row.original
return (
<div className="flex gap-2.5 items-center">
<Avatar className="size-10 hidden lg:block">
<AvatarFallback>{initials(name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">
<Abbr>{name}</Abbr>
</li>
<li className="text-muted-foreground text-sm">
<Abbr>{email}</Abbr>
</li>
</ul>
</div>
)
}
},
{
header: 'CNPJ',
cell: ({ row }) => {
const { cnpj } = row.original
return <>{formatCNPJ(cnpj)}</>
}
},
{
header: 'Cadastrado em',
meta: {
className: 'w-1/12'
},
cell: ({ row }) => {
const { createDate } = row.original
if (!createDate) {
return <></>
}
return formatted.format(new Date(createDate))
}
}
]

View File

@@ -0,0 +1,88 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { Await, useSearchParams } from 'react-router'
import { DataTable } from '@repo/ui/components/data-table'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili'
import { columns, type c } from './columns'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Colaboradores' }]
}
export async function loader({ context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
const users = createSearch({
index: 'betaeducacao-prod-users_d2o3r5gmm4it7j',
sort: ['createDate:desc', 'create_date:desc'],
filter: 'cnpj EXISTS',
query,
page,
hitsPerPage,
env: context.cloudflare.env
})
return { data: users }
}
export default function Route({ loaderData: { data } }) {
const [searchParams, setSearchParams] = useSearchParams()
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Gerenciar empresas
</h1>
<p className="text-muted-foreground">
Adicione colaboradores e organize sua equipe de forma prática.
</p>
</div>
<Await resolve={data}>
{({ hits, page, hitsPerPage, totalHits }) => {
return (
<DataTable
sort={[{ id: 'created_at', desc: true }]}
columns={columns}
data={hits as Org[]}
pageIndex={page - 1}
pageSize={hitsPerPage}
rowCount={totalHits}
>
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5">
<div className="2xl:w-1/4">
<SearchForm
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd> para
pesquisar
</>
}
defaultValue={searchParams.get('q') || ''}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('q', String(value))
searchParams.delete('p')
return searchParams
})
}
/>
</div>
</div>
</DataTable>
)
}}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,22 @@
import type { Route } from './+types'
import { Button } from '@repo/ui/components/ui/button'
export function meta({}: Route.MetaArgs) {
return [
{ title: 'Histórico de pagamentos' },
{ name: 'description', content: 'Welcome to React Router!' }
]
}
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE }
}
export default function Route({}: Route.ComponentProps) {
return (
<>
<Button>a</Button>
</>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { type ColumnDef } from '@tanstack/react-table'
import { Abbr } from '@repo/ui/components/abbr'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { initials } from '@repo/ui/lib/utils'
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type User = {
id: string
name: string
email: string
cpf?: string
}
const formatted = new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
export const columns: ColumnDef<User>[] = [
{
header: 'Colaborador',
cell: ({ row }) => {
const { name, email } = row.original
return (
<div className="flex gap-2.5 items-center">
<Avatar className="size-10 hidden lg:block">
<AvatarFallback>{initials(name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">
<Abbr>{name}</Abbr>
</li>
<li className="text-muted-foreground text-sm">
<Abbr>{email}</Abbr>
</li>
</ul>
</div>
)
}
},
{
header: 'CPF',
cell: ({ row }) => {
const { cpf } = row.original
if (cpf) {
return <>{formatCPF(cpf)}</>
}
return <></>
}
},
{
header: 'Último accesso',
cell: ({ row }) => {
// Post-migration: rename `lastLogin` to `last_login`
if (row.original?.lastLogin) {
const lastLogin = new Date(row.original.lastLogin)
return formatted.format(lastLogin)
}
return <></>
}
},
{
header: 'Cadastrado em',
meta: {
className: 'w-1/12'
},
cell: ({ row }) => {
const created_at = new Date(row.original.createDate)
return formatted.format(created_at)
}
}
]

View File

@@ -0,0 +1,88 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { Await, useSearchParams } from 'react-router'
import { DataTable } from '@repo/ui/components/data-table'
import { SearchForm } from '@repo/ui/components/search-form'
import { Skeleton } from '@repo/ui/components/skeleton'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { createSearch } from '@repo/util/meili'
import { columns, type User } from './columns'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Colaboradores' }]
}
export async function loader({ context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q') || ''
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
const users = createSearch({
index: 'betaeducacao-prod-users_d2o3r5gmm4it7j',
sort: ['createDate:desc', 'create_date:desc'],
filter: 'cnpj NOT EXISTS',
query,
page,
hitsPerPage,
env: context.cloudflare.env
})
return { data: users }
}
export default function Route({ loaderData: { data } }) {
const [searchParams, setSearchParams] = useSearchParams()
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Gerenciar colaboradores
</h1>
<p className="text-muted-foreground">
Adicione colaboradores e organize sua equipe de forma prática.
</p>
</div>
<Await resolve={data}>
{({ hits, page, hitsPerPage, totalHits }) => {
return (
<DataTable
sort={[{ id: 'created_at', desc: true }]}
columns={columns}
data={hits as User[]}
pageIndex={page - 1}
pageSize={hitsPerPage}
rowCount={totalHits}
>
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5">
<div className="2xl:w-1/4">
<SearchForm
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd> para
pesquisar
</>
}
defaultValue={searchParams.get('q') || ''}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('q', String(value))
searchParams.delete('p')
return searchParams
})
}
/>
</div>
</div>
</DataTable>
)
}}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,22 @@
import type { Route } from './+types'
import { Button } from '@repo/ui/components/ui/button'
export function meta({}: Route.MetaArgs) {
return [
{ title: 'Histórico de pagamentos' },
{ name: 'description', content: 'Welcome to React Router!' }
]
}
export function loader({ context }: Route.LoaderArgs) {
return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE }
}
export default function Route({}: Route.ComponentProps) {
return (
<>
<Button>a</Button>
</>
)
}

View File

@@ -0,0 +1,67 @@
import type { Route } from './+types'
import * as cookie from 'cookie'
import { Outlet } from 'react-router'
import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth'
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
import { NavUser } from '@repo/ui/components/nav-user'
import {
SidebarInset,
SidebarProvider,
SidebarTrigger
} from '@repo/ui/components/ui/sidebar'
import { Toaster } from '@repo/ui/components/ui/sonner'
import { AppSidebar } from '@/components/app-sidebar'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ context, request }: Route.ActionArgs) {
const user = context.get(userContext)
const rawCookie = request.headers.get('cookie') || ''
const parsedCookies = cookie.parse(rawCookie)
const { sidebar_state } = parsedCookies
return Response.json({ user, sidebar_state })
}
export default function Route({ loaderData }: Route.ComponentProps) {
const { user, sidebar_state } = loaderData
return (
<SidebarProvider defaultOpen={sidebar_state === 'true'} className="flex">
<AppSidebar />
<SidebarInset className="relative flex flex-col flex-1 min-w-0">
<header
className="bg-background/15 backdrop-blur-sm
px-4 py-2 lg:py-4 sticky top-0 z-5"
>
<div className="container mx-auto flex items-center">
<SidebarTrigger className="md:hidden" />
<ThemedImage className="max-md:hidden" />
<div className="ml-auto flex gap-2.5 items-center">
<ModeToggle />
<NavUser user={user} />
</div>
</div>
</header>
<main className="p-4">
<div className="container mx-auto relative">
<Outlet />
<Toaster
position="top-center"
richColors={true}
duration={Infinity}
closeButton={true}
/>
</div>
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -1,32 +0,0 @@
import type { Route } from './+types'
import { Link, Outlet } from 'react-router'
import dark from '@repo/ui/components/logo-dark.svg'
export default function Component({ loaderData }: Route.ComponentProps) {
// const { user } = loaderData
return (
<div className="relative flex flex-col flex-1 min-w-0">
<header
className="bg-background/15 backdrop-blur-sm
px-4 py-2 lg:py-4 sticky top-0 z-5"
>
<div className="container mx-auto flex items-center">
<Link to="/">
<img src={dark} className="h-6 lg:h-8" />
</Link>
<div className="ml-auto">{/*<NavUser user={user} />*/}</div>
</div>
</header>
<main className="p-4">
<div className="container mx-auto">
<Outlet />
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import type { Route } from './+types'
import { redirect } from 'react-router'
import { createAuth, type User } from '@repo/auth/auth'
import { requestIdContext } from '@repo/auth/context'
import { createSessionStorage } from '@repo/auth/session'
export async function loader({ request, context }: Route.ActionArgs) {
const sessionStorage = createSessionStorage(context.cloudflare.env)
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const returnTo = session.has('returnTo') ? session.get('returnTo') : '/'
const requestId = context.get(requestIdContext)
const user = session.get('user') as User | null
if (user) {
return redirect(returnTo)
}
try {
const authenticator = createAuth(context.cloudflare.env)
const user = await authenticator.authenticate('oidc', request)
session.set('user', user)
console.log(`[${requestId}] Redirecting the user to ${returnTo}`)
// Redirect to the home page after successful login
return redirect(returnTo, {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session)
}
})
} catch (error) {
console.error(`[${requestId}]`, error)
if (error instanceof Error) {
return Response.json(
{ error: error.message },
{
status: 400,
headers: {
'Content-Type': 'application/json; utf-8'
}
}
)
}
// Re-throw any other errors (including redirects)
throw error
}
}

View File

@@ -0,0 +1,23 @@
import type { Route } from './+types'
import { redirect } from 'react-router'
import type { OAuth2Strategy } from 'remix-auth-oauth2'
import { createAuth, type User } from '@repo/auth/auth'
import { createSessionStorage } from '@repo/auth/session'
export async function loader({ request, context }: Route.LoaderArgs) {
const authenticator = createAuth(context.cloudflare.env)
const sessionStorage = createSessionStorage(context.cloudflare.env)
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const user = session.get('user') as User
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
if (user?.accessToken && strategy) {
await strategy.revokeToken(user.accessToken)
}
return redirect('/login', {
headers: { 'Set-Cookie': await sessionStorage.destroySession(session) }
})
}

View File

@@ -0,0 +1,42 @@
import type { Route } from './+types'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import { authMiddleware } from '@repo/auth/middleware/auth'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export const loader = proxy
export const action = proxy
async function proxy({
request,
context
}: Route.ActionArgs): Promise<Response> {
const pathname = new URL(request.url).pathname.replace(/^\/~\/api\//, '')
const user = context.get(userContext) as User
const url = new URL(pathname, context.cloudflare.env.API_URL)
const headers = new Headers(request.headers)
headers.set('Authorization', `Bearer ${user.accessToken}`)
const response = await fetch(url.toString(), {
method: request.method,
headers,
...(['GET', 'HEAD'].includes(request.method)
? {}
: { body: await request.text() })
})
console.log(headers)
const contentType = response.headers.get('content-type') || ''
const body =
contentType.includes('application/json') || contentType.startsWith('text/')
? await response.text()
: await response.arrayBuffer()
return new Response(body, {
status: response.status,
headers: response.headers
})
}

View File

@@ -12,9 +12,13 @@
"typecheck": "npm run cf-typegen && react-router typegen && tsc --noEmit" "typecheck": "npm run cf-typegen && react-router typegen && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@repo/ui": "*", "@react-router/fs-routes": "^7.9.6",
"@repo/auth": "*", "@repo/auth": "*",
"@repo/ui": "*",
"@tanstack/react-table": "^8.21.3",
"cookie": "^1.0.2",
"isbot": "^5.1.31", "isbot": "^5.1.31",
"lucide-react": "^0.548.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.2" "react-router": "^7.9.2"

View File

@@ -1,8 +1,9 @@
import type { Config } from "@react-router/dev/config"; import type { Config } from '@react-router/dev/config'
export default { export default {
ssr: true, ssr: true,
future: { future: {
unstable_viteEnvironmentApi: true, v8_middleware: true,
}, unstable_viteEnvironmentApi: true
} satisfies Config; }
} satisfies Config

View File

@@ -1,23 +1,26 @@
import { createRequestHandler } from "react-router"; import { createRequestHandler, RouterContextProvider } from 'react-router'
declare module "react-router" { declare module 'react-router' {
export interface AppLoadContext { export interface AppLoadContext {
cloudflare: { cloudflare: {
env: Env; env: Env
ctx: ExecutionContext; ctx: ExecutionContext
}; }
} }
} }
const requestHandler = createRequestHandler( const requestHandler = createRequestHandler(
() => import("virtual:react-router/server-build"), () => import('virtual:react-router/server-build'),
import.meta.env.MODE import.meta.env.MODE
); )
export default { export default {
async fetch(request, env, ctx) { async fetch(request, env, ctx) {
return requestHandler(request, { const context = new RouterContextProvider()
cloudflare: { env, ctx },
}); return requestHandler(
}, request,
} satisfies ExportedHandler<Env>; Object.assign(context, { cloudflare: { env, ctx } })
)
}
} satisfies ExportedHandler<Env>

View File

@@ -9,8 +9,12 @@ routes = [
mode = "smart" mode = "smart"
[vars] [vars]
ISSUER_URL = "https://duiolq49qn25e.cloudfront.net" CLIENT_ID = "6fd6a7ec-c956-4f0b-96d7-337ffec6eabb"
VALUE_FROM_CLOUDFLARE = "Hello from Cloudflare" REDIRECT_URI = "https://insights.saladeaula.digital/login"
SCOPE = "openid profile email offline_access apps:insights"
API_URL = "https://bcs7fgb9og.execute-api.sa-east-1.amazonaws.com"
ISSUER_URL = "https://id.saladeaula.digital"
MEILI_HOST = "https://search.saladeaula.digital"
[observability.logs] [observability.logs]
enabled = true enabled = true

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,8 @@
"./src/hooks/*.tsx" "./src/hooks/*.tsx"
], ],
"./components/*": "./src/components/*.tsx", "./components/*": "./src/components/*.tsx",
"./components/*.svg": "./src/components/*.svg" "./components/*.svg": "./src/components/*.svg",
"./components/data-table": "./src/components/data-table/index.ts"
}, },
"dependencies": { "dependencies": {
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12", "@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",

View File

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

View File

@@ -116,7 +116,11 @@ export function RangeCalendarFilter({
className="*:cursor-pointer" className="*:cursor-pointer"
> >
{options.map(({ label, value }, idx) => ( {options.map(({ label, value }, idx) => (
<DropdownMenuRadioItem value={value} key={idx}> <DropdownMenuRadioItem
value={value}
key={idx}
onSelect={(e) => e.preventDefault()}
>
{label} {label}
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
))} ))}