add billing
This commit is contained in:
@@ -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,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'
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user