update billing

This commit is contained in:
2025-12-23 20:23:43 -03:00
parent f3dce9a4e9
commit 1a59ad9e91
5 changed files with 254 additions and 54 deletions

View File

@@ -169,10 +169,10 @@ function List({ items, search }) {
}, [search, fuse, items]) }, [search, fuse, items])
const charges = filtered const charges = filtered
?.filter((item) => 'course' in item && item?.unit_price > 0) ?.filter((item) => item?.unit_price > 0)
?.sort(sortBy('enrolled_at')) ?.sort(sortBy('enrolled_at'))
const credits = filtered const credits = filtered
?.filter((item) => 'course' in item && item?.unit_price < 0) ?.filter((item) => item?.unit_price < 0)
?.sort(sortBy('created_at')) ?.sort(sortBy('created_at'))
if (items.length === 0) { if (items.length === 0) {
@@ -207,6 +207,16 @@ function List({ items, search }) {
) )
} }
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 ( return (
<Table className="table-auto w-full"> <Table className="table-auto w-full">
{charges.length ? ( {charges.length ? (
@@ -288,16 +298,28 @@ function List({ items, search }) {
) : null} ) : null}
<TableFooter> <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> <TableRow>
<TableCell colSpan={4} className="text-right pointer-events-none"> <TableCell colSpan={4} className="text-right pointer-events-none">
Total Total
</TableCell> </TableCell>
<TableCell> <TableCell>
<Currency> <Currency>{total}</Currency>
{filtered
?.filter((x) => 'course' in x)
?.reduce((acc, { unit_price }) => acc + unit_price, 0)}
</Currency>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>

View File

@@ -6,7 +6,11 @@ import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Form } from '@repo/ui/components/ui/form' import { Form } from '@repo/ui/components/ui/form'
import { InputGroup, InputGroupInput } from '@repo/ui/components/ui/input-group' import {
InputGroup,
InputGroupAddon,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator' import { Separator } from '@repo/ui/components/ui/separator'
import { import {
@@ -17,6 +21,7 @@ import {
import { import {
MAX_ITEMS, MAX_ITEMS,
formSchema,
type Course, type Course,
type User type User
} from '../_.$orgid.enrollments.add/data' } from '../_.$orgid.enrollments.add/data'
@@ -39,11 +44,11 @@ type AssignedProps = {
export function Assigned({ courses }: AssignedProps) { export function Assigned({ courses }: AssignedProps) {
const { orgid } = useParams() const { orgid } = useParams()
const form = useForm({ const form = useForm({
// resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { enrollments: [emptyRow] } defaultValues: { enrollments: [emptyRow] }
}) })
const { formState, control, handleSubmit } = form const { formState, control, handleSubmit } = form
const { fields, insert, remove, append } = useFieldArray({ const { fields, remove, append } = useFieldArray({
control, control,
name: 'enrollments' name: 'enrollments'
}) })
@@ -51,6 +56,10 @@ export function Assigned({ courses }: AssignedProps) {
control, control,
name: 'enrollments' name: 'enrollments'
}) })
const subtotal = items.reduce(
(acc, { course }) => acc + (course?.unit_price || 0),
0
)
const onSearch = async (search: string) => { const onSearch = async (search: string) => {
const params = new URLSearchParams({ q: search }) const params = new URLSearchParams({ q: search })
@@ -108,6 +117,7 @@ export function Assigned({ courses }: AssignedProps) {
{/* Separator only for mobile */} {/* Separator only for mobile */}
{index >= 1 && <div className="h-2.5 lg:hidden"></div>} {index >= 1 && <div className="h-2.5 lg:hidden"></div>}
{/* User */}
<Controller <Controller
control={control} control={control}
name={`enrollments.${index}.user`} name={`enrollments.${index}.user`}
@@ -134,6 +144,7 @@ export function Assigned({ courses }: AssignedProps) {
)} )}
/> />
{/* Course */}
<Controller <Controller
control={control} control={control}
name={`enrollments.${index}.course`} name={`enrollments.${index}.course`}
@@ -161,6 +172,7 @@ export function Assigned({ courses }: AssignedProps) {
)} )}
/> />
{/* Scheduled for */}
<Controller <Controller
control={control} control={control}
name={`enrollments.${index}.scheduled_for`} name={`enrollments.${index}.scheduled_for`}
@@ -169,7 +181,12 @@ export function Assigned({ courses }: AssignedProps) {
)} )}
/> />
{/* Unit price */}
<InputGroup> <InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Valor unit.
</InputGroupAddon>
<InputGroupInput <InputGroupInput
className="pointer-events-none" className="pointer-events-none"
tabIndex={-1} tabIndex={-1}
@@ -178,6 +195,7 @@ export function Assigned({ courses }: AssignedProps) {
/> />
</InputGroup> </InputGroup>
{/* Action */}
<Button <Button
tabIndex={-1} tabIndex={-1}
variant="destructive" variant="destructive"
@@ -190,19 +208,86 @@ export function Assigned({ courses }: AssignedProps) {
</Fragment> </Fragment>
) )
})} })}
</div>
<Button {/* Add button */}
type="button" <div className="max-lg:mb-2.5">
// @ts-ignore <Button
onClick={() => append(emptyRow)} type="button"
className="cursor-pointer" // @ts-ignore
disabled={fields.length == MAX_ITEMS} onClick={() => append(emptyRow)}
variant="outline" className="cursor-pointer"
size="sm" disabled={fields.length == MAX_ITEMS}
> variant="outline"
<PlusIcon /> Adicionar size="sm"
</Button> >
<PlusIcon /> Adicionar
</Button>
</div>
{/* Subtotal */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Subtotal
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Subtotal
</InputGroupAddon>
<InputGroupInput
name="subtotal"
value={currency.format(subtotal)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
</InputGroup>
</>
{/* Discount */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Cupom
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Cupom
</InputGroupAddon>
<InputGroupInput
name="discount"
value={currency.format(0)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
<InputGroupAddon align="inline-end">
<Button
variant="outline"
className="text-xs cursor-pointer h-6 px-2"
type="button"
>
Adicionar
</Button>
</InputGroupAddon>
</InputGroup>
</>
{/* Total */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Total
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Total
</InputGroupAddon>
<InputGroupInput
name="total"
value={currency.format(subtotal)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
</InputGroup>
</>
</div>
<Separator /> <Separator />

View File

@@ -5,14 +5,15 @@ import { ErrorMessage } from '@hookform/error-message'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { Form } from '@repo/ui/components/ui/form'
import { Button } from '@repo/ui/components/ui/button'
import { import {
InputGroup, InputGroup,
InputGroupAddon, InputGroupAddon,
InputGroupButton, InputGroupButton,
InputGroupInput InputGroupInput
} from '@repo/ui/components/ui/input-group' } from '@repo/ui/components/ui/input-group'
import { Input } from '@repo/ui/components/ui/input'
import { Form } from '@repo/ui/components/ui/form'
import { Button } from '@repo/ui/components/ui/button'
import { Separator } from '@repo/ui/components/ui/separator' import { Separator } from '@repo/ui/components/ui/separator'
import { Cell } from '../_.$orgid.enrollments.add/route' import { Cell } from '../_.$orgid.enrollments.add/route'
@@ -70,6 +71,13 @@ export function Bulk({ courses }: BulkProps) {
control, control,
name: 'items' name: 'items'
}) })
const subtotal = items.reduce(
(acc, { course, quantity }) =>
acc +
(course?.unit_price || 0) *
(Number.isFinite(quantity) && quantity > 0 ? quantity : 1),
0
)
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
console.log(data) console.log(data)
@@ -98,6 +106,7 @@ export function Bulk({ courses }: BulkProps) {
{/* Separator only for mobile */} {/* Separator only for mobile */}
{index >= 1 && <div className="h-2.5 lg:hidden"></div>} {index >= 1 && <div className="h-2.5 lg:hidden"></div>}
{/* Course */}
<Controller <Controller
control={control} control={control}
name={`items.${index}.course`} name={`items.${index}.course`}
@@ -128,7 +137,12 @@ export function Bulk({ courses }: BulkProps) {
)} )}
/> />
{/* Quantity */}
<InputGroup> <InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Qtd.
</InputGroupAddon>
<InputGroupInput <InputGroupInput
type="number" type="number"
min={1} min={1}
@@ -182,7 +196,11 @@ export function Bulk({ courses }: BulkProps) {
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
{/* Unit price */}
<InputGroup> <InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Valor unit.
</InputGroupAddon>
<InputGroupInput <InputGroupInput
tabIndex={-1} tabIndex={-1}
className="pointer-events-none" className="pointer-events-none"
@@ -191,7 +209,12 @@ export function Bulk({ courses }: BulkProps) {
/> />
</InputGroup> </InputGroup>
{/* Total */}
<InputGroup> <InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Total
</InputGroupAddon>
<InputGroupInput <InputGroupInput
tabIndex={-1} tabIndex={-1}
className="pointer-events-none" className="pointer-events-none"
@@ -205,6 +228,7 @@ export function Bulk({ courses }: BulkProps) {
/> />
</InputGroup> </InputGroup>
{/* Action */}
<Button <Button
tabIndex={-1} tabIndex={-1}
variant="destructive" variant="destructive"
@@ -217,24 +241,90 @@ export function Bulk({ courses }: BulkProps) {
</Fragment> </Fragment>
) )
})} })}
</div>
<Button {/* Add button */}
type="button" <div className="max-lg:mb-2.5">
onClick={() => { <Button
// @ts-ignore type="button"
append(emptyRow, { shouldFocus: false }) onClick={() => {
queueMicrotask(() => { // @ts-ignore
setFocus(`items.${fields.length}.course`) append(emptyRow, { shouldFocus: false })
}) queueMicrotask(() => {
}} setFocus(`items.${fields.length}.course`)
className="cursor-pointer" })
disabled={fields.length == MAX_ITEMS} }}
variant="outline" className="cursor-pointer"
size="sm" variant="outline"
> size="sm"
<PlusIcon /> Adicionar >
</Button> <PlusIcon /> Adicionar
</Button>
</div>
{/* Subtotal */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Subtotal
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Subtotal
</InputGroupAddon>
<InputGroupInput
name="subtotal"
value={currency.format(subtotal)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
</InputGroup>
</>
{/* Discount */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Cupom
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Cupom
</InputGroupAddon>
<InputGroupInput
name="discount"
value={currency.format(0)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
<InputGroupAddon align="inline-end">
<Button
variant="outline"
className="text-xs cursor-pointer h-6 px-2"
type="button"
>
Adicionar
</Button>
</InputGroupAddon>
</InputGroup>
</>
{/* Total */}
<>
<div className="col-start-3 flex items-center justify-end text-sm font-medium max-lg:hidden">
Total
</div>
<InputGroup>
<InputGroupAddon className="border-r pr-2.5 w-1/3 lg:hidden justify-end">
Total
</InputGroupAddon>
<InputGroupInput
name="total"
value={currency.format(subtotal)}
className="pointer-events-none text-muted-foreground"
readOnly
/>
</InputGroup>
</>
</div>
<Separator /> <Separator />

View File

@@ -20,7 +20,8 @@ export const enrollment = z.object({
{ {
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
access_period: z.number() access_period: z.number(),
unit_price: z.number().optional()
}, },
{ error: 'Escolha um curso' } { error: 'Escolha um curso' }
) )

View File

@@ -349,19 +349,21 @@ export default function Route({
</Fragment> </Fragment>
))} ))}
</> </>
</div>
<Button <div className="max-lg:mt-2.5">
type="button" <Button
// @ts-ignore type="button"
onClick={() => append(emptyRow)} // @ts-ignore
className="cursor-pointer" onClick={() => append(emptyRow)}
disabled={fields.length == MAX_ITEMS} className="cursor-pointer"
variant="outline" disabled={fields.length == MAX_ITEMS}
size="sm" variant="outline"
> size="sm"
<PlusIcon /> Adicionar >
</Button> <PlusIcon /> Adicionar
</Button>
</div>
</div>
<Separator /> <Separator />