diff --git a/.gitignore b/.gitignore index 3cd5296e7..35a1877f2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Python files __pycache__/ *.pyc +.python-version # Distribution /frontend/public/storybook/ @@ -27,6 +28,8 @@ db.sqlite3 # React node_modules/ +.yarn +.yarnrc.yml .next/ # Development Enviroment diff --git a/backend/clubs/management/commands/populate.py b/backend/clubs/management/commands/populate.py index 5abe1e1a3..5c8dd77c5 100644 --- a/backend/clubs/management/commands/populate.py +++ b/backend/clubs/management/commands/populate.py @@ -394,6 +394,12 @@ def get_image(url): tag_undergrad, _ = Tag.objects.get_or_create(name="Undergraduate") tag_generic, _ = Tag.objects.get_or_create(name="Generic") + wharton_badge, _ = Badge.objects.get_or_create( + label="Wharton Council", + purpose="Dummy badge to mock Wharton-affiliated clubs", + visible=True, + ) + for i in range(1, 50): club, created = Club.objects.get_or_create( code="z-club-{}".format(i), @@ -406,6 +412,10 @@ def get_image(url): }, ) + if 10 <= i <= 15: + # Make some clubs Wharton-affiliated + club.badges.add(wharton_badge) + if created: club.available_virtually = i % 2 == 0 club.appointment_needed = i % 3 == 0 diff --git a/backend/clubs/management/commands/wharton_council_application.py b/backend/clubs/management/commands/wharton_council_application.py deleted file mode 100644 index 13d5f77bb..000000000 --- a/backend/clubs/management/commands/wharton_council_application.py +++ /dev/null @@ -1,167 +0,0 @@ -from datetime import datetime - -from django.core.management.base import BaseCommand - -from clubs.models import ( - ApplicationCycle, - ApplicationMultipleChoice, - ApplicationQuestion, - Badge, - Club, - ClubApplication, -) - - -class Command(BaseCommand): - help = "Helper to automatically create the Wharton council club applications." - web_execute = True - - def add_arguments(self, parser): - parser.add_argument( - "application_start_time", - type=str, - help="Date and time at which the centralized application opens.", - ) - parser.add_argument( - "application_end_time", - type=str, - help="Date and time at which the centralized application closes.", - ) - parser.add_argument( - "result_release_time", - type=str, - help="Date and time at which the centralized application results " - "are released.", - ) - parser.add_argument( - "application_cycle", type=str, help="A name for the application cycle" - ) - parser.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Do not actually create applications.", - ) - parser.add_argument( - "--clubs", - dest="clubs", - type=str, - help="The comma separated list of club codes for which to create the " - "centralized applications.", - ) - parser.set_defaults( - application_start_time="2021-09-04 00:00:00", - application_end_time="2021-09-04 00:00:00", - result_release_time="2021-09-04 00:00:00", - application_cycle="", - dry_run=False, - clubs="", - ) - - def handle(self, *args, **kwargs): - dry_run = kwargs["dry_run"] - club_names = list(map(lambda x: x.strip(), kwargs["clubs"].split(","))) - app_cycle = kwargs["application_cycle"] - clubs = [] - - if not club_names or all(not name for name in club_names): - wc_badge = Badge.objects.filter( - label="Wharton Council", - purpose="org", - ).first() - clubs = list(Club.objects.filter(badges=wc_badge)) - else: - clubs = list(Club.objects.filter(code__in=club_names)) - - application_start_time = datetime.strptime( - kwargs["application_start_time"], "%Y-%m-%d %H:%M:%S" - ) - application_end_time = datetime.strptime( - kwargs["application_end_time"], "%Y-%m-%d %H:%M:%S" - ) - result_release_time = datetime.strptime( - kwargs["result_release_time"], "%Y-%m-%d %H:%M:%S" - ) - - prompt_one = ( - "Tell us about a time you took " "initiative or demonstrated leadership" - ) - prompt_two = "Tell us about a time you faced a challenge and how you solved it" - prompt_three = "Tell us about a time you collaborated well in a team" - - cycle, _ = ApplicationCycle.objects.get_or_create( - name=app_cycle, - start_date=application_start_time, - end_date=application_end_time, - ) - - if len(clubs) == 0: - self.stdout.write("No valid club codes provided, returning...") - - for club in clubs: - name = f"{club.name} Application" - if dry_run: - self.stdout.write(f"Would have created application for {club.name}") - else: - self.stdout.write(f"Creating application for {club.name}") - - most_recent = ( - ClubApplication.objects.filter(club=club) - .order_by("-created_at") - .first() - ) - - if most_recent: - # If an application for this club exists, clone it - application = most_recent.make_clone() - application.application_start_time = application_start_time - application.application_end_time = application_end_time - application.result_release_time = result_release_time - application.application_cycle = cycle - application.is_wharton_council = True - application.external_url = ( - f"https://pennclubs.com/club/{club.code}/" - f"application/{application.pk}" - ) - application.save() - else: - # Otherwise, start afresh - application = ClubApplication.objects.create( - name=name, - club=club, - application_start_time=application_start_time, - application_end_time=application_end_time, - result_release_time=result_release_time, - application_cycle=cycle, - is_wharton_council=True, - ) - external_url = ( - f"https://pennclubs.com/club/{club.code}/" - f"application/{application.pk}" - ) - application.external_url = external_url - application.save() - prompt = ( - "Choose one of the following " - "prompts for your personal statement" - ) - prompt_question = ApplicationQuestion.objects.create( - question_type=ApplicationQuestion.MULTIPLE_CHOICE, - application=application, - prompt=prompt, - ) - ApplicationMultipleChoice.objects.create( - value=prompt_one, question=prompt_question - ) - ApplicationMultipleChoice.objects.create( - value=prompt_two, question=prompt_question - ) - ApplicationMultipleChoice.objects.create( - value=prompt_three, question=prompt_question - ) - ApplicationQuestion.objects.create( - question_type=ApplicationQuestion.FREE_RESPONSE, - prompt="Answer the prompt you selected", - word_limit=150, - application=application, - ) diff --git a/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py b/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py new file mode 100644 index 000000000..92377bdb5 --- /dev/null +++ b/backend/clubs/migrations/0091_clubapplication_application_end_time_exception.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2023-11-17 22:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0090_auto_20230106_1443"), + ] + + operations = [ + migrations.AddField( + model_name="clubapplication", + name="application_end_time_exception", + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/backend/clubs/migrations/0092_merge_20240106_1117.py b/backend/clubs/migrations/0092_merge_20240106_1117.py new file mode 100644 index 000000000..9656c2c26 --- /dev/null +++ b/backend/clubs/migrations/0092_merge_20240106_1117.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.18 on 2024-01-06 16:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0091_applicationextension"), + ("clubs", "0091_clubapplication_application_end_time_exception"), + ] + + operations = [] diff --git a/backend/clubs/migrations/0092_auto_20240106_1119.py b/backend/clubs/migrations/0093_auto_20240106_1153.py similarity index 84% rename from backend/clubs/migrations/0092_auto_20240106_1119.py rename to backend/clubs/migrations/0093_auto_20240106_1153.py index c4995cadd..d848ce713 100644 --- a/backend/clubs/migrations/0092_auto_20240106_1119.py +++ b/backend/clubs/migrations/0093_auto_20240106_1153.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.18 on 2024-01-06 16:19 +# Generated by Django 3.2.18 on 2024-01-06 16:53 from django.conf import settings from django.db import migrations @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("clubs", "0091_applicationextension"), + ("clubs", "0092_merge_20240106_1117"), ] operations = [ diff --git a/backend/clubs/migrations/0094_applicationcycle_release_date.py b/backend/clubs/migrations/0094_applicationcycle_release_date.py new file mode 100644 index 000000000..62ad134f9 --- /dev/null +++ b/backend/clubs/migrations/0094_applicationcycle_release_date.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.18 on 2024-01-11 14:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("clubs", "0093_auto_20240106_1153"), + ] + + operations = [ + migrations.AddField( + model_name="applicationcycle", + name="release_date", + field=models.DateTimeField(null=True), + ), + ] diff --git a/backend/clubs/models.py b/backend/clubs/models.py index 80afb9794..43d2acd0c 100644 --- a/backend/clubs/models.py +++ b/backend/clubs/models.py @@ -1534,6 +1534,7 @@ class ApplicationCycle(models.Model): name = models.CharField(max_length=255) start_date = models.DateTimeField(null=True) end_date = models.DateTimeField(null=True) + release_date = models.DateTimeField(null=True) def __str__(self): return self.name @@ -1551,6 +1552,7 @@ class ClubApplication(CloneModel): description = models.TextField(blank=True) application_start_time = models.DateTimeField() application_end_time = models.DateTimeField() + application_end_time_exception = models.BooleanField(default=False, blank=True) name = models.TextField(blank=True) result_release_time = models.DateTimeField() application_cycle = models.ForeignKey( diff --git a/backend/clubs/serializers.py b/backend/clubs/serializers.py index cd12b40b1..a4295be7b 100644 --- a/backend/clubs/serializers.py +++ b/backend/clubs/serializers.py @@ -23,6 +23,7 @@ AdminNote, Advisor, ApplicationCommittee, + ApplicationCycle, ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, @@ -97,6 +98,28 @@ def save(self): return super().save() +class ApplicationCycleSerializer(serializers.ModelSerializer): + class Meta: + model = ApplicationCycle + fields = ["id", "name", "start_date", "end_date", "release_date"] + + def validate(self, data): + """ + Check that start_date <= end_date <= release_date + """ + start_date = data.get("start_date") + end_date = data.get("end_date") + release_date = data.get("release_date") + + if start_date and end_date and start_date >= end_date: + raise serializers.ValidationError("Start must be before end.") + + if end_date and release_date and end_date >= release_date: + raise serializers.ValidationError("End must be before release.") + + return data + + class TagSerializer(serializers.ModelSerializer): clubs = serializers.IntegerField(read_only=True) @@ -1011,6 +1034,7 @@ class Meta: "is_favorite", "is_member", "is_subscribe", + "is_wharton", "membership_count", "recruiting_cycle", "name", @@ -1663,7 +1687,6 @@ class Meta(ClubListSerializer.Meta): "instagram", "is_ghost", "is_request", - "is_wharton", "linkedin", "listserv", "members", @@ -2804,6 +2827,7 @@ class Meta: "rejection_email", "application_start_time", "application_end_time", + "application_end_time_exception", "result_release_time", "external_url", "committees", diff --git a/backend/clubs/urls.py b/backend/clubs/urls.py index d1526e5a9..69d517551 100644 --- a/backend/clubs/urls.py +++ b/backend/clubs/urls.py @@ -51,6 +51,7 @@ UserZoomAPIView, WhartonApplicationAPIView, WhartonApplicationStatusAPIView, + WhartonCyclesView, YearViewSet, email_preview, ) @@ -78,6 +79,16 @@ router.register( r"external/members/(?P.+)", ExternalMemberListViewSet, basename="external" ) +router.register( + r"cycles", + WhartonCyclesView, + basename="wharton-applications-create", +) +router.register( + r"whartonapplications", + WhartonApplicationAPIView, + basename="wharton", +) router.register(r"submissions", ApplicationSubmissionUserViewSet, basename="submission") clubs_router = routers.NestedSimpleRouter(router, r"clubs", lookup="club") @@ -156,11 +167,6 @@ MeetingZoomWebhookAPIView.as_view(), name="webhooks-meeting", ), - path( - r"whartonapplications/", - WhartonApplicationAPIView.as_view(), - name="wharton-applications", - ), path( r"whartonapplications/status/", WhartonApplicationStatusAPIView.as_view(), diff --git a/backend/clubs/views.py b/backend/clubs/views.py index 5a624e746..6861b3291 100644 --- a/backend/clubs/views.py +++ b/backend/clubs/views.py @@ -69,6 +69,7 @@ from clubs.models import ( AdminNote, Advisor, + ApplicationCycle, ApplicationExtension, ApplicationMultipleChoice, ApplicationQuestion, @@ -125,6 +126,7 @@ from clubs.serializers import ( AdminNoteSerializer, AdvisorSerializer, + ApplicationCycleSerializer, ApplicationExtensionSerializer, ApplicationQuestionResponseSerializer, ApplicationQuestionSerializer, @@ -4876,9 +4878,265 @@ def get_queryset(self): ) -class WhartonApplicationAPIView(generics.ListAPIView): +class WhartonCyclesView(viewsets.ModelViewSet): """ - get: Return information about all Wharton Council club applications which are + get: Return information about all Wharton Council application cycles + patch: Update application cycle and WC applications with cycle + clubs: list clubs with cycle + add_clubs: add clubs to cycle + remove_clubs_from_all: remove clubs from all cycles + """ + + permission_classes = [WhartonApplicationPermission | IsSuperuser] + # Designed to support partial updates, but ModelForm sends all fields here + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = ApplicationCycleSerializer + + def get_queryset(self): + return ApplicationCycle.objects.all().order_by("end_date") + + def update(self, *args, **kwargs): + """ + Updates times for all applications with cycle + """ + applications = ClubApplication.objects.filter( + application_cycle=self.get_object() + ) + str_start_date = self.request.data.get("start_date").replace("T", " ") + str_end_date = self.request.data.get("end_date").replace("T", " ") + str_release_date = self.request.data.get("release_date").replace("T", " ") + time_format = "%Y-%m-%d %H:%M:%S%z" + start = ( + datetime.datetime.strptime(str_start_date, time_format) + if str_start_date + else self.get_object().start_date + ) + end = ( + datetime.datetime.strptime(str_end_date, time_format) + if str_end_date + else self.get_object().end_date + ) + release = ( + datetime.datetime.strptime(str_release_date, time_format) + if str_release_date + else self.get_object().release_date + ) + for app in applications: + app.application_start_time = start + if app.application_end_time_exception: + continue + app.application_end_time = end + app.result_release_time = release + f = ["application_start_time", "application_end_time", "result_release_time"] + ClubApplication.objects.bulk_update(applications, f) + return super().update(*args, **kwargs) + + @action(detail=True, methods=["GET"]) + def get_clubs(self, *args, **kwargs): + """ + Retrieve clubs associated with given cycle + --- + requestBody: + content: {} + responses: + "200": + content: {} + --- + """ + cycle = self.get_object() + + return Response( + ClubApplication.objects.filter(application_cycle=cycle) + .select_related("club") + .values("club__name", "club__code") + ) + + @action(detail=True, methods=["PATCH"]) + def edit_clubs(self, *args, **kwargs): + """ + Edit clubs associated with given cycle + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: string + responses: + "200": + content: {} + --- + + """ + cycle = self.get_object() + club_codes = self.request.data.get("clubs") + start = cycle.start_date + end = cycle.end_date + release = cycle.release_date + + # Some apps get deleted + ClubApplication.objects.filter(application_cycle=cycle).exclude( + club__code__in=club_codes + ).delete() + + # Some apps need to be created - use the default Wharton Template + prompt_one = ( + "Tell us about a time you took " "initiative or demonstrated leadership" + ) + prompt_two = "Tell us about a time you faced a challenge and how you solved it" + prompt_three = "Tell us about a time you collaborated well in a team" + created_apps_clubs = ( + ClubApplication.objects.filter( + application_cycle=cycle, club__code__in=club_codes + ) + .select_related("club") + .values_list("club__code", flat=True) + ) + creation_pending_clubs = Club.objects.filter( + code__in=set(club_codes) - set(created_apps_clubs) + ) + + for club in creation_pending_clubs: + name = f"{club.name} Application" + most_recent = ( + ClubApplication.objects.filter(club=club) + .order_by("-created_at") + .first() + ) + + if most_recent: + # If an application for this club exists, clone it + application = most_recent.make_clone() + application.application_start_time = start + application.application_end_time = end + application.result_release_time = release + application.application_cycle = cycle + application.is_wharton_council = True + application.external_url = ( + f"https://pennclubs.com/club/{club.code}/" + f"application/{application.pk}" + ) + application.save() + else: + # Otherwise, start afresh + application = ClubApplication.objects.create( + name=name, + club=club, + application_start_time=start, + application_end_time=end, + result_release_time=release, + application_cycle=cycle, + is_wharton_council=True, + ) + external_url = ( + f"https://pennclubs.com/club/{club.code}/" + f"application/{application.pk}" + ) + application.external_url = external_url + application.save() + prompt = ( + "Choose one of the following prompts for your personal statement" + ) + prompt_question = ApplicationQuestion.objects.create( + question_type=ApplicationQuestion.MULTIPLE_CHOICE, + application=application, + prompt=prompt, + ) + ApplicationMultipleChoice.objects.create( + value=prompt_one, question=prompt_question + ) + ApplicationMultipleChoice.objects.create( + value=prompt_two, question=prompt_question + ) + ApplicationMultipleChoice.objects.create( + value=prompt_three, question=prompt_question + ) + ApplicationQuestion.objects.create( + question_type=ApplicationQuestion.FREE_RESPONSE, + prompt="Answer the prompt you selected", + word_limit=150, + application=application, + ) + + return Response([]) + + @action(detail=False, methods=["post"]) + def add_clubs_to_exception(self, *args, **kwargs): + """ + Exempt selected clubs from application cycle deadline + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: object + properties: + id: + type: integer + application_end_time: + type: string + responses: + "200": + content: {} + --- + """ + clubs = self.request.data.get("clubs") + apps = [] + for club in clubs: + app = ClubApplication.objects.get(pk=club["id"]) + apps.append(app) + app.application_end_time = club["end_date"] + app.application_end_time_exception = True + ClubApplication.objects.bulk_update( + apps, + ["application_end_time", "application_end_time_exception"], + ) + return Response([]) + + @action(detail=False, methods=["post"]) + def remove_clubs_from_exception(self, *args, **kwargs): + """ + Remove selected clubs from application cycle deadline exemption + --- + requestBody: + content: + application/json: + schema: + type: object + properties: + clubs: + type: array + items: + type: string + responses: + "200": + content: {} + --- + """ + club_ids = self.request.data.get("clubs", []) + apps = ClubApplication.objects.filter(pk__in=club_ids) + for app in apps: + app.application_end_time_exception = False + app.application_end_time = app.application_cycle.end_date + ClubApplication.objects.bulk_update( + apps, + ["application_end_time", "application_end_time_exception"], + ) + return Response([]) + + +class WhartonApplicationAPIView(viewsets.ModelViewSet): + """ + list: Return information about all Wharton Council club applications which are currently on going """ @@ -4886,7 +5144,7 @@ class WhartonApplicationAPIView(generics.ListAPIView): serializer_class = ClubApplicationSerializer def get_operation_id(self, **kwargs): - return "List Wharton applications and details" + return f"{kwargs['operId']} Wharton Application" def get_queryset(self): now = timezone.now() diff --git a/frontend/components/ModelForm.tsx b/frontend/components/ModelForm.tsx index 8b55ae052..95af62886 100644 --- a/frontend/components/ModelForm.tsx +++ b/frontend/components/ModelForm.tsx @@ -1,4 +1,5 @@ import { Form, Formik } from 'formik' +import moment from 'moment' import { ReactElement, useEffect, useMemo, useState } from 'react' import styled from 'styled-components' @@ -355,6 +356,9 @@ export const ModelForm = (props: ModelFormProps): ReactElement => { data[key] !== null ) ) { + if (data[key] instanceof Date) { + data[key] = moment(data[key]).format('YYYY-MM-DD HH:mm:ssZ') + } flt[key] = data[key] } return flt diff --git a/frontend/components/Settings/WhartonApplicationCycles.tsx b/frontend/components/Settings/WhartonApplicationCycles.tsx new file mode 100644 index 000000000..ddb53aa9b --- /dev/null +++ b/frontend/components/Settings/WhartonApplicationCycles.tsx @@ -0,0 +1,327 @@ +import { Field } from 'formik' +import { ReactElement, useEffect, useState } from 'react' +import DatePicker from 'react-datepicker' +import Select from 'react-select' +import styled from 'styled-components' + +import { ClubApplication } from '~/types' +import { doApiRequest } from '~/utils' + +import { Checkbox, Icon, Loading, Modal, Subtitle, Text } from '../common' +import { DateTimeField, TextField } from '../FormComponents' +import ModelForm from '../ModelForm' + +const fields = ( + <> + + + + + +) + +type Cycle = { + name: string + id: number | null +} + +type ClubOption = { + label: string + value: number +} + +type ExtensionOption = { + id: number + clubName: string + endDate: Date + exception?: boolean + changed: boolean +} + +const ScrollWrapper = styled.div` + overflow-y: auto; + margin-top: 1rem; + height: 40vh; +` + +const WhartonApplicationCycles = (): ReactElement => { + const [editMembership, setEditMembership] = useState(false) + const [membershipCycle, setMembershipCycle] = useState({ + name: '', + id: null, + }) + + const [editExtensions, setEditExtensions] = useState(false) + const [extensionsCycle, setExtensionsCycle] = useState({ + name: '', + id: null, + }) + + const [clubsSelectedMembership, setClubsSelectedMembership] = useState< + ClubOption[] + >([]) + const [clubOptionsMembership, setClubOptionsMembership] = useState< + ClubOption[] + >([]) + + const [clubsExtensions, setClubsExtensions] = useState([]) + + const [permissions, setPermissions] = useState(null) + + const closeMembershipModal = (): void => { + setEditMembership(false) + + // call /cycles/:id/clubs to set the clubs associated with the cycle + doApiRequest(`/cycles/${membershipCycle.id}/edit_clubs/`, { + method: 'PATCH', + body: { clubs: clubsSelectedMembership.map((x) => x.value) }, + }) + } + + const closeExtensionsModal = (): void => { + setEditExtensions(false) + // calculate clubs that have changed + const clubsToUpdate = clubsExtensions.filter((x) => x.changed) + // split into clubs with exceptions and clubs without + const clubsExceptions = clubsToUpdate.filter((x) => x.exception) + const clubsNoExceptions = clubsToUpdate.filter((x) => !x.exception) + + // call /cycles/:id/add_clubs and /cycles/remove_clubs_from_all with data.clubs as list of ids + if (clubsExceptions.length > 0) { + doApiRequest(`/cycles/add_clubs_to_exception/`, { + method: 'POST', + body: { + clubs: clubsExceptions.map((x) => { + // eslint-disable-next-line camelcase + return { id: x.id, end_date: x.endDate } + }), + }, + }) + } + if (clubsNoExceptions.length > 0) { + doApiRequest(`/cycles/remove_clubs_from_exception/`, { + method: 'POST', + body: { clubs: clubsNoExceptions.map((x) => x.id) }, + }) + } + } + + useEffect(() => { + doApiRequest('/clubs/?format=json') + .then((resp) => resp.json()) + .then((data) => data.filter((club) => club.is_wharton)) + .then((data) => { + setClubOptionsMembership( + data.map((club) => { + return { label: club.name, value: club.code } + }), + ) + }) + }, []) + + const refreshMembership = (): void => { + if (membershipCycle && membershipCycle.id != null) { + doApiRequest(`/cycles/${membershipCycle.id}/get_clubs?format=json`) + .then((resp) => resp.json()) + .then((associatedClubs) => { + setClubsSelectedMembership( + associatedClubs.map((data) => { + return { label: data.club__name, value: data.club__code } + }), + ) + }) + } + } + + useEffect(() => { + refreshMembership() + }, [membershipCycle]) + + useEffect(() => { + doApiRequest('/cycles') + .then((resp) => resp.json()) + .then((data) => { + setPermissions(!data.detail) + }) + }) + + useEffect(() => { + if (extensionsCycle && extensionsCycle.id != null) { + doApiRequest(`/cycles/${extensionsCycle.id}/clubs?format=json`) + .then((resp) => resp.json()) + .then((data) => { + const initialOptions = data.map((club: ClubApplication) => { + return { + id: club.id, + clubName: club.name, + endDate: new Date(club.application_end_time), + exception: club.application_end_time_exception, + changed: false, + } + }) + setClubsExtensions(initialOptions) + }) + } + }, [extensionsCycle]) + + if (clubOptionsMembership == null || permissions == null) { + return + } + + if (!permissions) { + return You do not have permission to view this page. + } + + return ( + <> + ( + <> + + + + )} + /> + setEditMembership(false)}> + {membershipCycle && membershipCycle.name && ( + <> + + Club Membership for {membershipCycle.name} Cycle + + { + <> +
+ +