Files
saladeaula.digital/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx

332 lines
9.8 KiB
TypeScript

import type { Route } from './+types/route'
import Fuse from 'fuse.js'
import { DateTime as LuxonDateTime } from 'luxon'
import { Suspense, useMemo } from 'react'
import { BanIcon } from 'lucide-react'
import { Await, useSearchParams } from 'react-router'
import { cn } from '@repo/ui/lib/utils'
import { request as req } from '@repo/util/request'
import { Skeleton } from '@repo/ui/components/skeleton'
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 { Button } from '@repo/ui/components/ui/button'
import { SearchForm } from '@repo/ui/components/search-form'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime'
import { billingPeriod, formatDate } from './util'
import { RangePeriod } from './range-period'
import { tz, statuses } from './data'
export function meta({}) {
return [{ title: 'Resumo de cobranças' }]
}
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
})
const { billing_day = 1 } = (await subscription.json()) as {
billing_day: number
}
const [startDate, endDate] = billingPeriod(
billing_day,
LuxonDateTime.now().setZone(tz).toJSDate()
)
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 {
billing_day,
billing,
startDate: LuxonDateTime.fromISO(start, { zone: tz }).toJSDate(),
endDate: LuxonDateTime.fromISO(end, { zone: tz }).toJSDate()
}
}
export default function Route({
loaderData: { billing_day, billing, startDate, endDate }
}: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams()
const search = searchParams.get('s') as string
return (
<>
<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 = [], ...billing }) => {
const {
icon: Icon,
label: status,
color
} = statuses?.[billing?.status || 'CLOSED']
return (
<Card>
<CardContent className="space-y-4">
<div className="flex max-lg:flex-col gap-2.5">
<div className="w-full xl:w-1/4">
<SearchForm
defaultValue={search || ''}
placeholder={
<>
Digite <Kbd className="border font-mono">/</Kbd>{' '}
para pesquisar
</>
}
onChange={(value) =>
setSearchParams((searchParams) => {
searchParams.set('s', String(value))
return searchParams
})
}
/>
</div>
<RangePeriod
startDate={startDate}
endDate={endDate}
billingDay={billing_day}
/>
<Button
className={cn('pointer-events-none lg:ml-auto', color)}
variant="outline"
asChild
>
<span>
<Icon className="size-3.5" /> {status}
</span>
</Button>
</div>
<List items={items} search={search} />
</CardContent>
</Card>
)
}}
</Await>
</Suspense>
</>
)
}
function List({ items, search }) {
const fuse = useMemo(() => {
return new Fuse(items, {
keys: ['user.name'],
threshold: 0.3,
includeMatches: true
})
}, [items])
const filtered = useMemo(() => {
if (!search) {
return items
}
return fuse.search(search).map(({ item }) => item)
}, [search, fuse, items])
const charges = filtered
?.filter((item) => item?.unit_price > 0)
?.sort(sortBy('enrolled_at'))
const credits = filtered
?.filter((item) => item?.unit_price < 0)
?.sort(sortBy('created_at'))
if (items.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nenhuma cobrança encontrada</EmptyTitle>
<EmptyDescription>
Não nenhuma cobrança para este período.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
if (filtered.length === 0) {
return (
<Empty className="border border-dashed">
<EmptyHeader>
<EmptyMedia variant="icon">
<BanIcon />
</EmptyMedia>
<EmptyTitle>Nada encontrado</EmptyTitle>
<EmptyDescription>
Nenhum resultado para <mark>{search}</mark>.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
const subtotal = filtered
?.filter(({ unit_price }) => unit_price > 0)
?.reduce((acc, { unit_price }) => acc + unit_price, 0)
const discounts = filtered
?.filter(({ unit_price }) => unit_price < 0)
?.reduce((acc, { unit_price }) => acc + unit_price, 0)
const total = filtered?.reduce((acc, { unit_price }) => acc + unit_price, 0)
return (
<Table className="table-auto w-full">
{charges.length ? (
<>
<TableHeader>
<TableRow className="bg-muted-foreground/10 pointer-events-none">
<TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead>
<TableHead>Matriculado por</TableHead>
<TableHead>Matriculado em</TableHead>
<TableHead>Valor unit.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{charges?.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>{enrolled_at}</DateTime>
</TableCell>
<TableCell>
<Currency>{unit_price}</Currency>
</TableCell>
</TableRow>
)
)}
</TableBody>
</>
) : null}
{credits.length ? (
<>
<TableHeader>
<TableRow className="bg-muted-foreground/10 pointer-events-none border-t">
<TableHead colSpan={2}></TableHead>
<TableHead>Cancelado por</TableHead>
<TableHead colSpan={2}>Cancelado em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{credits?.map(
(
{ user, course, author: canceled_by, unit_price, created_at },
index
) => (
<TableRow key={index}>
<TableCell>
<Abbr>{user.name}</Abbr>
</TableCell>
<TableCell>
<Abbr>{course.name}</Abbr>
</TableCell>
<TableCell>
<Abbr>{canceled_by ? canceled_by.name : 'N/A'}</Abbr>
</TableCell>
<TableCell>
<DateTime>{created_at}</DateTime>
</TableCell>
<TableCell>
<Currency>{unit_price}</Currency>
</TableCell>
</TableRow>
)
)}
</TableBody>
</>
) : null}
<TableFooter>
<TableRow>
<TableCell colSpan={4} className="text-right pointer-events-none">
Subtotal
</TableCell>
<TableCell>
<Currency>{subtotal}</Currency>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4} className="text-right pointer-events-none">
Descontos
</TableCell>
<TableCell>
<Currency>{discounts}</Currency>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4} className="text-right pointer-events-none">
Total
</TableCell>
<TableCell>
<Currency>{total}</Currency>
</TableCell>
</TableRow>
</TableFooter>
</Table>
)
}
const sortBy = (field: 'enrolled_at' | 'created_at') => (a: any, b: any) =>
new Date(a[field]).getTime() - new Date(b[field]).getTime()