From 62b5340b2003f09ce887e1a5f0858a94f1767fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Mon, 15 Dec 2025 16:38:40 -0300 Subject: [PATCH] add cancel schedule --- .../app/routes/orgs/enrollments/scheduled.py | 38 ++++- .../routes/_.$orgid.admins._index/route.tsx | 2 + .../_.$orgid.enrollments._index/columns.tsx | 4 +- .../app/routes/_.$orgid.scheduled/route.tsx | 130 ++++++++++++++++-- .../routes/_.$orgid.users._index/columns.tsx | 2 + .../app/events/billing/append_enrollment.py | 9 +- .../app/events/billing/cancel_enrollment.py | 8 +- .../app/events/billing/close_window.py | 14 +- .../events/billing/send_email_on_closing.py | 22 +-- .../events/billing/test_cancel_enrollment.py | 6 +- packages/ui/src/components/ui/item.tsx | 88 ++++++------ 11 files changed, 238 insertions(+), 85 deletions(-) diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py index cd2908b..178599b 100644 --- a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py @@ -1,10 +1,14 @@ +from http import HTTPStatus from typing import Annotated from aws_lambda_powertools import Logger from aws_lambda_powertools.event_handler.api_gateway import Router -from aws_lambda_powertools.event_handler.openapi.params import Query -from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey +from aws_lambda_powertools.event_handler.exceptions import NotFoundError +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 config import ENROLLMENT_TABLE @@ -23,3 +27,33 @@ def scheduled( start_key=start_key, limit=150, ) + + +class ScheduledNotFoundError(NotFoundError): ... + + +@router.delete('//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) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx index 38f5b59..1528603 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.admins._index/route.tsx @@ -25,6 +25,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@repo/ui/components/ui/dropdown-menu' import { Spinner } from '@repo/ui/components/ui/spinner' @@ -138,6 +139,7 @@ function ActionMenu({ id }: { id: string }) { )} + diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx index 634d4ce..ff4a9f0 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/columns.tsx @@ -194,7 +194,7 @@ function RemoveDedupItem({ }) 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) onSuccess?.() } @@ -279,7 +279,7 @@ function CancelItem({ }) if (r.ok) { - toast.info('A matrícula foi cancelada') + toast.info('A matrícula foi cancelada.') setOpen(false) onSuccess?.() } diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx index d14152c..7f158dd 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx @@ -1,12 +1,17 @@ import type { Route } from './+types/route' +import type { MouseEvent } from 'react' +import { useRequest, useToggle } from 'ahooks' import { BanIcon, CalendarIcon, + CircleXIcon, EllipsisIcon, PlusIcon, + RocketIcon, UserIcon } from 'lucide-react' +import { toast } from 'sonner' import { DateTime } from 'luxon' import { Fragment, Suspense } from 'react' import { Await } from 'react-router' @@ -37,6 +42,27 @@ import { Link } from 'react-router' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' import { initials } from '@repo/ui/lib/utils' 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) { return [{ title: 'Matrículas agendadas' }] @@ -100,7 +126,10 @@ export default function Route({ return (
{scheduled.map(([run_at, items], index) => ( -
+
{DateTime.fromISO(run_at) .setLocale('pt-BR') @@ -112,7 +141,7 @@ export default function Route({ {items.map( ( - { user, course, created_by, scheduled_at }, + { sk, user, course, created_by, scheduled_at }, index ) => ( @@ -133,7 +162,7 @@ export default function Route({
  • {' '} @@ -148,11 +177,9 @@ export default function Route({
- {/* - - */} + + + {index !== items.length - 1 && } @@ -172,6 +199,93 @@ export default function Route({ ) } +function ActionMenu({ sk }: { sk: string }) { + const { revalidate } = useRevalidator() + + const onSuccess = () => { + revalidate() + } + + return ( + + + + + + + + Matricular agora + + + + + + ) +} + +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) => { + e.preventDefault() + + const r = await runAsync() + if (r.ok) { + toast.info('O agendamento foi cancelada.') + onSuccess?.() + } + + setOpen(false) + } + + return ( + + + e.preventDefault()} + > + Cancelar + + + + + Tem certeza absoluta? + + Esta ação não pode ser desfeita. Isso{' '} + + cancela permanentemente o agendamento + {' '} + desta matrícula. + + + + + + + Cancelar + + + + ) +} + function grouping(items) { const newItems = Object.entries( items.reduce((acc, item) => { diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx index 19802c5..8ad4ebf 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users._index/columns.tsx @@ -27,6 +27,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@repo/ui/components/ui/dropdown-menu' import { Spinner } from '@repo/ui/components/ui/spinner' @@ -74,6 +75,7 @@ function ActionMenu({ row }: { row: any }) { )} + diff --git a/orders-events/app/events/billing/append_enrollment.py b/orders-events/app/events/billing/append_enrollment.py index 339e6fc..a6accd8 100644 --- a/orders-events/app/events/billing/append_enrollment.py +++ b/orders-events/app/events/billing/append_enrollment.py @@ -62,14 +62,14 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: enrollment['course'] = old_course 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'], date_=created_at, ) pk = 'BILLING#ORG#{org_id}'.format(org_id=org_id) sk = 'START#{start}#END#{end}'.format( - start=start_date.isoformat(), - end=end_date.isoformat(), + start=start_period.isoformat(), + end=end_period.isoformat(), ) logger.info('Enrollment found', data=enrollment) @@ -91,7 +91,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: 'id': pk, 'sk': f'{sk}#SCHEDULE#AUTO_CLOSE', '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_, } diff --git a/orders-events/app/events/billing/cancel_enrollment.py b/orders-events/app/events/billing/cancel_enrollment.py index c25b534..b781e08 100644 --- a/orders-events/app/events/billing/cancel_enrollment.py +++ b/orders-events/app/events/billing/cancel_enrollment.py @@ -32,17 +32,17 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: ) 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']), date_=created_at, ) pk = 'BILLING#ORG#{org_id}'.format(org_id=subscription['org_id']) sk = 'START#{start}#END#{end}'.format( - start=start_date.isoformat(), - end=end_date.isoformat(), + start=start_period.isoformat(), + end=end_period.isoformat(), ) - if now_.date() > end_date: + if now_.date() > end_period: logger.debug('Enrollment outside the billing period') return False diff --git a/orders-events/app/events/billing/close_window.py b/orders-events/app/events/billing/close_window.py index 5e3c052..215c23a 100644 --- a/orders-events/app/events/billing/close_window.py +++ b/orders-events/app/events/billing/close_window.py @@ -25,13 +25,13 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: now_ = now() # Key pattern `BILLING#ORG#{org_id}` *_, org_id = keys['id'].split('#') - # Key pattern `START#{start_date}#END#{end_date}#SCHEDULE#AUTO_CLOSE` - _, start_date, _, end_date, *_ = keys['sk'].split('#') + # Key pattern `START#{start_period}#END#{end_period}#SCHEDULE#AUTO_CLOSE` + _, start_period, _, end_period, *_ = keys['sk'].split('#') result = order_layer.collection.query( KeyPair( pk=keys['id'], - sk=f'START#{start_date}#END#{end_date}#ENROLLMENT', + sk=f'START#{start_period}#END#{end_period}#ENROLLMENT', ), limit=150, ) @@ -40,8 +40,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: { 'template_uri': BILLING_TEMPLATE_URI, 'args': { - 'start_date': start_date, - 'end_date': end_date, + 'start_date': start_period, + 'end_date': end_period, 'items': result['items'], }, }, @@ -56,7 +56,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: logger.info('The request timed out') 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}' try: @@ -74,7 +74,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: transact.update( key=KeyPair( 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, \ updated_at = :updated_at', diff --git a/orders-events/app/events/billing/send_email_on_closing.py b/orders-events/app/events/billing/send_email_on_closing.py index 8fc14b3..782a9c7 100644 --- a/orders-events/app/events/billing/send_email_on_closing.py +++ b/orders-events/app/events/billing/send_email_on_closing.py @@ -12,7 +12,7 @@ from boto3clients import dynamodb_client, s3_client, sesv2_client from config import EMAIL_SENDER, USER_TABLE 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') BCC = [ @@ -24,7 +24,7 @@ MESSAGE = """ Oi, tudo bem?

Em anexo você encontra o relatório das matrículas realizadas no período de -{start_date} a {end_date}.

+{start_period} a {end_period}.

📈 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'] # Key pattern `BILLING#ORG#{org_id}` *_, org_id = new_image['id'].split('#') - # Key pattern `START#{start_date}#END#{end_date} - _, start_date, _, end_date, *_ = new_image['sk'].split('#') + # Key pattern `START#{start_period}#END#{v} + _, start_period, _, end_period, *_ = new_image['sk'].split('#') emailmsg = Message( from_=EMAIL_SENDER, @@ -52,24 +52,24 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: reply_to=REPLY_TO, bcc=BCC, subject=SUBJECT.format( - start_date=_locale_dateformat(start_date), - end_date=_locale_dateformat(end_date), + start_period=_locale_dateformat(start_period), + end_period=_locale_dateformat(end_period), ), ) emailmsg.add_alternative( MESSAGE.format( org_id=org_id, - start_date=_locale_dateformat(start_date), - end_date=_locale_dateformat(end_date), - start_query=start_date, - end_query=start_date, + start_period=_locale_dateformat(start_period), + end_period=_locale_dateformat(end_period), + start_query=start_period, + end_query=end_period, ) ) attachment = MIMEApplication(_get_file_bytes(new_image['s3_uri'])) attachment.add_header( 'Content-Disposition', 'attachment', - filename=f'{start_date}_{end_date}.pdf', + filename=f'{start_period}_{end_period}.pdf', ) emailmsg.attach(attachment) diff --git a/orders-events/tests/events/billing/test_cancel_enrollment.py b/orders-events/tests/events/billing/test_cancel_enrollment.py index e7e9993..46ac4c5 100644 --- a/orders-events/tests/events/billing/test_cancel_enrollment.py +++ b/orders-events/tests/events/billing/test_cancel_enrollment.py @@ -30,14 +30,14 @@ def test_cancel_enrollment( lambda_context: LambdaContext, ): now_ = now() - start_date, end_date = get_billing_period( + start_period, end_period = get_billing_period( billing_day=6, date_=now_, ) pk = 'BILLING#ORG#cJtK9SsnJhKPyxESe7g3DG' sk = 'START#{start}#END#{end}#ENROLLMENT#{enrollment_id}'.format( - start=start_date.isoformat(), - end=end_date.isoformat(), + start=start_period.isoformat(), + end=end_period.isoformat(), enrollment_id=enrollment_id, ) # Add up-to-date enrollment item to billing diff --git a/packages/ui/src/components/ui/item.tsx b/packages/ui/src/components/ui/item.tsx index d97de21..d58e5a0 100644 --- a/packages/ui/src/components/ui/item.tsx +++ b/packages/ui/src/components/ui/item.tsx @@ -1,16 +1,16 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from "@/lib/utils" -import { Separator } from "@/components/ui/separator" +import { cn } from '@/lib/utils' +import { Separator } from '@/components/ui/separator' -function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { +function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
) @@ -24,42 +24,42 @@ function ItemSeparator({ ) } 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: { variant: { - default: "bg-transparent", - outline: "border-border", - muted: "bg-muted/50", + default: 'bg-transparent', + outline: 'border-border', + muted: 'bg-muted/50' }, size: { - default: "p-4 gap-4 ", - sm: "py-3 px-4 gap-2.5", - }, + default: 'p-4 gap-4 ', + sm: 'py-3 px-4 gap-2.5' + } }, defaultVariants: { - variant: "default", - size: "default", - }, + variant: 'default', + size: 'default' + } } ) function Item({ className, - variant = "default", - size = "default", + variant = 'default', + size = 'default', asChild = false, ...props -}: React.ComponentProps<"div"> & +}: React.ComponentProps<'div'> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div" + const Comp = asChild ? Slot : 'div' return ( & VariantProps) { +}: React.ComponentProps<'div'> & VariantProps) { return (
) { +function ItemContent({ className, ...props }: React.ComponentProps<'div'>) { return (
) { ) } -function ItemTitle({ className, ...props }: React.ComponentProps<"div">) { +function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) { return (
) { ) } -function ItemDescription({ className, ...props }: React.ComponentProps<"p">) { +function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) { return (

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + 'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance', + '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', className )} {...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 (

) } -function ItemHeader({ className, ...props }: React.ComponentProps<"div">) { +function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
) { ) } -function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { +function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) { return (