diff --git a/apps/admin.saladeaula.digital/app/middleware/workspace.ts b/apps/admin.saladeaula.digital/app/middleware/workspace.ts index cb796bd..2bf2b00 100644 --- a/apps/admin.saladeaula.digital/app/middleware/workspace.ts +++ b/apps/admin.saladeaula.digital/app/middleware/workspace.ts @@ -46,12 +46,15 @@ export const workspaceMiddleware = async ( const user = context.get(userContext)! const cacheKey = buildWorkspaceCacheKey(request, user.sub, orgId) - const cached = await getWorkspaceFromCache(cacheKey) - if (cached) { - context.set(workspaceContext, cached) - return next() - } + try { + const cached = await getWorkspaceFromCache(cacheKey) + + if (cached) { + context.set(workspaceContext, cached) + return next() + } + } catch {} console.log(`[${new Date().toISOString()}] [${requestId}] Cache miss`) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/course-picker.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/course-picker.tsx index 7157132..467f82a 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/course-picker.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/course-picker.tsx @@ -7,13 +7,7 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' -import { - forwardRef, - use, - useMemo, - useState, - type InputHTMLAttributes -} from 'react' +import { forwardRef, useMemo, useState, type InputHTMLAttributes } from 'react' import { Button } from '@repo/ui/components/ui/button' import { @@ -45,7 +39,7 @@ interface CoursePickerProps extends Omit< 'value' | 'onChange' > { value?: Course - options: Promise<{ hits: any[] }> + options: any[] onChange?: (value: any) => void error?: any } @@ -59,12 +53,11 @@ const normalize = (value: string) => export const CoursePicker = forwardRef( ({ value, onChange, options, error, ...props }, ref) => { - const { hits } = use(options) const [search, setSearch] = useState('') const [open, { set }] = useToggle() const [sort, { toggle }] = useToggle('a-z', 'z-a') const fuse = useMemo(() => { - return new Fuse(hits, { + return new Fuse(options, { keys: ['name'], threshold: 0.3, includeMatches: true, @@ -73,11 +66,11 @@ export const CoursePicker = forwardRef( return typeof value === 'string' ? normalize(value) : value } }) - }, [hits]) + }, [options]) const filtered = useMemo(() => { if (!search) { - return [...hits].sort((a, b) => { + return [...options].sort((a, b) => { const comparison = a.name.localeCompare(b.name) return sort === 'a-z' ? comparison : -comparison }) @@ -87,7 +80,7 @@ export const CoursePicker = forwardRef( ...item, matches })) - }, [search, fuse, hits, sort]) + }, [search, fuse, options, sort]) return ( @@ -149,13 +142,15 @@ export const CoursePicker = forwardRef( name, access_period, metadata__unit_price: unit_price, - quantity = null + quantity = null, + disabled = false }) => { return ( { onChange?.({ id, diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts index 7124fef..f456160 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/data.ts @@ -4,6 +4,7 @@ import { z } from 'zod' export const MAX_ITEMS = 50 export const enrollment = z.object({ + id: z.uuidv4().optional(), user: z .object( { @@ -34,7 +35,8 @@ export const enrollment = z.object({ scheduled_for: z .date() .optional() - .transform((date) => (date ? format(date, 'yyyy-MM-dd') : undefined)) + .transform((date) => (date ? format(date, 'yyyy-MM-dd') : undefined)), + seat: z.object({ order_id: z.uuidv4() }).optional() }) export const formSchema = z.object({ diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx index ee01c99..ee51714 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx @@ -131,8 +131,9 @@ export async function action({ params, request, context }: Route.ActionArgs) { } export default function Route({ - loaderData: { courses, submission } + loaderData: { courses: courses_, submission } }: Route.ComponentProps) { + const { hits: courses } = use(courses_) const { orgid } = useParams() const { enrolled } = use(submission) const fetcher = useFetcher() diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx index cc150ea..3717770 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/assigned.tsx @@ -1,48 +1,48 @@ -import { Fragment, useEffect } from 'react' -import { - Trash2Icon, - PlusIcon, - CircleQuestionMarkIcon, - ArrowRightIcon -} from 'lucide-react' -import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form' -import { useParams } from 'react-router' import { ErrorMessage } from '@hookform/error-message' import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { DateTime } from 'luxon' - -import { Form } from '@repo/ui/components/ui/form' import { - InputGroup, - InputGroupAddon, - InputGroupInput -} from '@repo/ui/components/ui/input-group' + ArrowRightIcon, + CircleQuestionMarkIcon, + PlusIcon, + Trash2Icon +} from 'lucide-react' +import { DateTime } from 'luxon' +import { Fragment, use, useEffect } from 'react' +import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form' +import { useParams } from 'react-router' +import { z } from 'zod' + import { Button } from '@repo/ui/components/ui/button' -import { Separator } from '@repo/ui/components/ui/separator' -import { Kbd } from '@repo/ui/components/ui/kbd' -import { Spinner } from '@repo/ui/components/ui/spinner' +import { Form } from '@repo/ui/components/ui/form' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@repo/ui/components/ui/hover-card' - -import { TZ } from '@/conf' import { - MAX_ITEMS, + InputGroup, + InputGroupAddon, + InputGroupInput +} from '@repo/ui/components/ui/input-group' +import { Kbd } from '@repo/ui/components/ui/kbd' +import { Separator } from '@repo/ui/components/ui/separator' +import { Spinner } from '@repo/ui/components/ui/spinner' + +import { useWizard } from '@/components/wizard' +import { TZ } from '@/conf' +import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' +import { formSchema, + MAX_ITEMS, type Course, type User } from '../_.$orgid.enrollments.add/data' -import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for' import { Cell } from '../_.$orgid.enrollments.add/route' -import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' +import { ScheduledForInput } from '../_.$orgid.enrollments.add/scheduled-for' import { UserPicker } from '../_.$orgid.enrollments.add/user-picker' import { Summary } from './bulk' -import { currency } from './utils' -import { useWizard } from '@/components/wizard' import { useWizardStore } from './store' +import { currency } from './utils' const emptyRow = { user: undefined, @@ -56,8 +56,9 @@ type AssignedProps = { courses: Promise<{ hits: Course[] }> } -export function Assigned({ courses }: AssignedProps) { +export function Assigned({ courses: courses_ }: AssignedProps) { const wizard = useWizard() + const { hits: courses } = use(courses_) const { orgid } = useParams() const { update, ...state } = useWizardStore() const form = useForm({ @@ -76,7 +77,7 @@ export function Assigned({ courses }: AssignedProps) { } }) - const { formState, control, handleSubmit, setValue } = form + const { formState, control, handleSubmit } = form const { fields, remove, append } = useFieldArray({ control, name: 'enrollments' diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx index a7caade..9d9be7c 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/bulk.tsx @@ -7,7 +7,7 @@ import { Trash2Icon, XIcon } from 'lucide-react' -import { Fragment, useEffect } from 'react' +import { Fragment, use, useEffect } from 'react' import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form' import { z } from 'zod' @@ -64,7 +64,8 @@ type BulkProps = { courses: Promise<{ hits: Course[] }> } -export function Bulk({ courses }: BulkProps) { +export function Bulk({ courses: courses_ }: BulkProps) { + const { hits: courses } = use(courses_) const wizard = useWizard() const { update, ...state } = useWizardStore() const form = useForm({ diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx index 4a93b0f..4da62de 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.buy/route.tsx @@ -44,7 +44,6 @@ import { HttpMethod, request as req } from '@repo/util/request' import { Step, StepItem, StepSeparator } from '@/components/step' import { Wizard, WizardStep } from '@/components/wizard' import { useWorksapce } from '@/components/workspace-switcher' -import { INTERNAL_EMAIL_DOMAIN } from '@/conf' import { workspaceContext } from '@/middleware/workspace' import { Button } from '@repo/ui/components/ui/button' import { Spinner } from '@repo/ui/components/ui/spinner' diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.seats/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.seats/route.tsx index 9cc52cd..a700d67 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.seats/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.seats/route.tsx @@ -9,8 +9,8 @@ import { Trash2Icon } from 'lucide-react' import { Fragment, useMemo } from 'react' -import { Controller, useFieldArray, useForm } from 'react-hook-form' -import { Link, redirect, useParams } from 'react-router' +import { Controller, useFieldArray, useForm, useWatch } from 'react-hook-form' +import { Link, redirect, useFetcher, useParams } from 'react-router' import { Breadcrumb, @@ -34,7 +34,9 @@ import { HoverCardTrigger } from '@repo/ui/components/ui/hover-card' import { Kbd } from '@repo/ui/components/ui/kbd' -import { request as req } from '@repo/util/request' +import { Separator } from '@repo/ui/components/ui/separator' +import { Spinner } from '@repo/ui/components/ui/spinner' +import { HttpMethod, request as req } from '@repo/util/request' import { workspaceContext } from '@/middleware/workspace' import { CoursePicker } from '../_.$orgid.enrollments.add/course-picker' @@ -42,6 +44,7 @@ import { formSchema, MAX_ITEMS, type Course, + type Schema, type User } from '../_.$orgid.enrollments.add/data' import { @@ -57,9 +60,8 @@ export function meta({}: Route.MetaArgs) { } type Seat = { - id: string - pk: string - course: Course + order_id: string + enrollment_id: string } export async function loader({ request, params, context }: Route.LoaderArgs) { @@ -75,13 +77,31 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { context }) .then((r) => r.json() as any) - .then(({ items }) => items as Seat[]) + .then(({ items }) => items as { sk: string; course: Course }[]) return { seats } } +export async function action({ params, request, context }: Route.ActionArgs) { + const { orgid: org_id } = params + const body = (await request.json()) as object + + const r = await req({ + url: `enrollments`, + headers: new Headers({ 'Content-Type': 'application/json' }), + method: HttpMethod.POST, + body: JSON.stringify({ org_id, ...body }), + request, + context + }) + + const data = (await r.json()) as { sk: string } + return redirect(`/${org_id}/enrollments/${data.sk}/submitted`) +} + export default function Route({ loaderData: { seats } }: Route.ComponentProps) { const { orgid } = useParams() + const fetcher = useFetcher() const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { enrollments: [emptyRow] } @@ -92,34 +112,80 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) { name: 'enrollments' }) - const courses = useMemo( - () => - Promise.resolve({ - hits: Array.from( - seats - .reduce((map, { course }) => { - const existing = map.get(course.id) + const enrollments = useWatch({ + control, + name: 'enrollments' + }) - if (existing) { - existing.quantity += 1 - } else { - map.set(course.id, { - ...course, - metadata__unit_price: 1, - quantity: 1 - }) - } + const usedSeatIds = useMemo(() => { + return new Set(enrollments?.map((e) => e.id).filter(Boolean)) + }, [enrollments]) - return map - }, new Map()) - .values() - ) - }), - [seats] - ) + const seatsByCourse = useMemo(() => { + return seats.reduce>((acc, seat) => { + const courseId = seat.course.id + const [, order_id, , enrollment_id] = seat.sk.split('#') - console.log(seats) + if (!acc[courseId]) { + acc[courseId] = [] + } + acc[courseId].push({ order_id, enrollment_id }) + return acc + }, {}) + }, [seats]) + + const usedSeatsByCourse = useMemo(() => { + const acc = new Map() + + seats.forEach((seat) => { + const [, , , enrollment_id] = seat.sk.split('#') + + if (!usedSeatIds.has(enrollment_id)) { + return + } + + const courseId = seat.course.id + acc.set(courseId, (acc.get(courseId) ?? 0) + 1) + }) + + return acc + }, [seats, usedSeatIds]) + + const courses = useMemo(() => { + return { + hits: Array.from( + seats + .reduce((acc, { course }) => { + const existing = acc.get(course.id) + + if (existing) { + existing.quantity += 1 + } else { + acc.set(course.id, { + ...course, + metadata__unit_price: 1, + quantity: 1, + disabled: false + }) + } + + return acc + }, new Map()) + .values() + ).map((course) => { + const used = usedSeatsByCourse.get(course.id) ?? 0 + return { ...course, disabled: used >= course.quantity } + }) + } + }, [seats, usedSeatsByCourse]) + + const onSubmit = async (data: Schema) => { + await fetcher.submit(JSON.stringify(data), { + method: 'post', + encType: 'application/json' + }) + } const onSearch = async (search: string) => { const params = new URLSearchParams({ q: search }) const r = await fetch(`/${orgid}/users.json?${params.toString()}`) @@ -127,15 +193,57 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) { return hits } + const pickSeat = (courseId: string): Seat | null => { + const pool = seatsByCourse[courseId] + + if (!pool) { + return null + } + + return ( + pool.find((seat) => { + return !usedSeatIds.has(seat.enrollment_id) + }) ?? null + ) + } + const duplicateRow = (index: number, times: number = 1) => { if (fields.length + times > MAX_ITEMS) { return null } - const { user, ...rest } = getValues(`enrollments.${index}`) + const { course } = getValues(`enrollments.${index}`) + + if (!course?.id) { + Array.from({ length: times }, (_, i) => { + // @ts-ignore + insert(index + 1 + i, { course: null }) + }) + + return + } + + const reservedSeatIds = new Set(usedSeatIds) + Array.from({ length: times }, (_, i) => { - // @ts-ignore - insert(index + 1 + i, rest) + const pool = seatsByCourse[course.id] + + const seat = + pool?.find((seat) => !reservedSeatIds.has(seat.enrollment_id)) ?? null + + if (seat) { + reservedSeatIds.add(seat.enrollment_id) + + // @ts-ignore + insert(index + 1 + i, { + id: seat.enrollment_id, + seat: { order_id: seat.order_id }, + course + }) + } else { + // @ts-ignore + insert(index + 1 + i, { course: null }) + } }) } @@ -154,168 +262,206 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) { +
+ + + Adicionar matrículas + + Siga os passos abaixo para adicionar colaboradores às matrículas + abertas. + + - - - Adicionar matrículas - - Siga os passos abaixo para adicionar colaboradores às matrículas - abertas. - - - - -
- {/* Header */} - <> - Colaborador - Curso - - Matricular em - - - - - -

- Escolha a data em que o colaborador será matriculado no - curso. -

- -

- Você poderá acompanhar as matrículas em{' '} - Agendamentos -

-
-
-
- {/**/} - - - {/* Rows */} - <> - {fields.map((field, index) => ( - - {/* Separator only for mobile */} - {index >= 1 &&
} - - ( -
- - - ( -

- {message} -

- )} - /> -
- )} - /> - - ( -
- - ( -

- {message} -

- )} - /> -
- )} - /> - - ( - - )} - /> - - {/* Action */} -
- + + - - +

+ Escolha a data em que o colaborador será matriculado no + curso. +

- + Você poderá acompanhar as matrículas em{' '} + Agendamentos +

+
+ + + {/**/} + + + {/* Rows */} + <> + {fields.map((field, index) => ( + + {/* Separator only for mobile */} + {index >= 1 &&
} + + ( +
+ + + ( +

+ {message} +

+ )} + /> +
+ )} /> - -
-
- ))} - -
+ ( +
+ { + const seat = pickSeat(course.id) -
- -
- - + if (!seat) { + return + } + + setValue( + `enrollments.${index}.id`, + seat.enrollment_id + ) + + setValue( + `enrollments.${index}.seat.order_id`, + seat.order_id + ) + + onChange(course) + }} + options={courses.hits} + error={fieldState.error} + readOnly + /> + ( +

+ {message} +

+ )} + /> +
+ )} + /> + + ( + + )} + /> + + {/* Action */} +
+ + + + + +
+ + ))} + + + +
+ +
+ + + +
+ +
+
+
+ ) } diff --git a/packages/ui/src/routes/enrollments/columns.tsx b/packages/ui/src/routes/enrollments/columns.tsx index 4a8eb10..449c72a 100644 --- a/packages/ui/src/routes/enrollments/columns.tsx +++ b/packages/ui/src/routes/enrollments/columns.tsx @@ -3,14 +3,14 @@ import type { ColumnDef } from '@tanstack/react-table' import { HelpCircleIcon } from 'lucide-react' -import { Badge } from '@repo/ui/components/ui/badge' import { Abbr } from '@repo/ui/components/abbr' -import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' -import { Progress } from '@repo/ui/components/ui/progress' import { DataTableColumnDatetime, DataTableColumnHeaderSort } from '@repo/ui/components/data-table' +import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' +import { Badge } from '@repo/ui/components/ui/badge' +import { Progress } from '@repo/ui/components/ui/progress' import { cn, initials } from '@repo/ui/lib/utils' import { labels, statuses, type Enrollment } from './data' @@ -88,7 +88,7 @@ export const columns: ColumnDef[] = [ }, { accessorKey: 'created_at', - meta: { title: 'Cadastrado em' }, + meta: { title: 'Matriculado em' }, enableSorting: true, enableHiding: true, header: DataTableColumnHeaderSort,