add other projects
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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 <></>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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</>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Route() {
|
||||
return <>user logs</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
85
apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx
Normal file
85
apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/admin.saladeaula.digital/app/routes/_index/route.tsx
Normal file
33
apps/admin.saladeaula.digital/app/routes/_index/route.tsx
Normal 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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
})
|
||||
}
|
||||
40
apps/admin.saladeaula.digital/app/routes/~.api.$/route.ts
Normal file
40
apps/admin.saladeaula.digital/app/routes/~.api.$/route.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user