This commit is contained in:
2026-01-26 18:37:34 -03:00
parent 63c6344821
commit d28b1362e5
5 changed files with 47 additions and 7 deletions

View File

@@ -0,0 +1,285 @@
import {
BanIcon,
CheckCircle2Icon,
CircleDashedIcon,
ClockIcon,
EllipsisIcon,
HelpCircleIcon,
PlusIcon,
type LucideIcon
} from 'lucide-react'
import { Fragment } from 'react'
import { Abbr } from '@repo/ui/components/abbr'
import { DateTime } from '@repo/ui/components/datetime'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from '@repo/ui/components/ui/hover-card'
import { Kbd } from '@repo/ui/components/ui/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { cn, initials } from '@repo/ui/lib/utils'
import { Link } from 'react-router'
import type { Enrollment, Seat } from './route'
const dtOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit'
}
export function Enrollments({
enrollments,
seats
}: {
enrollments: Enrollment[]
seats: Seat[]
}) {
return (
<Card className="lg:max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="text-xl">Matrículas relacionadas</CardTitle>
<CardDescription>
Acompanhe os detalhes de todas as matrículas relacionadas a esta
compra.
</CardDescription>
<CardAction>
<SeatsMenu seats={seats} />
</CardAction>
</CardHeader>
<CardContent>
<Table>
<TableHeader className="pointer-events-none">
<TableRow>
<TableHead>Colaborador</TableHead>
<TableHead>Curso</TableHead>
<TableHead>Status</TableHead>
<TableHead>Executada em</TableHead>
<TableHead>Revogada em</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_tr]:hover:bg-transparent">
{enrollments.map(
(
{
user,
course,
status,
executed_at,
rollback_at,
reason: reason_
},
idx
) => {
const friendlyReason = reason_ ? reason(reason_) : null
return (
<TableRow key={idx}>
<TableCell>
<div className="flex gap-2.5 items-center">
<Avatar className="size-10 hidden lg:block">
<AvatarFallback className="border">
{initials(user.name)}
</AvatarFallback>
</Avatar>
<ul>
<li className="font-bold">
<Abbr>{user.name}</Abbr>
</li>
<li className="text-muted-foreground text-sm">
<Abbr>{user.email}</Abbr>
</li>
</ul>
</div>
</TableCell>
<TableCell>
<Abbr>{course.name}</Abbr>
</TableCell>
<TableCell>
{friendlyReason ? (
<HoverCard openDelay={0}>
<HoverCardTrigger>
<Status status={status} />
</HoverCardTrigger>
<HoverCardContent align="end" className="text-sm">
<p className="flex gap-1">
<HelpCircleIcon className="size-4.5 mt-px" />{' '}
{friendlyReason}
</p>
</HoverCardContent>
</HoverCard>
) : (
<Status status={status} />
)}
</TableCell>
<TableCell>
{executed_at ? (
<DateTime options={dtOptions}>{executed_at}</DateTime>
) : null}
</TableCell>
<TableCell>
{rollback_at ? (
<DateTime options={dtOptions}>{rollback_at}</DateTime>
) : null}
</TableCell>
</TableRow>
)
}
)}
{enrollments.length === 0 && (
<TableRow>
<TableCell className="text-center h-24" colSpan={5}>
Nenhuma matrícula ainda.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
const reasons: Record<string, string> = {
DEDUPLICATION: 'Matrícula ou agendamento já existentes.',
CANCELLATION: 'Cancelamento da matrícula.',
UNSCHEDULED: 'Cancelamento do agendamento da matrícula.',
DEADLINE: 'Data do agendamento é anterior ao dia do pagamento.'
} as const
const reason = (reason_: string): string | null => {
return reason_ in reasons ? reasons[reason_] : null
}
const statuses: Record<string, { icon: LucideIcon; color?: string }> = {
PENDING: {
icon: CircleDashedIcon,
color: 'text-blue-400 [&_svg]:text-blue-500'
},
SCHEDULED: {
icon: ClockIcon,
color: 'text-blue-400 [&_svg]:text-blue-500'
},
EXECUTED: {
icon: CheckCircle2Icon,
color: 'text-green-400 [&_svg]:text-green-500'
},
ROLLBACK: {
icon: BanIcon,
color: 'text-orange-400 [&_svg]:text-orange-500'
}
}
const labels: Record<string, string> = {
PENDING: 'Pendente',
EXECUTED: 'Executada',
SCHEDULED: 'Agendada',
ROLLBACK: 'Revogada'
}
function Status({ status: s }: { status: string }) {
const status = labels[s] ?? s
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return (
<Badge variant="outline" className={cn(color, 'px-1.5')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}
function SeatsMenu({ seats: seats_ }: { seats: Seat[] }) {
const seats = Object.values(
seats_.reduce((acc: any, { course }) => {
acc[course.id] ??= { course, quantity: 0 }
acc[course.id].quantity++
return acc
}, {})
) as { course: Seat['course']; quantity: number }[]
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon-sm"
className="cursor-pointer relative"
>
{seats.length > 0 && (
<span className="absolute flex size-2 -top-0.5 -right-0.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex size-2 rounded-full bg-green-500"></span>
</span>
)}
<EllipsisIcon />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-82">
<div className="grid gap-4">
{seats.length > 0 ? (
<>
<div className="space-y-2">
<h4 className="leading-none font-medium">Matrículas abertas</h4>
<p className="text-muted-foreground text-sm">
Matrículas que estão abertas e relacionadas a esta compra.
</p>
</div>
<div className="text-sm grid grid-cols-[1fr_15%] gap-1">
{seats.map(({ course, quantity }, idx) => {
return (
<Fragment key={idx}>
<Abbr>{course.name}</Abbr>
<div className="flex justify-end">
<Kbd>{quantity}x</Kbd>
</div>
</Fragment>
)
})}
</div>
<Button size="sm" variant="outline" asChild>
<Link to="../enrollments/seats">
<PlusIcon /> Matricular
</Link>
</Button>
</>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma matrícula aberta foi encontrada para esta compra.
</p>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,656 @@
import type { Route } from './+types/route'
import { formatCEP } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRequest, useToggle } from 'ahooks'
import {
AlertCircleIcon,
ArrowLeftRightIcon,
CircleCheckIcon,
CircleXIcon,
EllipsisIcon,
ExternalLinkIcon,
HelpCircleIcon
} from 'lucide-react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { Link, useRevalidator } from 'react-router'
import { z } from 'zod'
import { Abbr } from '@repo/ui/components/abbr'
import { Currency } from '@repo/ui/components/currency'
import { DateTime } from '@repo/ui/components/datetime'
import { Badge } from '@repo/ui/components/ui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Kbd } from '@repo/ui/components/ui/kbd'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import { cn } from '@repo/ui/lib/utils'
import {
labels,
statuses,
type Order as Order_
} from '@repo/ui/routes/orders/data'
import { request as req } from '@repo/util/request'
import {
CreditCard,
creditCardSchema,
type CreditCard as CreditCardProps
} from '../_.$orgid.enrollments.buy/payment'
import type { Address } from '../_.$orgid.enrollments.buy/review'
import { useWizardStore } from '../_.$orgid.enrollments.buy/store'
import { Enrollments } from './enrollments'
export function meta() {
return [
{
title: 'Detalhes do pagamento'
}
]
}
const PaymentMethodComponent = {
PIX: PixPaymentMethod,
BANK_SLIP: BankSlipPaymentMethod,
CREDIT_CARD: CreditCardPaymentMethod
}
type Item = {
id: string
name: string
unit_price: number
quantity: number
}
type User = {
id: string
name: string
email: string
}
type Invoice = {
invoice_id: string
secure_url: string
bank_slip?: {
bank_slip_pdf_url: string
}
pix?: {
qrcode: string
qrcode_text: string
}
}
type Attempts = {
sk: string
status: string
brand: string
last4: string
}
type Course = {
id: string
name: string
}
export type Enrollment = {
status: 'PENDING' | 'EXECUTED' | 'ROLLBACK' | 'SCHEDULED'
user: User
course: Course
executed_at?: string
rollback_at?: string
scheduled_at?: string
reason?: string
}
export type Seat = {
course: Course
}
type Order = Order_ & {
items: Item[]
interest_amount: number
due_date: string
created_at: string
paid_at?: string
canceled_at?: string
expired_at?: string
subtotal: number
discount: number
address: Address
payment_attempts: Attempts[]
credit_card?: CreditCardProps
coupon?: string
enrollments?: Enrollment[]
seats?: Seat[]
installments?: number
created_by?: User
invoice: Invoice
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
const r = await req({
url: `/orders/${params.id}`,
context,
request
})
if (!r.ok) {
throw new Response(null, { status: r.status })
}
const order = (await r.json()) as Order
return { order }
}
export default function Route({ loaderData: { order } }: Route.ComponentProps) {
const { reset } = useWizardStore()
const {
coupon,
address,
total,
payment_method,
interest_amount,
discount,
invoice,
payment_attempts = [],
enrollments = [],
seats = [],
items = [],
created_at,
expired_at,
paid_at,
subtotal
} = order
const Component =
(PaymentMethodComponent as Record<string, React.ComponentType<any>>)[
payment_method
] ?? UnknownPaymentMethod
useEffect(() => {
reset()
}, [])
return (
<div className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="../payments">Pagamentos</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Detalhes do pagamento</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Card className="lg:max-w-4xl mx-auto">
<CardHeader className="gap-0">
<CardTitle className="text-2xl">Detalhes do pagamento</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ItemGroup className="grid lg:grid-cols-2 gap-4">
<Item variant="outline" className="items-start">
{/* Billing address */}
<ItemContent>
<ItemTitle>Endereço de cobrança</ItemTitle>
<ul className="text-muted-foreground text-sm leading-normal font-normal text-balance">
{address?.address1}
{address?.address2 ? <>, {address?.address2}</> : null}
<br />
{address?.neighborhood}
<br />
{address?.city}, {address?.state}
<br />
{formatCEP(address?.postcode)}
</ul>
</ItemContent>
</Item>
{/* Payment method */}
<Item variant="outline" className="items-start">
<ItemContent>
<ItemTitle>Forma de pagamento</ItemTitle>
<div className="text-muted-foreground text-sm leading-normal font-normal text-balance space-y-2.5">
<div>
{Component && <Component {...order} invoice={invoice} />}
</div>
<ul>
{paid_at && (
<li>
Pago em{' '}
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{paid_at}
</DateTime>
</li>
)}
{expired_at && (
<li>
Expirado em{' '}
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{expired_at}
</DateTime>
</li>
)}
<li>
Comprado em{' '}
<DateTime
options={{ hour: '2-digit', minute: '2-digit' }}
>
{created_at}
</DateTime>
</li>
</ul>
</div>
</ItemContent>
{payment_attempts.length > 0 ? (
<ItemActions>
<PaymentAttemptsMenu payment_attempts={payment_attempts} />
</ItemActions>
) : null}
</Item>
</ItemGroup>
<Table className="pointer-events-none">
<TableHeader>
<TableRow>
<TableHead>Curso</TableHead>
<TableHead>Quantidade</TableHead>
<TableHead>Valor unit.</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items?.map(({ name, unit_price, quantity }, index) => {
return (
<TableRow key={index}>
<TableCell>{name}</TableCell>
<TableCell>{quantity}</TableCell>
<TableCell>
<Currency>{unit_price}</Currency>
</TableCell>
<TableCell>
<Currency>{unit_price * quantity}</Currency>
</TableCell>
</TableRow>
)
})}
</TableBody>
{/* Summary */}
<TableFooter>
<TableRow>
<TableCell className="text-right" colSpan={3}>
Subtotal
</TableCell>
<TableCell>
<Currency>{subtotal}</Currency>
</TableCell>
</TableRow>
{/* Discount */}
<TableRow>
<TableCell colSpan={3}>
<span className="flex gap-1 justify-end">
Descontos
{coupon && (
<Kbd>
<Abbr maxLen={8}>{coupon}</Abbr>
</Kbd>
)}
</span>
</TableCell>
<TableCell>
<Currency>{discount}</Currency>
</TableCell>
</TableRow>
{/* Interest */}
{interest_amount ? (
<TableRow>
<TableCell className="text-right" colSpan={3}>
Juros
</TableCell>
<TableCell>
<Currency>{interest_amount}</Currency>
</TableCell>
</TableRow>
) : (
<></>
)}
{/* Total */}
<TableRow>
<TableCell className="text-right" colSpan={3}>
Total
</TableCell>
<TableCell>
<Currency>{total}</Currency>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</CardContent>
</Card>
<Enrollments enrollments={enrollments} seats={seats} />
</div>
)
}
function Status({ status: s }: { status: string }) {
const status = labels[s] ?? s
const { icon: Icon, color } = statuses?.[s] ?? { icon: HelpCircleIcon }
return (
<Badge variant="outline" className={cn(color, 'px-1.5')}>
<Icon className={cn('stroke-2', color)} />
{status}
</Badge>
)
}
type PaymentMethodProps = {
id: string
status: string
total: number
invoice: Invoice
installments: number
}
type BankSlipPaymentMethodProps = PaymentMethodProps & {}
function BankSlipPaymentMethod({
status,
invoice
}: BankSlipPaymentMethodProps) {
return (
<div className="space-y-2">
<ul className="flex gap-x-1.5">
<li>Boleto bancário</li>
<li>
<Status status={status} />
</li>
</ul>
{invoice?.bank_slip ? (
<>
<Button
size="sm"
variant="secondary"
className="cursor-pointer"
asChild
>
<a href={invoice.bank_slip.bank_slip_pdf_url} target="_blank">
<ExternalLinkIcon /> Abrir o boleto bancário
</a>
</Button>
</>
) : null}
</div>
)
}
type PixPaymentMethodrops = PaymentMethodProps & {}
function PixPaymentMethod({ invoice, status }: PixPaymentMethodrops) {
return (
<div className="space-y-2">
<ul className="flex gap-x-1.5">
<li>Pix</li>
<li>
<Status status={status} />
</li>
</ul>
{invoice?.pix ? (
<div
className="font-mono text-xs break-all p-2.5 border
rounded-md text-red-900 dark:text-yellow-600
bg-gray-50 dark:bg-muted/50 select-all"
>
{invoice.pix.qrcode_text}
</div>
) : null}
</div>
)
}
function UnknownPaymentMethod() {
return <>Deposito bancário</>
}
type CreditCardPaymentMethodProps = PaymentMethodProps & {
stats?: { last_attempt_succeeded: boolean }
credit_card: { last4: string; brand: string }
}
function CreditCardPaymentMethod({
id,
status,
total,
credit_card,
installments,
invoice,
stats
}: CreditCardPaymentMethodProps) {
return (
<>
<ul className="lg:flex gap-x-1">
<li>
<Abbr maxLen={6}>{credit_card.brand}</Abbr>
</li>
<li>(Crédito) **** {credit_card.last4}</li>
<li>
{stats?.last_attempt_succeeded === false && status === 'PENDING' ? (
<Badge variant="outline" className="text-red-400 px-1.5">
<AlertCircleIcon /> Negado
</Badge>
) : (
<Status status={status} />
)}
</li>
</ul>
<p>
{installments}x <Currency>{total / Number(installments)}</Currency>
</p>
{stats?.last_attempt_succeeded === false &&
status === 'PENDING' &&
invoice?.invoice_id ? (
<div className="lg:flex justify-center mt-2">
<CreditCardPaymentDialog
id={id}
total={total}
installments={installments}
invoice_id={invoice.invoice_id}
>
<Button size="sm" variant="secondary" className="cursor-pointer">
<ArrowLeftRightIcon /> Pagar com outro cartão
</Button>
</CreditCardPaymentDialog>
</div>
) : null}
</>
)
}
const formSchema = z.object({
credit_card: creditCardSchema
})
type Schema = z.input<typeof formSchema>
function CreditCardPaymentDialog({
children,
id,
installments,
invoice_id,
total
}) {
const revalidator = useRevalidator()
const [open, { set: setOpen, toggle }] = useToggle()
const { runAsync } = useRequest(
async ({ credit_card }) => {
return await fetch(`/~/api/orders/${id}/payment-retries`, {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ credit_card, installments, invoice_id })
})
},
{ manual: true }
)
const { control, handleSubmit, formState } = useForm<Schema>({
resolver: zodResolver(formSchema)
})
const onSubmit = async ({ credit_card }: Schema) => {
await runAsync({ credit_card })
revalidator.revalidate()
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={toggle}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Novo cartão de crédito</DialogTitle>
<DialogDescription>
Use um novo cartão para concluir o pagamento. Nenhuma cobrança foi
realizada no cartão anterior.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<CreditCard control={control} />
<Separator />
<DialogFooter className="*:cursor-pointer">
<DialogClose asChild>
<Button
type="button"
variant="link"
className="text-black dark:text-white"
tabIndex={-1}
>
Cancelar
</Button>
</DialogClose>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting && <Spinner />}
Pagar <Currency>{total}</Currency>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function PaymentAttemptsMenu({
payment_attempts
}: {
payment_attempts: Attempts[]
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="secondary" className="cursor-pointer" size="icon-sm">
<EllipsisIcon />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-82 space-y-1.5">
{payment_attempts.map(({ sk, brand, last4, status }, index) => {
const [, , created_at] = sk.split('#')
return (
<ul key={index} className="text-sm flex gap-1.5">
<li>
<Kbd>
<DateTime
options={{
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
}}
>
{created_at}
</DateTime>
</Kbd>
</li>
<li>
<Abbr maxLen={6}>{brand}</Abbr>
</li>
<li className="ml-auto">**** {last4}</li>
<li className="flex items-center">
{status === 'FAILED' ? (
<CircleXIcon className="size-4 text-red-400" />
) : (
<CircleCheckIcon className="size-4 text-green-400" />
)}
</li>
</ul>
)
})}
</PopoverContent>
</Popover>
)
}