314 lines
9.3 KiB
TypeScript
314 lines
9.3 KiB
TypeScript
import type { Route } from './+types/route'
|
|
|
|
import Fuse from 'fuse.js'
|
|
import { DateTime } 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 { 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
|
|
}).then((r) => r.json())
|
|
|
|
const [startDate, endDate] = billingPeriod(
|
|
subscription?.billing_day || 1,
|
|
DateTime.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 {
|
|
subscription,
|
|
billing,
|
|
startDate: DateTime.fromISO(start, { zone: tz }).toJSDate(),
|
|
endDate: DateTime.fromISO(end, { zone: tz }).toJSDate()
|
|
}
|
|
}
|
|
|
|
export default function Route({
|
|
loaderData: { subscription, 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>/</Kbd> para pesquisar
|
|
</>
|
|
}
|
|
onChange={(value) =>
|
|
setSearchParams((searchParams) => {
|
|
searchParams.set('s', String(value))
|
|
return searchParams
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<RangePeriod
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
billingDay={subscription?.billing_day || 1}
|
|
/>
|
|
|
|
<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) => 'course' in item && item?.unit_price > 0)
|
|
?.sort(sortBy('enrolled_at'))
|
|
const credits = filtered
|
|
?.filter((item) => 'course' in 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 há 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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<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.format(new Date(enrolled_at))}
|
|
</TableCell>
|
|
<TableCell>{currency.format(unit_price)}</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.format(new Date(created_at))}
|
|
</TableCell>
|
|
<TableCell>{currency.format(unit_price)}</TableCell>
|
|
</TableRow>
|
|
)
|
|
)}
|
|
</TableBody>
|
|
</>
|
|
) : null}
|
|
|
|
<TableFooter>
|
|
<TableRow>
|
|
<TableCell colSpan={4} className="text-right pointer-events-none">
|
|
Total
|
|
</TableCell>
|
|
<TableCell>
|
|
{currency.format(
|
|
filtered
|
|
?.filter((x) => 'course' in x)
|
|
.reduce((acc, { unit_price }) => acc + unit_price, 0)
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableFooter>
|
|
</Table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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'
|
|
})
|
|
|
|
const sortBy = (field: 'enrolled_at' | 'created_at') => (a: any, b: any) =>
|
|
new Date(a[field]).getTime() - new Date(b[field]).getTime()
|