add billing
This commit is contained in:
@@ -56,7 +56,7 @@ class DeduplicationWindow(BaseModel):
|
|||||||
offset_days: int
|
offset_days: int
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionTerms(BaseModel):
|
class Subscription(BaseModel):
|
||||||
billing_day: int
|
billing_day: int
|
||||||
|
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ def enroll(
|
|||||||
)
|
)
|
||||||
+ KeyPair(
|
+ KeyPair(
|
||||||
pk=str(org_id),
|
pk=str(org_id),
|
||||||
sk='METADATA#SUBSCRIPTION_TERMS',
|
sk='METADATA#SUBSCRIPTION',
|
||||||
rename_key='terms',
|
rename_key='subscription',
|
||||||
table_name=USER_TABLE,
|
table_name=USER_TABLE,
|
||||||
)
|
)
|
||||||
+ KeyPair(
|
+ KeyPair(
|
||||||
@@ -106,7 +106,7 @@ def enroll(
|
|||||||
ctx = {
|
ctx = {
|
||||||
'org': Org.model_validate(org),
|
'org': Org.model_validate(org),
|
||||||
'created_by': created_by,
|
'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]
|
immediate = [e for e in enrollments if not e.scheduled_for]
|
||||||
@@ -150,7 +150,7 @@ Context = TypedDict(
|
|||||||
'Context',
|
'Context',
|
||||||
{
|
{
|
||||||
'org': Org,
|
'org': Org,
|
||||||
'terms': SubscriptionTerms,
|
'subscription': Subscription,
|
||||||
'created_by': Authenticated,
|
'created_by': Authenticated,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -161,7 +161,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
|||||||
user = enrollment.user
|
user = enrollment.user
|
||||||
course = enrollment.course
|
course = enrollment.course
|
||||||
org: Org = context['org']
|
org: Org = context['org']
|
||||||
subscription_terms: SubscriptionTerms = context['terms']
|
subscription: Subscription = context['subscription']
|
||||||
created_by: Authenticated = context['created_by']
|
created_by: Authenticated = context['created_by']
|
||||||
lock_hash = md5_hash(f'{user.id}{course.id}')
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
access_expires_at = now_ + timedelta(days=course.access_period)
|
access_expires_at = now_ + timedelta(days=course.access_period)
|
||||||
@@ -213,7 +213,7 @@ def enroll_now(enrollment: Enrollment, context: Context):
|
|||||||
'id': enrollment.id,
|
'id': enrollment.id,
|
||||||
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
'sk': 'METADATA#SUBSCRIPTION_COVERED',
|
||||||
'org_id': org.id,
|
'org_id': org.id,
|
||||||
'billing_day': subscription_terms.billing_day,
|
'billing_day': subscription.billing_day,
|
||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -270,7 +270,7 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
|||||||
scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore
|
scheduled_for = date_to_midnight(enrollment.scheduled_for) # type: ignore
|
||||||
deduplication_window = enrollment.deduplication_window
|
deduplication_window = enrollment.deduplication_window
|
||||||
org: Org = context['org']
|
org: Org = context['org']
|
||||||
subscription_terms: SubscriptionTerms = context['terms']
|
subscription: Subscription = context['subscription']
|
||||||
created_by: Authenticated = context['created_by']
|
created_by: Authenticated = context['created_by']
|
||||||
lock_hash = md5_hash(f'{user.id}{course.id}')
|
lock_hash = md5_hash(f'{user.id}{course.id}')
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ def enroll_later(enrollment: Enrollment, context: Context):
|
|||||||
'id': created_by.id,
|
'id': created_by.id,
|
||||||
'name': created_by.name,
|
'name': created_by.name,
|
||||||
},
|
},
|
||||||
'subscription_billing_day': subscription_terms.billing_day,
|
'subscription_billing_day': subscription.billing_day,
|
||||||
'ttl': ttl(start_dt=scheduled_for),
|
'ttl': ttl(start_dt=scheduled_for),
|
||||||
'created_at': now_,
|
'created_at': now_,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,27 @@ from aws_lambda_powertools.event_handler.openapi.params import Query
|
|||||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||||
|
|
||||||
from boto3clients import dynamodb_client
|
from boto3clients import dynamodb_client
|
||||||
from config import COURSE_TABLE
|
from config import ORDER_TABLE, USER_TABLE
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
dyn = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
|
dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/<org_id>/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('/<org_id>/billing')
|
@router.get('/<org_id>/billing')
|
||||||
def get_custom_pricing(
|
def billing(
|
||||||
org_id: str,
|
org_id: str,
|
||||||
start_date: Annotated[date, Query()],
|
start_date: Annotated[date, Query()],
|
||||||
end_date: Annotated[date, Query()],
|
end_date: Annotated[date, Query()],
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import {
|
import {
|
||||||
BookCopyIcon,
|
BookCopyIcon,
|
||||||
CalendarClockIcon,
|
CalendarClockIcon,
|
||||||
FileBadgeIcon,
|
// FileBadgeIcon,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
ReceiptTextIcon,
|
ReceiptTextIcon,
|
||||||
ShieldUserIcon,
|
ShieldUserIcon,
|
||||||
ShoppingCartIcon,
|
ShoppingCartIcon,
|
||||||
UploadIcon,
|
// UploadIcon,
|
||||||
UsersIcon
|
UsersIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { type LucideIcon } from 'lucide-react'
|
||||||
|
import { NavLink, useParams } from 'react-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -11,9 +14,6 @@ import {
|
|||||||
} from '@repo/ui/components/ui/sidebar'
|
} from '@repo/ui/components/ui/sidebar'
|
||||||
import { useIsMobile } from '@repo/ui/hooks/use-mobile'
|
import { useIsMobile } from '@repo/ui/hooks/use-mobile'
|
||||||
|
|
||||||
import { type LucideIcon } from 'lucide-react'
|
|
||||||
import { NavLink, useParams } from 'react-router'
|
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
title: string
|
title: string
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function WorkspaceProvider({
|
|||||||
}) {
|
}) {
|
||||||
const { orgid } = useParams()
|
const { orgid } = useParams()
|
||||||
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | any>(
|
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | any>(
|
||||||
() => workspaces.find((ws) => ws.id === orgid) || {}
|
() => workspaces.find(({ id }) => id === orgid) || {}
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,14 +70,13 @@ export function WorkspaceProvider({
|
|||||||
|
|
||||||
export function WorkspaceSwitcher() {
|
export function WorkspaceSwitcher() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { orgid } = useParams()
|
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
|
const { activeWorkspace, setActiveWorkspace, workspaces } = useWorksapce()
|
||||||
const [, fragment, _] = location.pathname.slice(1).split('/')
|
const [, fragment, _] = location.pathname.slice(1).split('/')
|
||||||
|
|
||||||
const onSelect = (ws: Workspace) => {
|
const onSelect = (workspace: Workspace) => {
|
||||||
setActiveWorkspace(ws)
|
setActiveWorkspace(workspace)
|
||||||
window.location.assign(`/${ws.id}/${fragment}`)
|
window.location.assign(`/${workspace.id}/${fragment}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Route } from './+types'
|
import type { Route } from './+types/route'
|
||||||
|
|
||||||
import { useToggle } from 'ahooks'
|
import { useToggle } from 'ahooks'
|
||||||
import { EllipsisIcon, PencilIcon, UserRoundMinusIcon } from 'lucide-react'
|
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 { Await, NavLink, useParams, useRevalidator } from 'react-router'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -48,12 +48,10 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
|
|||||||
request
|
request
|
||||||
}).then((r) => r.json())
|
}).then((r) => r.json())
|
||||||
|
|
||||||
return {
|
return { users }
|
||||||
data: users
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
export default function Route({ loaderData: { users } }: Route.ComponentProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Skeleton />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
<div className="space-y-0.5 mb-8">
|
<div className="space-y-0.5 mb-8">
|
||||||
@@ -63,9 +61,8 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Await resolve={data}>
|
<Await resolve={users}>
|
||||||
{({ items }) => {
|
{({ items }) => (
|
||||||
return (
|
|
||||||
<div className="grid gap-4 lg:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 lg:gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{items.map(({ sk, name, email }: Admin) => {
|
{items.map(({ sk, name, email }: Admin) => {
|
||||||
const [_, id] = sk.split('#')
|
const [_, id] = sk.split('#')
|
||||||
@@ -73,13 +70,17 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
key={id}
|
key={id}
|
||||||
className="bg-card border-border/50 hover:shadow-muted-foreground/10 hover:border-muted group
|
className="group relative p-8 overflow-hidden bg-card
|
||||||
relative overflow-hidden rounded-2xl border p-8 transition-all duration-300 hover:shadow-2xl"
|
border border-border/50 rounded-2xl
|
||||||
|
hover:shadow-muted-foreground/10 hover:border-muted
|
||||||
|
hover:shadow-2xl transition-all duration-300"
|
||||||
>
|
>
|
||||||
<ActionMenu id={id} />
|
<ActionMenu id={id} />
|
||||||
<div
|
<div
|
||||||
className="from-muted-foreground/5 absolute inset-0 bg-gradient-to-br to-transparent
|
className="from-muted-foreground/5 absolute inset-0
|
||||||
opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
bg-linear-to-br to-transparent opacity-0
|
||||||
|
transition-opacity duration-300
|
||||||
|
group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col items-center text-center">
|
<div className="relative flex flex-col items-center text-center">
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
@@ -94,7 +95,10 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
<h1 className="mb-2 text-xl font-bold">
|
<h1 className="mb-2 text-xl font-bold">
|
||||||
<Abbr>{name}</Abbr>
|
<Abbr>{name}</Abbr>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground bg-muted/50 inline-block rounded-full px-4 py-1.5 text-sm font-medium">
|
<p
|
||||||
|
className="text-muted-foreground bg-muted/50 inline-block
|
||||||
|
rounded-full px-4 py-1.5 text-sm font-medium"
|
||||||
|
>
|
||||||
<Abbr>{email}</Abbr>
|
<Abbr>{email}</Abbr>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +107,7 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}}
|
|
||||||
</Await>
|
</Await>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
@@ -116,7 +119,8 @@ function ActionMenu({ id }: { id: string }) {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer absolute z-1 right-4 top-4"
|
className="data-[state=open]:bg-muted text-muted-foreground
|
||||||
|
cursor-pointer absolute z-1 right-4 top-4"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
>
|
>
|
||||||
<EllipsisIcon />
|
<EllipsisIcon />
|
||||||
@@ -145,7 +149,7 @@ function RevokeItem({ id }: { id: string }) {
|
|||||||
const { orgid } = useParams()
|
const { orgid } = useParams()
|
||||||
const { revalidate } = useRevalidator()
|
const { revalidate } = useRevalidator()
|
||||||
|
|
||||||
const revoke = async (e) => {
|
const revoke = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
set(true)
|
set(true)
|
||||||
|
|
||||||
|
|||||||
@@ -21,18 +21,20 @@ export function meta({}: Route.MetaArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ context, request, params }: Route.LoaderArgs) {
|
export async function loader({ context, request, params }: Route.LoaderArgs) {
|
||||||
const data = req({
|
const scheduled = req({
|
||||||
url: `/orgs/${params.orgid}/enrollments/scheduled`,
|
url: `/orgs/${params.orgid}/enrollments/scheduled`,
|
||||||
context,
|
context,
|
||||||
request
|
request
|
||||||
}).then((r) => r.json())
|
}).then((r) => r.json())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data
|
scheduled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
export default function Route({
|
||||||
|
loaderData: { scheduled }
|
||||||
|
}: Route.ComponentProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Skeleton />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
<div className="space-y-0.5 mb-8">
|
<div className="space-y-0.5 mb-8">
|
||||||
@@ -45,7 +47,7 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Await resolve={data}>
|
<Await resolve={scheduled}>
|
||||||
{(resolved) => (
|
{(resolved) => (
|
||||||
<>
|
<>
|
||||||
<Empty className="border border-dashed">
|
<Empty className="border border-dashed">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ChevronRightIcon, ChevronLeftIcon } from 'lucide-react'
|
||||||
|
import { subMonths, addMonths } from 'date-fns'
|
||||||
|
|
||||||
|
import { Button } from '@repo/ui/components/ui/button'
|
||||||
|
import { ButtonGroup } from '@repo/ui/components/ui/button-group'
|
||||||
|
import { Badge } from '@repo/ui/components/ui/badge'
|
||||||
|
|
||||||
|
import { formatDate, billingPeriod } from './util'
|
||||||
|
import { useSearchParams } from 'react-router'
|
||||||
|
|
||||||
|
type RangePeriodProps = {
|
||||||
|
startDate: Date
|
||||||
|
endDate: Date
|
||||||
|
billingDay: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RangePeriod({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
billingDay
|
||||||
|
}: RangePeriodProps) {
|
||||||
|
const [, setSearchParams] = useSearchParams()
|
||||||
|
const prevPeriod = billingPeriod(billingDay, subMonths(startDate, 1))
|
||||||
|
const nextPeriod = billingPeriod(billingDay, addMonths(endDate, 1))
|
||||||
|
const [nextDate] = nextPeriod
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-dashed cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchParams((searchParams) => {
|
||||||
|
const [start, end] = prevPeriod
|
||||||
|
searchParams.set('start', formatDate(start))
|
||||||
|
searchParams.set('end', formatDate(end))
|
||||||
|
return searchParams
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="pointer-events-none border-dashed">
|
||||||
|
<div className="gap-1 flex">
|
||||||
|
<Badge variant="outline" className="rounded-sm px-1 font-mono">
|
||||||
|
{datetime.format(startDate)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="rounded-sm px-1 font-mono">
|
||||||
|
{datetime.format(endDate)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-dashed cursor-pointer"
|
||||||
|
disabled={nextDate > new Date()}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchParams((searchParams) => {
|
||||||
|
const [start, end] = nextPeriod
|
||||||
|
searchParams.set('start', formatDate(start))
|
||||||
|
searchParams.set('end', formatDate(end))
|
||||||
|
return searchParams
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const datetime = new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
@@ -1,12 +1,69 @@
|
|||||||
import type { Route } from './+types/route'
|
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({}) {
|
export function meta({}) {
|
||||||
return [{ title: 'Resumo de cobranças' }]
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
<div className="space-y-0.5 mb-8">
|
<div className="space-y-0.5 mb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
Resumo de cobranças
|
Resumo de cobranças
|
||||||
@@ -16,6 +73,114 @@ export default function Route({}: Route.ComponentProps) {
|
|||||||
controle financeiro.
|
controle financeiro.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Await resolve={billing}>
|
||||||
|
{({ items }) => {
|
||||||
|
const billing = items.find((item) => item.sk === sk)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-2.5">
|
||||||
|
<div className="flex max-lg:flex-col gap-2.5">
|
||||||
|
<Button
|
||||||
|
className="pointer-events-none"
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<ClockIcon className="size-3.5" /> {billing?.status}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<RangePeriod
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
billingDay={subscription.billing_day}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table className="table-auto w-full">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Colaborador</TableHead>
|
||||||
|
<TableHead>Curso</TableHead>
|
||||||
|
<TableHead>Matriculado por</TableHead>
|
||||||
|
<TableHead>Matriculado em</TableHead>
|
||||||
|
<TableHead>Valor unit.</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items
|
||||||
|
?.filter((item) => 'course' in item)
|
||||||
|
?.map(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
user,
|
||||||
|
course,
|
||||||
|
author: created_by,
|
||||||
|
unit_price,
|
||||||
|
enrolled_at
|
||||||
|
},
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<Abbr>{user.name}</Abbr>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Abbr>{course.name}</Abbr>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Abbr>
|
||||||
|
{created_by ? created_by.name : 'N/A'}
|
||||||
|
</Abbr>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{datetime.format(new Date(enrolled_at))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{currency.format(unit_price)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-right">
|
||||||
|
Total
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{currency.format(
|
||||||
|
items
|
||||||
|
?.filter((item) => 'course' in item)
|
||||||
|
.reduce(
|
||||||
|
(acc, { unit_price }) => acc + unit_price,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -70,7 +70,7 @@ export async function loader({ context, request, params }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const s = searchParams.get('s') as string
|
const search = searchParams.get('s') as string
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Skeleton />}>
|
<Suspense fallback={<Skeleton />}>
|
||||||
@@ -104,7 +104,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List s={s} hits={hits as Course[]} customPricing={items} />
|
<List
|
||||||
|
search={search}
|
||||||
|
hits={hits as Course[]}
|
||||||
|
customPricing={items}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -114,11 +118,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function List({
|
function List({
|
||||||
s,
|
search,
|
||||||
hits = [],
|
hits = [],
|
||||||
customPricing = []
|
customPricing = []
|
||||||
}: {
|
}: {
|
||||||
s: string
|
search: string
|
||||||
hits: Course[]
|
hits: Course[]
|
||||||
customPricing: CustomPricing[]
|
customPricing: CustomPricing[]
|
||||||
}) {
|
}) {
|
||||||
@@ -131,12 +135,12 @@ function List({
|
|||||||
}, [hits])
|
}, [hits])
|
||||||
|
|
||||||
const hits_ = useMemo(() => {
|
const hits_ = useMemo(() => {
|
||||||
if (!s) {
|
if (!search) {
|
||||||
return hits
|
return hits
|
||||||
}
|
}
|
||||||
|
|
||||||
return fuse.search(s).map(({ item }) => item)
|
return fuse.search(search).map(({ item }) => item)
|
||||||
}, [s, fuse, hits])
|
}, [search, fuse, hits])
|
||||||
|
|
||||||
const customPricingMap = new Map(
|
const customPricingMap = new Map(
|
||||||
customPricing.map((x) => {
|
customPricing.map((x) => {
|
||||||
@@ -154,7 +158,7 @@ function List({
|
|||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nada encontrado</EmptyTitle>
|
<EmptyTitle>Nada encontrado</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
Nenhum resultado para <mark>{s}</mark>.
|
Nenhum resultado para <mark>{search}</mark>.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
|||||||
now_ = now()
|
now_ = now()
|
||||||
terms = user_layer.get_item(
|
terms = user_layer.get_item(
|
||||||
# Post-migration (users): uncomment the following line
|
# 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'),
|
KeyPair(new_image['org_id'], 'metadata#billing_policy'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
type ReactNode
|
type ReactNode
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|||||||
83
packages/ui/src/components/ui/button-group.tsx
Normal file
83
packages/ui/src/components/ui/button-group.tsx
Normal file
@@ -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<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ const buttonVariants = cva(
|
|||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant = "default",
|
||||||
size,
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
@@ -51,6 +51,8 @@ function Button({
|
|||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user