add cancel schedule

This commit is contained in:
2025-12-15 16:38:40 -03:00
parent 2774545d09
commit 62b5340b20
11 changed files with 238 additions and 85 deletions

View File

@@ -1,10 +1,14 @@
from http import HTTPStatus
from typing import Annotated from typing import Annotated
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.openapi.params import Query from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from aws_lambda_powertools.event_handler.openapi.params import Body, Query
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from pydantic import FutureDatetime
from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
@@ -23,3 +27,33 @@ def scheduled(
start_key=start_key, start_key=start_key,
limit=150, limit=150,
) )
class ScheduledNotFoundError(NotFoundError): ...
@router.delete('/<org_id>/enrollments/scheduled')
def cancel(
org_id: str,
scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)],
):
with dyn.transact_writer() as transact:
transact.delete(
key=KeyPair(
pk=f'SCHEDULED#ORG#{org_id}',
sk=f'{scheduled_for.isoformat()}#{lock_hash}',
),
cond_expr='attribute_exists(sk)',
exc_cls=ScheduledNotFoundError,
)
transact.delete(
key=KeyPair(
pk='LOCK#SCHEDULED',
sk=lock_hash,
),
cond_expr='attribute_exists(sk)',
exc_cls=ScheduledNotFoundError,
)
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)

View File

@@ -25,6 +25,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu' } from '@repo/ui/components/ui/dropdown-menu'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
@@ -138,6 +139,7 @@ function ActionMenu({ id }: { id: string }) {
)} )}
</NavLink> </NavLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<RevokeItem id={id} /> <RevokeItem id={id} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -194,7 +194,7 @@ function RemoveDedupItem({
}) })
if (r.ok) { if (r.ok) {
toast.info('A proteção contra duplicação foi removida') toast.info('A proteção contra duplicação foi removida.')
setOpen(false) setOpen(false)
onSuccess?.() onSuccess?.()
} }
@@ -279,7 +279,7 @@ function CancelItem({
}) })
if (r.ok) { if (r.ok) {
toast.info('A matrícula foi cancelada') toast.info('A matrícula foi cancelada.')
setOpen(false) setOpen(false)
onSuccess?.() onSuccess?.()
} }

View File

@@ -1,12 +1,17 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import type { MouseEvent } from 'react'
import { useRequest, useToggle } from 'ahooks'
import { import {
BanIcon, BanIcon,
CalendarIcon, CalendarIcon,
CircleXIcon,
EllipsisIcon, EllipsisIcon,
PlusIcon, PlusIcon,
RocketIcon,
UserIcon UserIcon
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import { Fragment, Suspense } from 'react' import { Fragment, Suspense } from 'react'
import { Await } from 'react-router' import { Await } from 'react-router'
@@ -37,6 +42,27 @@ import { Link } from 'react-router'
import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar'
import { initials } from '@repo/ui/lib/utils' import { initials } from '@repo/ui/lib/utils'
import { Abbr } from '@repo/ui/components/abbr' import { Abbr } from '@repo/ui/components/abbr'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@repo/ui/components/ui/alert-dialog'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useParams } from 'react-router'
import { useRevalidator } from 'react-router'
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: 'Matrículas agendadas' }] return [{ title: 'Matrículas agendadas' }]
@@ -100,7 +126,10 @@ export default function Route({
return ( return (
<div className="space-y-5 lg:max-w-4xl mx-auto"> <div className="space-y-5 lg:max-w-4xl mx-auto">
{scheduled.map(([run_at, items], index) => ( {scheduled.map(([run_at, items], index) => (
<div className="grid lg:grid-cols-5 gap-2.5" key={index}> <div
className="grid grid-cols-1 lg:grid-cols-5 gap-2.5"
key={index}
>
<div> <div>
{DateTime.fromISO(run_at) {DateTime.fromISO(run_at)
.setLocale('pt-BR') .setLocale('pt-BR')
@@ -112,7 +141,7 @@ export default function Route({
<ItemGroup> <ItemGroup>
{items.map( {items.map(
( (
{ user, course, created_by, scheduled_at }, { sk, user, course, created_by, scheduled_at },
index index
) => ( ) => (
<Fragment key={index}> <Fragment key={index}>
@@ -133,7 +162,7 @@ export default function Route({
<div className="mt-1"> <div className="mt-1">
<ul <ul
className="lg:flex gap-2.5 text-muted-foreground text-sm className="lg:flex gap-2.5 text-muted-foreground text-sm
*:flex *:gap-1 *:items-center" *:flex *:gap-1 *:items-center"
> >
<li> <li>
<CalendarIcon className="size-3.5" />{' '} <CalendarIcon className="size-3.5" />{' '}
@@ -148,11 +177,9 @@ export default function Route({
</ul> </ul>
</div> </div>
</ItemContent> </ItemContent>
{/*<ItemActions> <ItemActions className="self-start">
<Button variant="ghost" size="icon-sm"> <ActionMenu sk={sk} />
<EllipsisIcon /> </ItemActions>
</Button>
</ItemActions>*/}
</Item> </Item>
{index !== items.length - 1 && <ItemSeparator />} {index !== items.length - 1 && <ItemSeparator />}
@@ -172,6 +199,93 @@ export default function Route({
) )
} }
function ActionMenu({ sk }: { sk: string }) {
const { revalidate } = useRevalidator()
const onSuccess = () => {
revalidate()
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
<EllipsisIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer w-42">
<DropdownMenuItem disabled>
<RocketIcon /> Matricular agora
</DropdownMenuItem>
<DropdownMenuSeparator />
<CancelItem sk={sk} onSuccess={onSuccess} />
</DropdownMenuContent>
</DropdownMenu>
)
}
function CancelItem({ sk, onSuccess }: { sk: string; onSuccess?: () => void }) {
const { orgid } = useParams()
const [open, { set: setOpen }] = useToggle(false)
const { runAsync, loading } = useRequest(
async () => {
const [scheduled_for, lock_hash] = sk.split('#')
return await fetch(`/~/api/orgs/${orgid}/enrollments/scheduled`, {
method: 'DELETE',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ scheduled_for, lock_hash })
})
},
{ manual: true }
)
const cancel = async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
const r = await runAsync()
if (r.ok) {
toast.info('O agendamento foi cancelada.')
onSuccess?.()
}
setOpen(false)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
>
<CircleXIcon /> Cancelar
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
<AlertDialogDescription>
Esta ação não pode ser desfeita. Isso{' '}
<span className="font-bold">
cancela permanentemente o agendamento
</span>{' '}
desta matrícula.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function grouping(items) { function grouping(items) {
const newItems = Object.entries( const newItems = Object.entries(
items.reduce((acc, item) => { items.reduce((acc, item) => {

View File

@@ -27,6 +27,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from '@repo/ui/components/ui/dropdown-menu' } from '@repo/ui/components/ui/dropdown-menu'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
@@ -74,6 +75,7 @@ function ActionMenu({ row }: { row: any }) {
)} )}
</NavLink> </NavLink>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<UnlinkItem id={row.id} /> <UnlinkItem id={row.id} />
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -62,14 +62,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
enrollment['course'] = old_course enrollment['course'] = old_course
created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore created_at: datetime = fromisoformat(enrollment['created_at']) # type: ignore
start_date, end_date = get_billing_period( start_period, end_period = get_billing_period(
billing_day=new_image['billing_day'], billing_day=new_image['billing_day'],
date_=created_at, date_=created_at,
) )
pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id) pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id)
sk = 'START#{start}#END#{end}'.format( sk = 'START#{start}#END#{end}'.format(
start=start_date.isoformat(), start=start_period.isoformat(),
end=end_date.isoformat(), end=end_period.isoformat(),
) )
logger.info('Enrollment found', data=enrollment) logger.info('Enrollment found', data=enrollment)
@@ -91,7 +91,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
'id': pk, 'id': pk,
'sk': f'{sk}#SCHEDULE#AUTO_CLOSE', 'sk': f'{sk}#SCHEDULE#AUTO_CLOSE',
'ttl': ttl( 'ttl': ttl(
start_dt=datetime.combine(end_date, time()) + timedelta(days=1) start_dt=datetime.combine(end_period, time())
+ timedelta(days=1)
), ),
'created_at': now_, 'created_at': now_,
} }

View File

@@ -32,17 +32,17 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
) )
created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore created_at: datetime = fromisoformat(new_image['created_at']) # type: ignore
start_date, end_date = get_billing_period( start_period, end_period = get_billing_period(
billing_day=int(subscription['billing_day']), billing_day=int(subscription['billing_day']),
date_=created_at, date_=created_at,
) )
pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id']) pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id'])
sk = 'START#{start}#END#{end}'.format( sk = 'START#{start}#END#{end}'.format(
start=start_date.isoformat(), start=start_period.isoformat(),
end=end_date.isoformat(), end=end_period.isoformat(),
) )
if now_.date() > end_date: if now_.date() > end_period:
logger.debug('Enrollment outside the billing period') logger.debug('Enrollment outside the billing period')
return False return False

View File

@@ -25,13 +25,13 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
now_ = now() now_ = now()
# Key pattern `BILLING#ORG#{org_id}` # Key pattern `BILLING#ORG#{org_id}`
*_, org_id = keys['id'].split('#') *_, org_id = keys['id'].split('#')
# Key pattern `START#{start_date}#END#{end_date}#SCHEDULE#AUTO_CLOSE` # Key pattern `START#{start_period}#END#{end_period}#SCHEDULE#AUTO_CLOSE`
_, start_date, _, end_date, *_ = keys['sk'].split('#') _, start_period, _, end_period, *_ = keys['sk'].split('#')
result = order_layer.collection.query( result = order_layer.collection.query(
KeyPair( KeyPair(
pk=keys['id'], pk=keys['id'],
sk=f'START#{start_date}#END#{end_date}#ENROLLMENT', sk=f'START#{start_period}#END#{end_period}#ENROLLMENT',
), ),
limit=150, limit=150,
) )
@@ -40,8 +40,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
{ {
'template_uri': BILLING_TEMPLATE_URI, 'template_uri': BILLING_TEMPLATE_URI,
'args': { 'args': {
'start_date': start_date, 'start_date': start_period,
'end_date': end_date, 'end_date': end_period,
'items': result['items'], 'items': result['items'],
}, },
}, },
@@ -56,7 +56,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
logger.info('The request timed out') logger.info('The request timed out')
raise raise
object_key = f'billing/{org_id}/{start_date}_{end_date}.pdf' object_key = f'billing/{org_id}/{start_period}_{end_period}.pdf'
s3_uri = f's3://{BUCKET_NAME}/{object_key}' s3_uri = f's3://{BUCKET_NAME}/{object_key}'
try: try:
@@ -74,7 +74,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
transact.update( transact.update(
key=KeyPair( key=KeyPair(
pk=keys['id'], pk=keys['id'],
sk=f'START#{start_date}#END#{end_date}', sk=f'START#{start_period}#END#{end_period}',
), ),
update_expr='SET #status = :status, s3_uri = :s3_uri, \ update_expr='SET #status = :status, s3_uri = :s3_uri, \
updated_at = :updated_at', updated_at = :updated_at',

View File

@@ -12,7 +12,7 @@ from boto3clients import dynamodb_client, s3_client, sesv2_client
from config import EMAIL_SENDER, USER_TABLE from config import EMAIL_SENDER, USER_TABLE
SUBJECT = ( SUBJECT = (
'Relatório de matrículas realizadas entre {start_date} e {end_date} na EDUSEG®' 'Relatório de matrículas realizadas entre {start_period} e {end_period} na EDUSEG®'
) )
REPLY_TO = ('Carolina Brand', 'carolina@somosbeta.com.br') REPLY_TO = ('Carolina Brand', 'carolina@somosbeta.com.br')
BCC = [ BCC = [
@@ -24,7 +24,7 @@ MESSAGE = """
Oi, tudo bem?<br/><br/> Oi, tudo bem?<br/><br/>
Em anexo você encontra o relatório das matrículas realizadas no período de Em anexo você encontra o relatório das matrículas realizadas no período de
<strong>{start_date}</strong> a <strong>{end_date}</strong>.<br/><br/> <strong>{start_period}</strong> a <strong>{end_period}</strong>.<br/><br/>
<a href="https://admin.saladeaula.digital/{org_id}/billing?start={start_query}&end={end_query}"> <a href="https://admin.saladeaula.digital/{org_id}/billing?start={start_query}&end={end_query}">
📈 Clique aqui para ver mais detalhes do relatório 📈 Clique aqui para ver mais detalhes do relatório
@@ -43,8 +43,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
new_image = event.detail['new_image'] new_image = event.detail['new_image']
# Key pattern `BILLING#ORG#{org_id}` # Key pattern `BILLING#ORG#{org_id}`
*_, org_id = new_image['id'].split('#') *_, org_id = new_image['id'].split('#')
# Key pattern `START#{start_date}#END#{end_date} # Key pattern `START#{start_period}#END#{v}
_, start_date, _, end_date, *_ = new_image['sk'].split('#') _, start_period, _, end_period, *_ = new_image['sk'].split('#')
emailmsg = Message( emailmsg = Message(
from_=EMAIL_SENDER, from_=EMAIL_SENDER,
@@ -52,24 +52,24 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
reply_to=REPLY_TO, reply_to=REPLY_TO,
bcc=BCC, bcc=BCC,
subject=SUBJECT.format( subject=SUBJECT.format(
start_date=_locale_dateformat(start_date), start_period=_locale_dateformat(start_period),
end_date=_locale_dateformat(end_date), end_period=_locale_dateformat(end_period),
), ),
) )
emailmsg.add_alternative( emailmsg.add_alternative(
MESSAGE.format( MESSAGE.format(
org_id=org_id, org_id=org_id,
start_date=_locale_dateformat(start_date), start_period=_locale_dateformat(start_period),
end_date=_locale_dateformat(end_date), end_period=_locale_dateformat(end_period),
start_query=start_date, start_query=start_period,
end_query=start_date, end_query=end_period,
) )
) )
attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri'])) attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri']))
attachment.add_header( attachment.add_header(
'Content-Disposition', 'Content-Disposition',
'attachment', 'attachment',
filename=f'{start_date}_{end_date}.pdf', filename=f'{start_period}_{end_period}.pdf',
) )
emailmsg.attach(attachment) emailmsg.attach(attachment)

View File

@@ -30,14 +30,14 @@ def test_cancel_enrollment(
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
now_ = now() now_ = now()
start_date, end_date = get_billing_period( start_period, end_period = get_billing_period(
billing_day=6, billing_day=6,
date_=now_, date_=now_,
) )
pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG' pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG'
sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format( sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format(
start=start_date.isoformat(), start=start_period.isoformat(),
end=end_date.isoformat(), end=end_period.isoformat(),
enrollment_id=enrollment_id, enrollment_id=enrollment_id,
) )
# Add up-to-date enrollment item to billing # Add up-to-date enrollment item to billing

View File

@@ -1,16 +1,16 @@
import * as React from "react" import * as React from 'react'
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { Separator } from "@/components/ui/separator" import { Separator } from '@/components/ui/separator'
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
role="list" role="list"
data-slot="item-group" data-slot="item-group"
className={cn("group/item-group flex flex-col", className)} className={cn('group/item-group flex flex-col', className)}
{...props} {...props}
/> />
) )
@@ -24,42 +24,42 @@ function ItemSeparator({
<Separator <Separator
data-slot="item-separator" data-slot="item-separator"
orientation="horizontal" orientation="horizontal"
className={cn("my-0", className)} className={cn('my-0', className)}
{...props} {...props}
/> />
) )
} }
const itemVariants = cva( const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: 'bg-transparent',
outline: "border-border", outline: 'border-border',
muted: "bg-muted/50", muted: 'bg-muted/50'
}, },
size: { size: {
default: "p-4 gap-4 ", default: 'p-4 gap-4 ',
sm: "py-3 px-4 gap-2.5", sm: 'py-3 px-4 gap-2.5'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default'
}, }
} }
) )
function Item({ function Item({
className, className,
variant = "default", variant = 'default',
size = "default", size = 'default',
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & }: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) { VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : 'div'
return ( return (
<Comp <Comp
data-slot="item" data-slot="item"
@@ -72,27 +72,27 @@ function Item({
} }
const itemMediaVariants = cva( const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5", 'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4", icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image: image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover", 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover'
}, }
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default'
}, }
} }
) )
function ItemMedia({ function ItemMedia({
className, className,
variant = "default", variant = 'default',
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) { }: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return ( return (
<div <div
data-slot="item-media" data-slot="item-media"
@@ -103,12 +103,12 @@ function ItemMedia({
) )
} }
function ItemContent({ className, ...props }: React.ComponentProps<"div">) { function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="item-content" data-slot="item-content"
className={cn( className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", 'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className className
)} )}
{...props} {...props}
@@ -116,12 +116,12 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="item-title" data-slot="item-title"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium", 'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className className
)} )}
{...props} {...props}
@@ -129,13 +129,13 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return ( return (
<p <p
data-slot="item-description" data-slot="item-description"
className={cn( className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance", 'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className className
)} )}
{...props} {...props}
@@ -143,22 +143,22 @@ function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
) )
} }
function ItemActions({ className, ...props }: React.ComponentProps<"div">) { function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="item-actions" data-slot="item-actions"
className={cn("flex items-center gap-2", className)} className={cn('flex items-center gap-2', className)}
{...props} {...props}
/> />
) )
} }
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="item-header" data-slot="item-header"
className={cn( className={cn(
"flex basis-full items-center justify-between gap-2", 'flex basis-full items-center justify-between gap-2',
className className
)} )}
{...props} {...props}
@@ -166,12 +166,12 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
) )
} }
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="item-footer" data-slot="item-footer"
className={cn( className={cn(
"flex basis-full items-center justify-between gap-2", 'flex basis-full items-center justify-between gap-2',
className className
)} )}
{...props} {...props}
@@ -189,5 +189,5 @@ export {
ItemTitle, ItemTitle,
ItemDescription, ItemDescription,
ItemHeader, ItemHeader,
ItemFooter, ItemFooter
} }