add billing

This commit is contained in:
2025-12-12 20:28:47 -03:00
parent 3147ec2317
commit c516960b01
16 changed files with 496 additions and 97 deletions

View File

@@ -3,13 +3,13 @@
import {
BookCopyIcon,
CalendarClockIcon,
FileBadgeIcon,
// FileBadgeIcon,
GraduationCap,
LayoutDashboardIcon,
ReceiptTextIcon,
ShieldUserIcon,
ShoppingCartIcon,
UploadIcon,
// UploadIcon,
UsersIcon
} from 'lucide-react'

View File

@@ -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

View File

@@ -56,7 +56,7 @@ export function WorkspaceProvider({
}) {
const { orgid } = useParams()
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | any>(
() => 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 (

View File

@@ -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 (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
@@ -63,48 +61,53 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</p>
</div>
<Await resolve={data}>
{({ items }) => {
return (
<div className="grid gap-4 lg:gap-8 md:grid-cols-2 lg:grid-cols-3">
{items.map(({ sk, name, email }: Admin) => {
const [_, id] = sk.split('#')
<Await resolve={users}>
{({ items }) => (
<div className="grid gap-4 lg:gap-8 md:grid-cols-2 lg:grid-cols-3">
{items.map(({ sk, name, email }: Admin) => {
const [_, id] = sk.split('#')
return (
<section
key={id}
className="bg-card border-border/50 hover:shadow-muted-foreground/10 hover:border-muted group
relative overflow-hidden rounded-2xl border p-8 transition-all duration-300 hover:shadow-2xl"
>
<ActionMenu id={id} />
<div
className="from-muted-foreground/5 absolute inset-0 bg-gradient-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 mb-6">
<Avatar className="size-24 lg:size-28">
<AvatarFallback className="text-2xl">
{initials(name)}
</AvatarFallback>
</Avatar>
</div>
<div className="mb-6">
<h1 className="mb-2 text-xl font-bold">
<Abbr>{name}</Abbr>
</h1>
<p className="text-muted-foreground bg-muted/50 inline-block rounded-full px-4 py-1.5 text-sm font-medium">
<Abbr>{email}</Abbr>
</p>
</div>
return (
<section
key={id}
className="group relative p-8 overflow-hidden bg-card
border border-border/50 rounded-2xl
hover:shadow-muted-foreground/10 hover:border-muted
hover:shadow-2xl transition-all duration-300"
>
<ActionMenu id={id} />
<div
className="from-muted-foreground/5 absolute inset-0
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 mb-6">
<Avatar className="size-24 lg:size-28">
<AvatarFallback className="text-2xl">
{initials(name)}
</AvatarFallback>
</Avatar>
</div>
</section>
)
})}
</div>
)
}}
<div className="mb-6">
<h1 className="mb-2 text-xl font-bold">
<Abbr>{name}</Abbr>
</h1>
<p
className="text-muted-foreground bg-muted/50 inline-block
rounded-full px-4 py-1.5 text-sm font-medium"
>
<Abbr>{email}</Abbr>
</p>
</div>
</div>
</section>
)
})}
</div>
)}
</Await>
</Suspense>
)
@@ -116,7 +119,8 @@ function ActionMenu({ id }: { id: string }) {
<DropdownMenuTrigger asChild>
<Button
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"
>
<EllipsisIcon />
@@ -145,7 +149,7 @@ function RevokeItem({ id }: { id: string }) {
const { orgid } = useParams()
const { revalidate } = useRevalidator()
const revoke = async (e) => {
const revoke = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
set(true)

View File

@@ -21,18 +21,20 @@ export function meta({}: Route.MetaArgs) {
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const data = req({
const scheduled = req({
url: `/orgs/${params.orgid}/enrollments/scheduled`,
context,
request
}).then((r) => r.json())
return {
data
scheduled
}
}
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
export default function Route({
loaderData: { scheduled }
}: Route.ComponentProps) {
return (
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
@@ -45,7 +47,7 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</p>
</div>
<Await resolve={data}>
<Await resolve={scheduled}>
{(resolved) => (
<>
<Empty className="border border-dashed">

View File

@@ -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'
})

View File

@@ -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 (
<>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Resumo de cobranças
</h1>
<p className="text-muted-foreground">
Acompanhe as cobranças em tempo real e garanta mais eficiência no
controle financeiro.
</p>
</div>
<Suspense fallback={<Skeleton />}>
<div className="space-y-0.5 mb-8">
<h1 className="text-2xl font-bold tracking-tight">
Resumo de cobranças
</h1>
<p className="text-muted-foreground">
Acompanhe as cobranças em tempo real e garanta mais eficiência no
controle financeiro.
</p>
</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'
})

View File

@@ -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}`
}

View File

@@ -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 (
<Suspense fallback={<Skeleton />}>
@@ -104,7 +104,11 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
</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({
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({
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{s}</mark>.
Nenhum resultado para <mark>{search}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>