diff --git a/api.saladeaula.digital/app/routes/enrollments/enroll.py b/api.saladeaula.digital/app/routes/enrollments/enroll.py index 5c881ae..0f9a51b 100644 --- a/api.saladeaula.digital/app/routes/enrollments/enroll.py +++ b/api.saladeaula.digital/app/routes/enrollments/enroll.py @@ -56,7 +56,7 @@ class DeduplicationWindow(BaseModel): offset_days: int -class SubscriptionTerms(BaseModel): +class Subscription(BaseModel): billing_day: int @@ -88,8 +88,8 @@ def enroll( ) + KeyPair( pk=str(org_id), - sk='METADATA#SUBSCRIPTION_TERMS', - rename_key='terms', + sk='METADATA#SUBSCRIPTION', + rename_key='subscription', table_name=USER_TABLE, ) + KeyPair( @@ -106,7 +106,7 @@ def enroll( ctx = { 'org': Org.model_validate(org), 'created_by': created_by, - 'terms': SubscriptionTerms.model_validate(org['terms']), + 'subscription': Subscription.model_validate(org['subscription']), } immediate = [e for e in enrollments if not e.scheduled_for] @@ -150,7 +150,7 @@ Context = TypedDict( 'Context', { 'org': Org, - 'terms': SubscriptionTerms, + 'subscription': Subscription, 'created_by': Authenticated, }, ) @@ -161,7 +161,7 @@ def enroll_now(enrollment: Enrollment, context: Context): user = enrollment.user course = enrollment.course org: Org = context['org'] - subscription_terms: SubscriptionTerms = context['terms'] + subscription: Subscription = context['subscription'] created_by: Authenticated = context['created_by'] lock_hash = md5_hash(f'{user.id}{course.id}') access_expires_at = now_ + timedelta(days=course.access_period) @@ -213,7 +213,7 @@ def enroll_now(enrollment: Enrollment, context: Context): 'id': enrollment.id, 'sk': 'METADATA#SUBSCRIPTION_COVERED', 'org_id': org.id, - 'billing_day': subscription_terms.billing_day, + 'billing_day': subscription.billing_day, 'created_at': now_, } ) @@ -270,7 +270,7 @@ def enroll_later(enrollment: Enrollment, context: Context): scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore deduplication_window = enrollment.deduplication_window org: Org = context['org'] - subscription_terms: SubscriptionTerms = context['terms'] + subscription: Subscription = context['subscription'] created_by: Authenticated = context['created_by'] lock_hash = md5_hash(f'{user.id}{course.id}') @@ -289,7 +289,7 @@ def enroll_later(enrollment: Enrollment, context: Context): 'id': created_by.id, 'name': created_by.name, }, - 'subscription_billing_day': subscription_terms.billing_day, + 'subscription_billing_day': subscription.billing_day, 'ttl': ttl(start_dt=scheduled_for), 'created_at': now_, } diff --git a/api.saladeaula.digital/app/routes/orgs/billing.py b/api.saladeaula.digital/app/routes/orgs/billing.py index f94ed9d..3058d63 100644 --- a/api.saladeaula.digital/app/routes/orgs/billing.py +++ b/api.saladeaula.digital/app/routes/orgs/billing.py @@ -6,14 +6,27 @@ from aws_lambda_powertools.event_handler.openapi.params import Query from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client -from config import COURSE_TABLE +from config import ORDER_TABLE, USER_TABLE router = Router() -dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client) +dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) + + +@router.get('//subscription') +def subscription(org_id: str): + return dyn.collection.get_item( + KeyPair( + pk=org_id, + sk='METADATA#SUBSCRIPTION', + table_name=USER_TABLE, + ), + raise_on_error=False, + default={}, + ) @router.get('//billing') -def get_custom_pricing( +def billing( org_id: str, start_date: Annotated[date, Query()], end_date: Annotated[date, Query()], diff --git a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx index 9f0fe57..a693853 100644 --- a/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx +++ b/apps/admin.saladeaula.digital/app/components/app-sidebar.tsx @@ -3,13 +3,13 @@ import { BookCopyIcon, CalendarClockIcon, - FileBadgeIcon, + // FileBadgeIcon, GraduationCap, LayoutDashboardIcon, ReceiptTextIcon, ShieldUserIcon, ShoppingCartIcon, - UploadIcon, + // UploadIcon, UsersIcon } from 'lucide-react' diff --git a/apps/admin.saladeaula.digital/app/components/nav-main.tsx b/apps/admin.saladeaula.digital/app/components/nav-main.tsx index 53488cb..42bec4e 100644 --- a/apps/admin.saladeaula.digital/app/components/nav-main.tsx +++ b/apps/admin.saladeaula.digital/app/components/nav-main.tsx @@ -1,5 +1,8 @@ 'use client' +import { type LucideIcon } from 'lucide-react' +import { NavLink, useParams } from 'react-router' + import { SidebarGroup, SidebarGroupContent, @@ -11,9 +14,6 @@ import { } from '@repo/ui/components/ui/sidebar' import { useIsMobile } from '@repo/ui/hooks/use-mobile' -import { type LucideIcon } from 'lucide-react' -import { NavLink, useParams } from 'react-router' - type NavItem = { title: string url: string diff --git a/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx index 014ff05..ef0555d 100644 --- a/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx +++ b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx @@ -56,7 +56,7 @@ export function WorkspaceProvider({ }) { const { orgid } = useParams() const [activeWorkspace, setActiveWorkspace] = useState( - () => workspaces.find((ws) => ws.id === orgid) || {} + () => workspaces.find(({ id }) => id === orgid) || {} ) return ( @@ -70,14 +70,13 @@ export function WorkspaceProvider({ export function WorkspaceSwitcher() { const location = useLocation() - const { orgid } = useParams() const { isMobile, state } = useSidebar() const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce() const [, fragment, _] = location.pathname.slice(1).split('/') - const onSelect = (ws: Workspace) => { - setActiveWorkspace(ws) - window.location.assign(`/${ws.id}/${fragment}`) + const onSelect = (workspace: Workspace) => { + setActiveWorkspace(workspace) + window.location.assign(`/${workspace.id}/${fragment}`) } return ( diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx index 14ea3bd..38f5b59 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx @@ -1,8 +1,8 @@ -import type { Route } from './+types' +import type { Route } from './+types/route' import { useToggle } from 'ahooks' import { EllipsisIcon, PencilIcon, UserRoundMinusIcon } from 'lucide-react' -import { Suspense } from 'react' +import { Suspense, type MouseEvent } from 'react' import { Await, NavLink, useParams, useRevalidator } from 'react-router' import { toast } from 'sonner' @@ -48,12 +48,10 @@ export async function loader({ context, request, params }: Route.LoaderArgs) { request }).then((r) => r.json()) - return { - data: users - } + return { users } } -export default function Route({ loaderData: { data } }: Route.ComponentProps) { +export default function Route({ loaderData: { users } }: Route.ComponentProps) { return ( }>
@@ -63,48 +61,53 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {

- - {({ items }) => { - return ( -
- {items.map(({ sk, name, email }: Admin) => { - const [_, id] = sk.split('#') + + {({ items }) => ( +
+ {items.map(({ sk, name, email }: Admin) => { + const [_, id] = sk.split('#') - return ( -
- -
-
-
- - - {initials(name)} - - -
- -
-

- {name} -

-

- {email} -

-
+ return ( +
+ +
+
+
+ + + {initials(name)} + +
-
- ) - })} -
- ) - }} + +
+

+ {name} +

+

+ {email} +

+
+
+
+ ) + })} +
+ )}
) @@ -116,7 +119,8 @@ function ActionMenu({ id }: { id: string }) { + + + + + ) +} + +const datetime = new Intl.DateTimeFormat('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric' +}) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx index 8f74b8b..6f6ef42 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx @@ -1,21 +1,186 @@ import type { Route } from './+types/route' +import { DateTime } from 'luxon' +import { Suspense } from 'react' +import { ClockIcon } from 'lucide-react' + +import { request as req } from '@repo/util/request' +import { Skeleton } from '@repo/ui/components/skeleton' +import { Await } from 'react-router' +import { billingPeriod, formatDate } from './util' +import { Card, CardContent } from '@repo/ui/components/ui/card' +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow +} from '@repo/ui/components/ui/table' +import { Abbr } from '@repo/ui/components/abbr' + +import { RangePeriod } from './range-period' +import { Button } from '@repo/ui/components/ui/button' + export function meta({}) { return [{ title: 'Resumo de cobranças' }] } -export default function Route({}: Route.ComponentProps) { +export async function loader({ context, request, params }: Route.LoaderArgs) { + const { searchParams } = new URL(request.url) + const subscription = await req({ + url: `/orgs/${params.orgid}/subscription`, + context, + request + }).then((r) => r.json()) + + const [startDate, endDate] = billingPeriod( + subscription?.billing_day, + new Date() + ) + const start = searchParams.get('start') || formatDate(startDate) + const end = searchParams.get('end') || formatDate(endDate) + + const billing = req({ + url: `/orgs/${params.orgid}/billing?start_date=${start}&end_date=${end}`, + context, + request + }).then((r) => r.json()) + + return { + subscription, + billing, + startDate: DateTime.fromISO(start).toJSDate(), + endDate: DateTime.fromISO(end).toJSDate() + } +} + +export default function Route({ + loaderData: { subscription, billing, startDate, endDate } +}: Route.ComponentProps) { + const sk = `START#${formatDate(startDate)}#END#${formatDate(endDate)}` + return ( <> -
-

- Resumo de cobranças -

-

- Acompanhe as cobranças em tempo real e garanta mais eficiência no - controle financeiro. -

-
+ }> +
+

+ Resumo de cobranças +

+

+ Acompanhe as cobranças em tempo real e garanta mais eficiência no + controle financeiro. +

+
+ + + {({ items }) => { + const billing = items.find((item) => item.sk === sk) + + return ( + + +
+ + + +
+ + + + + Colaborador + Curso + Matriculado por + Matriculado em + Valor unit. + + + + {items + ?.filter((item) => 'course' in item) + ?.map( + ( + { + user, + course, + author: created_by, + unit_price, + enrolled_at + }, + index + ) => ( + + + {user.name} + + + {course.name} + + + + {created_by ? created_by.name : 'N/A'} + + + + {datetime.format(new Date(enrolled_at))} + + + {currency.format(unit_price)} + + + ) + )} + + + + + Total + + + {currency.format( + items + ?.filter((item) => 'course' in item) + .reduce( + (acc, { unit_price }) => acc + unit_price, + 0 + ) + )} + + + +
+
+
+ ) + }} +
+
) } +const currency = new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL' +}) + +const datetime = new Intl.DateTimeFormat('pt-BR', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' +}) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/util.ts b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/util.ts new file mode 100644 index 0000000..633c408 --- /dev/null +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/util.ts @@ -0,0 +1,50 @@ +export function billingPeriod(billingDay: number, date: Date) { + // Determine the anchor month and year + let anchorMonth, anchorYear + + if (date.getDate() >= billingDay) { + anchorMonth = date.getMonth() + 1 // JavaScript months are 0-11 + anchorYear = date.getFullYear() + } else { + // Move to previous month + if (date.getMonth() === 0) { + // January + anchorMonth = 12 + anchorYear = date.getFullYear() - 1 + } else { + anchorMonth = date.getMonth() // No +1 because we're going backwards + anchorYear = date.getFullYear() + } + } + + // Calculate start date + const lastDayOfMonth = new Date(anchorYear, anchorMonth, 0).getDate() + const startDay = Math.min(billingDay, lastDayOfMonth) + const startDate = new Date(anchorYear, anchorMonth - 1, startDay) // -1 because JS months are 0-based + + // Calculate next month and year + let nextMonth, nextYear + if (anchorMonth === 12) { + nextMonth = 1 + nextYear = anchorYear + 1 + } else { + nextMonth = anchorMonth + 1 + nextYear = anchorYear + } + + // Calculate end date + const nextLastDayOfMonth = new Date(nextYear, nextMonth, 0).getDate() + const endDay = Math.min(billingDay, nextLastDayOfMonth) + const endDate = new Date(nextYear, nextMonth - 1, endDay) + endDate.setDate(endDate.getDate() - 1) // Subtract one day + + return [startDate, endDate] +} + +export function formatDate(date = new Date()) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') // e.g: January = 01 + const day = String(date.getDate()).padStart(2, '0') + + return `${year}-${month}-${day}` +} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.courses._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.courses._index/route.tsx index 7f9f474..9875d75 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.courses._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.courses._index/route.tsx @@ -70,7 +70,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) { export default function Route({ loaderData: { data } }: Route.ComponentProps) { const [searchParams, setSearchParams] = useSearchParams() - const s = searchParams.get('s') as string + const search = searchParams.get('s') as string return ( }> @@ -104,7 +104,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
- + ) }} @@ -114,11 +118,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) { } function List({ - s, + search, hits = [], customPricing = [] }: { - s: string + search: string hits: Course[] customPricing: CustomPricing[] }) { @@ -131,12 +135,12 @@ function List({ }, [hits]) const hits_ = useMemo(() => { - if (!s) { + if (!search) { return hits } - return fuse.search(s).map(({ item }) => item) - }, [s, fuse, hits]) + return fuse.search(search).map(({ item }) => item) + }, [search, fuse, hits]) const customPricingMap = new Map( customPricing.map((x) => { @@ -154,7 +158,7 @@ function List({ Nada encontrado - Nenhum resultado para {s}. + Nenhum resultado para {search}. diff --git a/enrollments-events/app/events/stopgap/set_subscription_covered.py b/enrollments-events/app/events/stopgap/set_subscription_covered.py index 3f5b53f..97b9b31 100644 --- a/enrollments-events/app/events/stopgap/set_subscription_covered.py +++ b/enrollments-events/app/events/stopgap/set_subscription_covered.py @@ -25,7 +25,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: now_ = now() terms = user_layer.get_item( # Post-migration (users): uncomment the following line - # KeyPair(new_image['org_id'], 'METADATA#SUBSCRIPTION_TERMS'), + # KeyPair(new_image['org_id'], 'METADATA#SUBSCRIPTION'), KeyPair(new_image['org_id'], 'metadata#billing_policy'), ) diff --git a/packages/ui/src/components/data-table/data-table.tsx b/packages/ui/src/components/data-table/data-table.tsx index 0d18579..16d08c9 100644 --- a/packages/ui/src/components/data-table/data-table.tsx +++ b/packages/ui/src/components/data-table/data-table.tsx @@ -18,7 +18,6 @@ import { useContext, useEffect, useMemo, - useRef, useState, type ReactNode } from 'react' diff --git a/packages/ui/src/components/range-calendar-filter.tsx b/packages/ui/src/components/range-calendar-filter.tsx index de44cbb..5c67686 100644 --- a/packages/ui/src/components/range-calendar-filter.tsx +++ b/packages/ui/src/components/range-calendar-filter.tsx @@ -87,7 +87,7 @@ export function RangeCalendarFilter({ {formatted.format(dateRange.to)} diff --git a/packages/ui/src/components/ui/button-group.tsx b/packages/ui/src/components/ui/button-group.tsx new file mode 100644 index 0000000..8600af0 --- /dev/null +++ b/packages/ui/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx index 21409a0..37a7d4b 100644 --- a/packages/ui/src/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -38,8 +38,8 @@ const buttonVariants = cva( function Button({ className, - variant, - size, + variant = "default", + size = "default", asChild = false, ...props }: React.ComponentProps<"button"> & @@ -51,6 +51,8 @@ function Button({ return (