add other projects

This commit is contained in:
2025-11-04 15:00:49 -03:00
parent 80ff884ceb
commit 0b0ef528df
218 changed files with 58699 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
import type { Route } from './+types'
import { redirect } from 'react-router'
export async function loader({ params }: Route.LoaderArgs) {
const { orgid } = params
throw redirect(`/${orgid}/main`)
}

View File

@@ -0,0 +1,18 @@
import type { Route } from './+types'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Gestores' }]
}
export default function Route() {
return (
<>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">Gestores</h1>
<p className="text-muted-foreground">
Adicione gestores e organize sua equipe de forma prática.
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,230 @@
import type { Route } from './+types'
import Fuse from 'fuse.js'
import { AwardIcon, BanIcon, LaptopIcon } from 'lucide-react'
import { Suspense, useMemo } from 'react'
import { Await, useSearchParams } from 'react-router'
import placeholder from '@/assets/placeholder.webp'
import { SearchForm } from '@/components/search-form'
import { Skeleton } from '@/components/skeleton'
import { Card, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@/components/ui/empty'
import { Kbd } from '@/components/ui/kbd'
import { createSearch } from '@/lib/meili'
import { request as req } from '@/lib/request'
import { cn } from '@/lib/utils'
type Cert = {
exp_interval: number
}
type Course = {
id: string
name: string
access_period: string
cert: Cert
metadata__unit_price?: number
}
type CustomPricing = {
sk: string
unit_price: number
}
export function meta({}: Route.MetaArgs) {
return [{ title: 'Catálogo de cursos' }]
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const courses = createSearch({
index: 'saladeaula_courses',
sort: ['created_at:desc'],
filter: 'draft NOT EXISTS',
hitsPerPage: 100,
env: context.cloudflare.env
})
const customPricing = req({
url: `/orgs/${params.orgid}/custompricing`,
context,
request
}).then((r) => r.json())
return {
data: Promise.all([courses, customPricing])
}
}
export default function Route({ loaderData: { data } }) {
const [searchParams, setSearchParams] = useSearchParams()
const term = searchParams.get('term') as string
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Catálogo de cursos
</h1>
<p className="text-muted-foreground">
Explore o catálogo de cursos disponíveis para sua empresa.
</p>
</div>
<Await resolve={data}>
{([{ hits }, { items }]) => {
return (
<>
<div className="flex flex-col lg:flex-row justify-between gap-2.5 mb-2.5">
<div className="2xl:w-92">
<SearchForm
placeholder={
<>
Pressione <Kbd className="border font-mono">/</Kbd> para
filtrar...
</>
}
defaultValue={term}
onChange={(term) => {
setSearchParams({ term })
}}
/>
</div>
</div>
<div className="grid lg:grid-cols-3 xl:grid-cols-4 gap-5">
<List term={term} hits={hits} customPricing={items} />
</div>
</>
)
}}
</Await>
</Suspense>
)
}
function List({
term,
hits = [],
customPricing = []
}: {
term: string
hits: Course[]
customPricing: CustomPricing[]
}) {
const fuse = useMemo(() => {
return new Fuse(hits, {
keys: ['name'],
threshold: 0.3,
includeMatches: true
})
}, [hits])
const hits_ = useMemo(() => {
if (!term) {
return hits
}
return fuse.search(term).map(({ item }) => item)
}, [term, fuse, hits])
const customPricingMap = new Map(
customPricing.map((x) => {
const [, courseId] = x.sk.split('#')
return [courseId, x.unit_price]
})
)
if (hits_.length === 0) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{term}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return hits_.map((props: Course, idx) => {
return (
<Course
key={idx}
custom_pricing={customPricingMap.get(props.id)}
{...props}
/>
)
})
}
function Course({
name,
access_period,
cert,
metadata__unit_price,
custom_pricing
}: Course & { custom_pricing?: number }) {
return (
<Card className="overflow-hidden relative h-96">
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">{name}</CardTitle>
</CardHeader>
<CardFooter className="text-gray-300 text-sm absolute z-1 bottom-6 w-full flex gap-1.5">
<ul className="flex gap-2.5">
<li className="flex gap-0.5 items-center">
<LaptopIcon className="size-4" />
<span>{access_period}d</span>
</li>
{cert?.exp_interval && (
<li className="flex gap-0.5 items-center">
<AwardIcon className="size-4" />
<span>{cert.exp_interval}d</span>
</li>
)}
{metadata__unit_price && (
<>
<li className="flex gap-1.5">
<span
className={cn({
'line-through text-muted-foreground': custom_pricing
})}
>
{currency.format(metadata__unit_price)}
</span>
{custom_pricing && (
<span>{currency.format(custom_pricing)}</span>
)}
</li>
</>
)}
</ul>
</CardFooter>
<img
src={placeholder}
alt={name}
className="absolute bottom-0 opacity-75"
/>
</Card>
)
}
const currency = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
})

View File

@@ -0,0 +1,189 @@
'use client'
import type { CellContext, ColumnDef } from '@tanstack/react-table'
import {
CircleCheckIcon,
CircleIcon,
CircleOffIcon,
CircleXIcon,
HelpCircleIcon,
TimerIcon,
type LucideIcon
} from 'lucide-react'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { cn, initials } from '@/lib/utils'
// 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 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>[] = [
{
header: 'Colaborador',
enableHiding: false,
cell: ({ row: { original } }) => {
const { user } = original
return (
<div className="flex gap-2.5 items-center">
<Avatar className="size-12">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold truncate max-w-62">{user.name}</li>
<li className="text-muted-foreground text-sm truncate max-w-62">
{user.email}
</li>
</ul>
</div>
)
}
},
{
accessorKey: 'course.name',
header: 'Curso',
enableHiding: false,
cell: ({ row: { original } }) => (
<abbr className="truncate max-w-62 block" title={original.course.name}>
{original.course.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
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: { 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>
)
},
{
accessorKey: 'created_at',
header: 'Matriculado em',
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'started_at',
header: 'Iniciado em',
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'completed_at',
header: 'Aprovado em',
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'failed_at',
header: 'Reprovado em',
enableHiding: true,
cell: cellDate
},
{
accessorKey: 'canceled_at',
header: 'Cancelado em',
enableHiding: true,
cell: cellDate
}
]
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 <></>
}

View File

@@ -0,0 +1,275 @@
import type { Route } from './+types'
import { BookCopyIcon, 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 { FacetedFilter } from '@/components/faceted-filter'
import { RangeCalendarFilter } from '@/components/range-calendar-filter'
import { SearchForm } from '@/components/search-form'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { createSearch } from '@/lib/meili'
import { columns, statuses, type Enrollment } from './columns'
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 status = searchParams.getAll('status') || []
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
let builder = new MeiliSearchFilterBuilder().where('org_id', '=', orgid)
if (status.length) {
builder = builder.where('status', 'in', status)
}
if (field && from && to) {
builder = builder.where(field, 'between', [from, to])
}
return {
data: createSearch({
index: 'betaeducacao-prod-enrollments',
sort: ['created_at:desc'],
filter: builder.build(),
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 [from, to] = [searchParams.get('from'), searchParams.get('to')]
const [rangeField, setRangeField] = useState<string>(
searchParams.get('field') || 'created_at'
)
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 }) => {
return (
<DataTable
columns={columns}
data={hits as Enrollment[]}
pageIndex={page - 1}
pageSize={hitsPerPage}
rowCount={totalHits}
hiddenColumn={[
'completed_at',
'started_at',
'failed_at',
'canceled_at'
]}
>
<div className="flex flex-col 2xl:flex-row gap-2.5 mb-2.5">
<div className="w-full 2xl:w-1/3">
<SearchForm
defaultValue={searchParams.get('q') || ''}
placeholder={
<>
Pressione <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
icon={BookCopyIcon}
className="lg:flex-1"
value={searchParams.getAll('courses')}
onChange={(courses) => {
setSearchParams((searchParams) => {
searchParams.delete('courses')
searchParams.delete('p')
if (statuses.length) {
courses.forEach((s) =>
searchParams.has('courses', s)
? null
: searchParams.append('courses', s)
)
}
return searchParams
})
}}
title="Cursos"
options={Object.entries(statuses).map(([key, value]) => ({
value: key,
...value
}))}
/>
<FacetedFilter
icon={PlusCircleIcon}
className="lg:flex-1"
value={searchParams.getAll('status')}
onChange={(statuses) => {
setSearchParams((searchParams) => {
searchParams.delete('status')
searchParams.delete('p')
if (statuses.length) {
statuses.forEach((s) =>
searchParams.has('status', s)
? null
: searchParams.append('status', s)
)
}
return searchParams
})
}}
title="Status"
options={Object.entries(statuses).map(([key, value]) => ({
value: key,
...value
}))}
/>
<RangeCalendarFilter
className="lg:flex-1"
value={
from && to
? {
from: DateTime.fromISO(from, dtOptions),
to: DateTime.fromISO(to, dtOptions)
}
: undefined
}
onChange={(dateRange) => {
setSearchParams((searchParams) => {
if (dateRange) {
searchParams.set('field', rangeField)
searchParams.set(
'from',
formatted.format(dateRange.from)
)
searchParams.set(
'to',
formatted.format(dateRange.to)
)
} else {
searchParams.delete('from')
searchParams.delete('to')
searchParams.delete('field')
}
return searchParams
})
}}
>
<div className="p-2.5">
<Select
defaultValue={rangeField}
onValueChange={(field) => {
setRangeField(field)
setSearchParams((searchParams) => {
if (searchParams.has('from')) {
searchParams.set('field', field)
}
return searchParams
})
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="created_at">
Matriculado em
</SelectItem>
<SelectItem value="started_at">
Iniciado em
</SelectItem>
<SelectItem value="completed_at">
Aprovado em
</SelectItem>
<SelectItem value="failed_at">
Reprovado em
</SelectItem>
<SelectItem value="canceled_at">
Cancelado em
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</RangeCalendarFilter>
</div>
<div className="lg:ml-auto flex gap-2.5">
<CustomizeColumns className="flex-1" />
<Button className="flex-1" asChild>
<Link to="add">
<PlusIcon /> Adicionar
</Link>
</Button>
</div>
</div>
</div>
</DataTable>
)
}}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card'
export default function Route() {
return (
<div className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../enrollments">Matrículas</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Adicionar matrícula</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Adicionar matrícula</CardTitle>
<CardDescription>
Siga os passos abaixo para adicionar uma nova matrícula
</CardDescription>
</CardHeader>
<CardContent></CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { authMiddleware } from '@/middleware/auth'
import type { Route } from './+types'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export function meta({}) {
return [
{ title: 'Visão geral' }
// { name: 'description', content: 'Welcome to React Router!' }
]
}
export default function Page() {
return <>index org</>
}

View File

@@ -0,0 +1,63 @@
'use client'
import { type ColumnDef } from '@tanstack/react-table'
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Order = {
id: string
total: number
status: 'pending' | 'processing' | 'success' | 'failed'
payment_method: 'PIX' | 'CREDIT_CARD' | 'MANUAL' | 'failed'
name: 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<Order>[] = [
{
accessorKey: 'payment_method',
header: 'Forma de pag.'
},
{
accessorKey: 'status',
header: 'Status'
},
{
accessorKey: 'total',
header: 'Valor total',
cell: ({ row }) => {
const amount = parseFloat(row.getValue('total'))
const formatted = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(amount)
return <div className="font-medium">{formatted}</div>
}
},
{
header: 'Data de venc.',
cell: ({ row }) => {
try {
const dueDate = new Date(row.original.due_date)
return formatted.format(dueDate)
} catch {
return 'N/A'
}
}
},
{
header: 'Comprado em',
cell: ({ row }) => {
const createdAt = new Date(row.original.create_date)
return formatted.format(createdAt)
}
}
]

View File

@@ -0,0 +1,61 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { Await } from 'react-router'
import { DataTable } from '@/components/data-table'
import { Skeleton } from '@/components/skeleton'
import { createSearch } from '@/lib/meili'
import { columns, type Order } from './columns'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Histórico de compras' }]
}
export async function loader({ params, context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const { orgid } = params
const page = Number(searchParams.get('p')) + 1
const hitsPerPage = Number(searchParams.get('perPage')) || 25
return {
data: createSearch({
index: 'betaeducacao-prod-orders',
sort: ['create_date:desc'],
filter: `tenant_id = ${orgid}`,
page,
hitsPerPage,
env: context.cloudflare.env
})
}
}
export default function Route({ loaderData: { data } }) {
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Histórico de compras
</h1>
<p className="text-muted-foreground">
Acompanhe todos as compras realizadas, visualize pagamentos e mantenha
o controle financeiro.
</p>
</div>
<Await resolve={data}>
{({ hits, page, hitsPerPage, totalHits }) => {
return (
<DataTable
columns={columns}
data={hits as Order[]}
pageIndex={page - 1}
pageSize={hitsPerPage}
rowCount={totalHits}
/>
)
}}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,21 @@
import type { Route } from './+types'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas agendadas' }]
}
export default function Route() {
return (
<>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Matrículas agendadas
</h1>
<p className="text-muted-foreground">
Acompanhe todas as matrículas agendadas, cancele quando quiser ou
matricule imediatamente.
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,142 @@
import type { Route } from './+types'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { PatternFormat } from 'react-number-format'
import { Link, useOutletContext } from 'react-router'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/spinner'
import type { User } from '@/routes/_.$orgid.users.$id/route'
import { useForm } from 'react-hook-form'
const formSchema = z.object({
name: z.string().trim().nonempty('Digite seu nome'),
email: z.email(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' })
})
export default function Route() {
const { user } = useOutletContext() as { user: User }
const form = useForm({
defaultValues: user,
resolver: zodResolver(formSchema)
})
const { handleSubmit, control, formState } = form
const onSubmit = async (data: z.infer<typeof formSchema>) => {
console.log(data)
}
return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Editar colaborador</CardTitle>
<CardDescription>
Configurar as informações gerais para este colaborador
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="email"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormLabel className="text-sm font-normal text-muted-foreground">
<span>
Para gerenciar os emails ou trocar o email principal, use
as{' '}
<Link
to="emails"
className="text-blue-400 underline hover:no-underline"
>
configurações de emails
</Link>
</span>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="cpf"
render={({ field: { onChange, ref, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Editar colaborador
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,35 @@
import type { Route } from './+types'
import { Suspense } from 'react'
import { Await } from 'react-router'
import { Skeleton } from '@/components/skeleton'
import { request as req } from '@/lib/request'
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params
const data = req({
url: `/users/${id}/emails`,
request,
context
}).then((r) => r.json())
return { data }
}
export default function Route({ loaderData: { data } }) {
return (
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ items = [] }) => (
<ul>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return <li key={idx}>{email}</li>
})}
</ul>
)}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,3 @@
export default function Route() {
return <>user logs</>
}

View File

@@ -0,0 +1,40 @@
import { Await } from 'react-router'
import type { Route } from './+types'
import { Skeleton } from '@/components/skeleton'
import { request as req } from '@/lib/request'
import { Suspense } from 'react'
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params
const r = req({
url: `/users/${id}/orgs`,
request,
context
}).then((r) => r.json())
return { data: r }
}
export default function Route({ loaderData: { data } }) {
return (
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ items = [] }) => (
<ul>
{items.map(
({ name, cnpj }: { name: string; cnpj: string }, idx: number) => {
return (
<li key={idx}>
{name} {cnpj}
</li>
)
}
)}
</ul>
)}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,126 @@
import type { Route } from './+types'
import {
Link,
NavLink,
Outlet,
type ShouldRevalidateFunctionArgs
} from 'react-router'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { request as req } from '@/lib/request'
import { initials } from '@/lib/utils'
export function meta() {
return [
{
title: 'Editar colaborador'
}
]
}
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { id } = params
const r = await req({
url: `/users/${id}`,
request,
context
})
if (!r.ok) {
throw new Response(null, { status: r.status })
}
const user: User = await r.json()
return { user }
}
export function shouldRevalidate({
currentParams,
nextParams
}: ShouldRevalidateFunctionArgs) {
return currentParams.id !== nextParams.id
}
export type User = {
name: string
email: string
cpf: string
}
const links = [
{ to: '', title: 'Perfil', end: true },
{ to: 'emails', title: 'Emails' },
{ to: 'orgs', title: 'Empresas' }
]
export default function Route({
loaderData: { user }
}: {
loaderData: Awaited<ReturnType<typeof loader>>
}) {
return (
<div className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../users">Colaboradores</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Editar colaborador</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5">
<div className="flex gap-2.5 items-center mb-5">
<Avatar className="size-12">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">{user.name}</li>
<li className="text-muted-foreground text-sm">{user.email}</li>
</ul>
</div>
<Tabs>
<TabsList>
{links.map(({ to, title, ...props }, idx) => (
<NavLink
to={to}
key={idx}
className="aria-[current=page]:pointer-events-none"
{...props}
>
{({ isActive }) => (
<TabsTrigger
data-state={isActive ? 'active' : ''}
value={title}
asChild
>
<span>{title}</span>
</TabsTrigger>
)}
</NavLink>
))}
</TabsList>
</Tabs>
<Outlet context={{ user }} />
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { formatCPF } from '@brazilian-utils/brazilian-utils'
import { type ColumnDef } from '@tanstack/react-table'
import { ArrowRight } from 'lucide-react'
import { NavLink } from 'react-router'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { initials } from '@/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
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<User>[] = [
{
header: 'Colaborador',
cell: ({ row }) => {
const { name, email } = row.original
return (
<div className="flex gap-2.5 items-center">
<Avatar className="size-12">
<AvatarFallback>{initials(name)}</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold truncate max-w-62">{name}</li>
<li className="text-muted-foreground text-sm truncate max-w-92">
{email}
</li>
</ul>
</div>
)
}
},
{
header: 'CPF',
cell: ({ row }) => {
const { cpf } = row.original
if (cpf) {
return <>{formatCPF(cpf)}</>
}
return <></>
}
},
{
header: 'Cadastrado em',
cell: ({ row }) => {
const created_at = new Date(row.original.createDate)
return formatted.format(created_at)
}
},
{
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: ' ',
cell: ({ row }) => {
return (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="relative group"
asChild
>
<NavLink to={`${row.original?.id}`}>
{({ isPending }) => (
<>
{isPending && <Spinner className="absolute" />}
<span className="group-[.pending]:invisible">
Editar
</span>{' '}
<ArrowRight className="group-[.pending]:invisible" />
</>
)}
</NavLink>
</Button>
</div>
)
}
}
]

View File

@@ -0,0 +1,100 @@
import type { Route } from './+types'
import { PlusIcon } from 'lucide-react'
import { Suspense } from 'react'
import { Await, Link, useSearchParams } from 'react-router'
import { DataTable } from '@/components/data-table'
import { SearchForm } from '@/components/search-form'
import { Skeleton } from '@/components/skeleton'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { createSearch } from '@/lib/meili'
import { columns, type User } from './columns'
export function meta({}: Route.MetaArgs) {
return [
{ title: 'Colaboradores' },
{
name: 'description',
content: 'Adicione colaboradores e organize sua equipe de forma prática'
}
]
}
export async function loader({ params, context, request }: Route.LoaderArgs) {
const { searchParams } = new URL(request.url)
const { orgid } = params
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: `tenant_id = ${orgid}`,
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
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={
<>
Pressione <Kbd className="border font-mono">/</Kbd> para
pesquisar...
</>
}
defaultValue={searchParams.get('q') || ''}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('q', value)
searchParams.delete('p')
return searchParams
})
}
/>
</div>
<Button variant="outline" asChild>
<Link to="add">
<PlusIcon /> Adicionar colaborador
</Link>
</Button>
</div>
</DataTable>
)
}}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card'
export default function Route() {
return (
<div className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../users">Colaboradores</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Adicionar colaborador</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Adicionar colaborador</CardTitle>
<CardDescription>
Siga os passos abaixo para cadastrar um novo colaborador
</CardDescription>
</CardHeader>
<CardContent></CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
import { type ColumnDef } from '@tanstack/react-table'
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
export type Webhook = {
id: string
url: string
}
export const columns: ColumnDef<Webhook>[] = [
{
accessorKey: 'url',
header: 'URL'
}
]

View File

@@ -0,0 +1,34 @@
import type { Route } from './+types'
import { useLoaderData } from 'react-router'
import { DataTable } from '@/components/data-table'
import { authMiddleware } from '@/middleware/auth'
import { columns, type Webhook } from './columns'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export function meta({}: Route.MetaArgs) {
return [{ title: 'Webhooks' }]
}
export async function loader(): Promise<Webhook[]> {
return []
}
export default function Page() {
const data = useLoaderData() as Webhook[]
return (
<>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">Webhooks</h1>
<p className="text-muted-foreground">
Adicione webhooks para sua organização.
</p>
</div>
<DataTable columns={columns} data={data} />
</>
)
}

View File

@@ -0,0 +1,85 @@
import type { Route } from './+types'
import { Outlet, type ShouldRevalidateFunctionArgs } from 'react-router'
import { AppSidebar } from '@/components/app-sidebar'
import { ModeToggle, ThemedImage } from '@/components/dark-mode'
import { NavUser } from '@/components/nav-user'
import {
SidebarInset,
SidebarProvider,
SidebarTrigger
} from '@/components/ui/sidebar'
import { userContext } from '@/context'
import { useIsMobile } from '@/hooks/use-mobile'
import { request as req } from '@/lib/request'
import { authMiddleware } from '@/middleware/auth'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ params, context, request }: Route.ActionArgs) {
const user = context.get(userContext)
const r = await req({
url: `/users/${user.sub}/orgs?limit=25`,
request,
context
})
if (!r.ok) {
throw new Response(await r.text(), { status: r.status })
}
const { items = [] } = (await r.json()) as { items: { sk: string }[] }
const orgs = items.map(({ sk, ...props }) => ({
...props,
id: sk?.split('#')[1] ?? null
}))
const exists = orgs.some(({ id }) => id === params.orgid)
if (exists) {
return { user, orgs }
}
throw new Response(null, { status: 401 })
}
export function shouldRevalidate({
currentParams,
nextParams
}: ShouldRevalidateFunctionArgs) {
return currentParams.orgid !== nextParams.orgid
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, orgs } = loaderData
const isMobile = useIsMobile()
return (
<SidebarProvider className="flex">
<AppSidebar orgs={orgs} />
<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">
{isMobile ? <SidebarTrigger /> : <ThemedImage />}
<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">
<Outlet />
</div>
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,33 @@
import type { Route } from './+types'
import { redirect } from 'react-router'
import { userContext } from '@/context'
import { request as req } from '@/lib/request'
import { authMiddleware } from '@/middleware/auth'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ context, request }: Route.ActionArgs) {
const user = context.get(userContext)
const r = await req({
url: `/users/${user.sub}/orgs`,
request,
context
})
if (!r.ok) {
throw new Response(await r.text(), { status: r.status })
}
const { items = [] } = (await r.json()) as { items: { sk: string }[] }
const [{ sk } = {}] = items
if (sk) {
const [_, id] = sk.split('#')
throw redirect(`/${id}/main`)
}
throw new Response(null, { status: 401 })
}

View File

@@ -0,0 +1,51 @@
import type { Route } from './+types'
import { redirect } from 'react-router'
import { requestIdContext } from '@/context'
import { createAuth, type User } from '@/lib/auth'
import { createSessionStorage } from '@/lib/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 '@/lib/auth'
import { createSessionStorage } from '@/lib/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,40 @@
import type { Route } from './+types'
import { userContext } from '@/context'
import type { User } from '@/lib/auth'
import { authMiddleware } from '@/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() })
})
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
})
}