Files
saladeaula.digital/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx

559 lines
16 KiB
TypeScript

import type { Route } from './+types/route'
import { useRequest, useToggle } from 'ahooks'
import {
AlertTriangleIcon,
BanIcon,
CalendarIcon,
CircleXIcon,
ClockAlertIcon,
ClockCheckIcon,
ClockIcon,
EllipsisIcon,
PlusIcon,
RocketIcon,
UserIcon
} from 'lucide-react'
import { DateTime as LuxonDateTime } from 'luxon'
import type { MouseEvent, ReactNode } from 'react'
import { Fragment, Suspense } from 'react'
import { Await } from 'react-router'
import { toast } from 'sonner'
import { Abbr } from '@repo/ui/components/abbr'
import { DateTime } from '@repo/ui/components/datetime'
import { Skeleton } from '@repo/ui/components/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@repo/ui/components/ui/alert-dialog'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Button } from '@repo/ui/components/ui/button'
import { Card, CardContent } from '@repo/ui/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Spinner } from '@repo/ui/components/ui/spinner'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from '@repo/ui/components/ui/tabs'
import { initials } from '@repo/ui/lib/utils'
import { request as req } from '@repo/util/request'
import { Link, useParams, useRevalidator, useSearchParams } from 'react-router'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas agendadas' }]
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const scheduled = req({
url: `/orgs/${params.orgid}/enrollments/scheduled`,
context,
request
}).then((r) => r.json())
return {
scheduled
}
}
export default function Route({
loaderData: { scheduled }
}: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams()
return (
<Suspense fallback={<Skeleton />}>
<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>
<Await resolve={scheduled}>
{({ items }) => {
if (items.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nenhum agendamento ainda</EmptyTitle>
<EmptyDescription>
Agende a matrícula dos seus colaboradores de forma rápida e
organizada.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<Link to="../enrollments/add">
<PlusIcon /> Agendar
</Link>
</Button>
</EmptyContent>
</Empty>
)
}
const scheduled = grouping(filtering(items, undefined))
const executed = sorting(grouping(filtering(items, 'EXECUTED')))
const failed = sorting(grouping(filtering(items, 'FAILED')))
return (
<div className="space-y-5 lg:max-w-4xl mx-auto">
<Tabs
defaultValue={String(
searchParams.has('tab') ? searchParams.get('tab') : 'pending'
)}
className="space-y-5"
onValueChange={(value) => {
setSearchParams((searchParams) => {
searchParams.set('tab', value)
return searchParams
})
}}
>
<div className="flex justify-between">
<TabsList className="*:cursor-pointer">
<TabsTrigger value="pending">Aguardando</TabsTrigger>
<TabsTrigger value="executed">Executada</TabsTrigger>
<TabsTrigger value="failed">Falhou</TabsTrigger>
</TabsList>
<Button asChild>
<Link to="../enrollments/add">
<PlusIcon />{' '}
<span className="hidden xl:block">Agendar</span>
</Link>
</Button>
</div>
<TabsContent value="pending" className="space-y-5">
<Timeline events={scheduled}>
{({ items }) => (
<Card className="col-span-4">
<CardContent>
<Scheduled items={items} />
</CardContent>
</Card>
)}
</Timeline>
</TabsContent>
<TabsContent value="executed" className="space-y-5">
<Timeline events={executed}>
{({ items }) => (
<Card className="col-span-4">
<CardContent>
<Executed items={items} />
</CardContent>
</Card>
)}
</Timeline>
</TabsContent>
<TabsContent value="failed" className="space-y-5">
<Timeline events={failed}>
{({ items }) => (
<Card className="col-span-4">
<CardContent>
<Failed items={items} />
</CardContent>
</Card>
)}
</Timeline>
</TabsContent>
</Tabs>
</div>
)
}}
</Await>
</Suspense>
)
}
function Timeline({
events = [],
children
}: {
events: any[]
children: (props: any) => ReactNode
}) {
if (events.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<ClockIcon />
</EmptyMedia>
<EmptyTitle>Nenhum agendamento encontrado</EmptyTitle>
<EmptyDescription>
Ainda não agendamentos. Quando houver, eles aparecerão aqui.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return (
<>
{events.map(([run_at, items], index) => (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-2.5" key={index}>
<div>
{LuxonDateTime.fromISO(run_at)
.setLocale('pt-BR')
.toFormat('cccc, dd LLL yyyy')}
</div>
{children({ items })}
</div>
))}
</>
)
}
function Scheduled({ items = [] }) {
return (
<ItemGroup>
{items.map(({ sk, user, course, created_by, scheduled_at }, index) => (
<Fragment key={index}>
<Item className="max-lg:px-0 max-lg:first:pt-0 max-lg:last:pb-0">
<ItemMedia className="hidden lg:block">
<Avatar className="size-10">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr>
</ItemDescription>
<div className="mt-1">
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li>
<CalendarIcon className="size-3.5" />
<span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{scheduled_at}
</DateTime>
</span>
</li>
<li>
<UserIcon className="size-3.5" />
<span>{created_by.name}</span>
</li>
</ul>
</div>
</ItemContent>
<ItemActions className="self-start">
<ActionMenu sk={sk} />
</ItemActions>
</Item>
{index !== items.length - 1 && <ItemSeparator />}
</Fragment>
))}
</ItemGroup>
)
}
function Executed({ items = [] }) {
return (
<ItemGroup>
{items.length === 0 ? (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nenhum agendamento ainda</EmptyTitle>
<EmptyDescription>
Agende a matrícula dos seus colaboradores de forma rápida e
organizada.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild>
<Link to="../enrollments/add">
<PlusIcon /> Agendar
</Link>
</Button>
</EmptyContent>
</Empty>
) : null}
{sorting(items).map(({ course, user, created_at }, index) => (
<Fragment key={index}>
<Item className="max-lg:px-0 max-lg:first:pt-0 max-lg:last:pb-0">
<ItemMedia className="hidden lg:block">
<Avatar className="size-10 ">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr>
</ItemDescription>
<div className="mt-1">
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li>
<ClockCheckIcon className="size-3.5" />
<span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{created_at}
</DateTime>
</span>
</li>
</ul>
</div>
</ItemContent>
</Item>
{index !== items.length - 1 && <ItemSeparator />}
</Fragment>
))}
</ItemGroup>
)
}
function Failed({ items = [] }) {
return (
<ItemGroup>
{items.map(({ snapshot: { course, user }, cause, created_at }, index) => (
<Fragment key={index}>
<Item className="max-lg:px-0 max-lg:first:pt-0 max-lg:last:pb-0">
<ItemMedia className="hidden lg:block">
<Avatar className="size-10 ">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{course.name}</ItemTitle>
<ItemDescription className="flex flex-col">
<Abbr>{user.name}</Abbr>
<Abbr>{user.email}</Abbr>
</ItemDescription>
<div className="mt-1">
<ul className="lg:flex gap-2.5 text-muted-foreground text-sm *:flex *:gap-1 *:items-center">
<li>
<ClockAlertIcon className="size-3.5" />
<span>
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{created_at}
</DateTime>
</span>
</li>
{cause?.type === 'DeduplicationConflictError' ? (
<li className="text-red-400">
<AlertTriangleIcon className="size-3.5" />
<span>Protegido contra duplicação</span>
</li>
) : null}
</ul>
</div>
</ItemContent>
</Item>
{index !== items.length - 1 && <ItemSeparator />}
</Fragment>
))}
</ItemGroup>
)
}
function ActionMenu({ sk }: { sk: string }) {
const [open, { toggle, set }] = useToggle()
const { revalidate } = useRevalidator()
const onSuccess = () => {
revalidate()
set(false)
}
return (
<DropdownMenu open={open} onOpenChange={toggle}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer w-42">
{/* <Proceedtem sk={sk} onSuccess={onSuccess} />
<DropdownMenuSeparator />*/}
<CancelItem sk={sk} onSuccess={onSuccess} />
</DropdownMenuContent>
</DropdownMenu>
)
}
function Proceedtem({ sk, onSuccess }: { sk: string; onSuccess?: () => void }) {
const { runAsync, loading } = useRequest(
async () => {
await new Promise((r) => setTimeout(r, 1000))
},
{ manual: true }
)
const proceed = async (e: MouseEvent) => {
e.preventDefault()
await runAsync()
}
return (
<DropdownMenuItem onClick={proceed}>
{loading ? <Spinner /> : <RocketIcon />} Matricular agora
</DropdownMenuItem>
)
}
function CancelItem({ sk, onSuccess }: { sk: string; onSuccess?: () => void }) {
const { orgid } = useParams()
const [open, { set: setOpen }] = useToggle(false)
const { runAsync, loading } = useRequest(
async () => {
const [scheduled_for, lock_hash] = sk.split('#')
return await fetch(`/~/api/orgs/${orgid}/enrollments/scheduled`, {
method: 'DELETE',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ scheduled_for, lock_hash })
})
},
{ manual: true }
)
const cancel = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
const r = await runAsync()
if (r.ok) {
toast.info('O agendamento foi cancelada.')
onSuccess?.()
}
setOpen(false)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
>
<CircleXIcon /> Cancelar
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">
cancela permanentemente o agendamento
</span>{' '}
desta matrícula.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function filtering(items, status) {
return items.filter(({ sk }: { sk: string }) => {
const [, , s] = sk.split('#')
return s == status
})
}
function grouping(items) {
const newItems = Object.entries(
items.reduce((acc, item) => {
const [run_at] = item.sk.split('#')
if (!acc[run_at]) {
acc[run_at] = []
}
acc[run_at].push(item)
return acc
}, [])
)
return newItems.sort((x, y) => x[0].localeCompare(y[0]))
}
function sorting(items) {
return items.sort((a, b) => {
return new Date(b[0]).getTime() - new Date(a[0]).getTime()
})
}