From 0df224029031e877a1787b5b187f194fb060910c Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Mon, 2 Mar 2026 00:19:41 +1300
Subject: [PATCH 01/13] feat(ui): add create game schedule popup component
- Also removed usage of deprecated `leftIcon` prop.
- Added component test+stories for `CreateGameSchedulePopUp`
- Added date util functions and tests relevant to this component.
---
apps/backend/src/utils/date.test.ts | 65 +++++
packages/shared/src/enums/popup.ts | 2 +
.../src/schemas/game-session-schedule.ts | 24 ++
packages/shared/src/utils/date.ts | 47 ++++
.../CreateGameSchedulePopUp.stories.tsx | 36 +++
.../CreateGameSchedulePopUp.test.tsx | 114 +++++++++
.../CreateGameSchedulePopUp.tsx | 236 ++++++++++++++++++
.../Generic/CreateGameSchedulePopUp/index.ts | 1 +
.../CreateMemberPopUp/CreateMemberPopUp.tsx | 2 +-
.../CreateSessionPopUp/CreateSessionPopUp.tsx | 2 +-
packages/ui/src/components/Generic/index.ts | 1 +
11 files changed, 528 insertions(+), 2 deletions(-)
create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx
create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx
create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/index.ts
diff --git a/apps/backend/src/utils/date.test.ts b/apps/backend/src/utils/date.test.ts
index 1a4482321..b743222b5 100644
--- a/apps/backend/src/utils/date.test.ts
+++ b/apps/backend/src/utils/date.test.ts
@@ -5,13 +5,78 @@ import {
getDateTimeStatus,
getDaysBetweenWeekdays,
getGameSessionOpenDay,
+ isoToTimeInput,
isSameDate,
+ timeInputToIso,
Weekday,
} from "@repo/shared"
import { semesterMock } from "@repo/shared/mocks"
import type { Semester } from "@repo/shared/payload-types"
import { vi } from "vitest"
+describe("isoToTimeInput", () => {
+ it("should return empty string for undefined", () => {
+ expect(isoToTimeInput(undefined)).toBe("")
+ })
+
+ it("should return empty string for empty string", () => {
+ expect(isoToTimeInput("")).toBe("")
+ })
+
+ it("should return empty string for an invalid date string", () => {
+ expect(isoToTimeInput("not-a-date")).toBe("")
+ })
+
+ it("should format a UTC morning time correctly", () => {
+ expect(isoToTimeInput("2024-01-01T09:30:00.000Z")).toBe("09:30")
+ })
+
+ it("should format a UTC afternoon time correctly", () => {
+ expect(isoToTimeInput("2024-01-01T14:05:00.000Z")).toBe("14:05")
+ })
+
+ it("should format midnight correctly", () => {
+ expect(isoToTimeInput("2024-01-01T00:00:00.000Z")).toBe("00:00")
+ })
+
+ it("should format the end of day correctly", () => {
+ expect(isoToTimeInput("2024-01-01T23:59:00.000Z")).toBe("23:59")
+ })
+
+ it("should pad single-digit hours and minutes with a leading zero", () => {
+ expect(isoToTimeInput("2024-06-15T08:05:00.000Z")).toBe("08:05")
+ })
+
+ it("should use UTC time regardless of the date's timezone offset", () => {
+ expect(isoToTimeInput("2024-01-01T09:00:00+05:00")).toBe("04:00")
+ })
+})
+
+describe("timeInputToIso", () => {
+ it("should convert a morning time to ISO string", () => {
+ expect(timeInputToIso("09:30")).toBe("1970-01-01T09:30:00.000Z")
+ })
+
+ it("should convert an afternoon time to ISO string", () => {
+ expect(timeInputToIso("14:05")).toBe("1970-01-01T14:05:00.000Z")
+ })
+
+ it("should convert midnight to ISO string", () => {
+ expect(timeInputToIso("00:00")).toBe("1970-01-01T00:00:00.000Z")
+ })
+
+ it("should convert end of day to ISO string", () => {
+ expect(timeInputToIso("23:59")).toBe("1970-01-01T23:59:00.000Z")
+ })
+
+ it("should be the inverse of isoToTimeInput", () => {
+ const times = ["00:00", "09:30", "12:00", "14:05", "23:59"]
+ for (const time of times) {
+ expect(isoToTimeInput(timeInputToIso(time))).toBe(time)
+ }
+ })
+})
+
describe("getDaysBetweenWeekdays", () => {
it("should return 0 when fromDay and toDay are the same", () => {
expect(getDaysBetweenWeekdays(Weekday.monday, Weekday.monday)).toBe(0)
diff --git a/packages/shared/src/enums/popup.ts b/packages/shared/src/enums/popup.ts
index b83284b2d..7b6481099 100644
--- a/packages/shared/src/enums/popup.ts
+++ b/packages/shared/src/enums/popup.ts
@@ -5,4 +5,6 @@ export enum Popup {
CHANGE_SESSION_FLOW = "change-session-flow",
CREATE_MEMBER = "create-member",
CREATE_SEMESTER = "create-semester",
+ CREATE_GAME_SCHEDULE = "create-game-schedule",
+ EDIT_GAME_SCHEDULE = "edit-game-schedule",
}
diff --git a/packages/shared/src/schemas/game-session-schedule.ts b/packages/shared/src/schemas/game-session-schedule.ts
index 5f1f30c6e..ffebd0a91 100644
--- a/packages/shared/src/schemas/game-session-schedule.ts
+++ b/packages/shared/src/schemas/game-session-schedule.ts
@@ -5,6 +5,30 @@ import { WeekdayZodEnum } from "../types"
import { PaginationDataSchema } from "./query"
import { SemesterSchema } from "./semester"
+export const CreateGameSchedulePopUpFormDataSchema = z
+ .object({
+ name: z.string().min(1, "Name is required"),
+ location: z.string().min(1, "Location is required"),
+ day: WeekdayZodEnum,
+ startTime: z.string().min(1, "Start time is required"),
+ endTime: z.string().min(1, "End time is required"),
+ capacity: z.coerce.number().int().min(1, "Capacity must be at least 1"),
+ casualCapacity: z.coerce.number().int().min(1, "Casual capacity must be at least 1"),
+ })
+ .refine(
+ ({ startTime, endTime }) => {
+ if (!startTime || !endTime) {
+ return true
+ }
+ return endTime > startTime
+ },
+ { message: "End time must be after start time", path: ["endTime"] },
+ )
+
+export type CreateGameSchedulePopUpFormValues = z.infer<
+ typeof CreateGameSchedulePopUpFormDataSchema
+>
+
export const GameSessionScheduleSchema = z.object({
id: z.string(),
semester: z.union([z.string(), SemesterSchema]),
diff --git a/packages/shared/src/utils/date.ts b/packages/shared/src/utils/date.ts
index c4a5122c9..27cc42248 100644
--- a/packages/shared/src/utils/date.ts
+++ b/packages/shared/src/utils/date.ts
@@ -182,6 +182,53 @@ export function formatDateToString(date: Date): string {
return dayjs(date).format("YYYY-MM-DD")
}
+/**
+ * Converts an ISO 8601 datetime string to a time input value in HH:MM format (UTC).
+ *
+ * Intended for populating HTML `` elements from stored ISO timestamps.
+ *
+ * @param iso The ISO 8601 datetime string to convert
+ * @returns Time string in HH:MM format, or empty string if input is falsy or invalid
+ *
+ * @example
+ * ```ts
+ * isoToTimeInput("2024-01-01T09:30:00.000Z") // Returns: "09:30"
+ * isoToTimeInput("2024-01-01T14:05:00.000Z") // Returns: "14:05"
+ * isoToTimeInput("") // Returns: ""
+ * isoToTimeInput(undefined) // Returns: ""
+ * ```
+ */
+export function isoToTimeInput(iso?: string): string {
+ if (!iso) return ""
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) {
+ return ""
+ }
+ const hours = String(date.getUTCHours()).padStart(2, "0")
+ const minutes = String(date.getUTCMinutes()).padStart(2, "0")
+ return `${hours}:${minutes}`
+}
+
+/**
+ * Converts an HTML `` value (HH:MM) to an ISO 8601 datetime string.
+ *
+ * The date portion is fixed to 1970-01-01 UTC to match the backend's time field normalization.
+ *
+ * @param time The time string in HH:MM format
+ * @returns ISO 8601 datetime string
+ *
+ * @example
+ * ```ts
+ * timeInputToIso("09:30") // Returns: "1970-01-01T09:30:00.000Z"
+ * timeInputToIso("14:05") // Returns: "1970-01-01T14:05:00.000Z"
+ * timeInputToIso("00:00") // Returns: "1970-01-01T00:00:00.000Z"
+ * ```
+ */
+export function timeInputToIso(time: string): string {
+ const [hours, minutes] = time.split(":")
+ return new Date(Date.UTC(1970, 0, 1, Number(hours), Number(minutes))).toISOString()
+}
+
/**
* Method used to get the status of a date
*
diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx
new file mode 100644
index 000000000..49a0bae08
--- /dev/null
+++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx
@@ -0,0 +1,36 @@
+import { gameSessionScheduleMock } from "@repo/shared/mocks"
+import type { Meta, StoryFn } from "@storybook/nextjs-vite"
+import {
+ CreateGameSchedulePopUp,
+ type CreateGameSchedulePopUpProps,
+} from "./CreateGameSchedulePopUp"
+
+const meta: Meta = {
+ title: "Generic Components / CreateGameSchedulePopUp",
+ component: CreateGameSchedulePopUp,
+ argTypes: {
+ title: { control: "text", description: "The dialog header title" },
+ scheduleToEdit: { control: "object", description: "Object to pre-fill input fields" },
+ onConfirm: { action: "confirmed" },
+ },
+ args: {
+ title: "Create Game Schedule",
+ },
+}
+
+export default meta
+type Story = StoryFn
+
+export const Default: Story = () => {
+ return console.log("Confirmed:", val)} open />
+}
+
+export const PrefilledData: Story = () => {
+ return (
+ console.log("Confirmed:", val)}
+ open
+ scheduleToEdit={gameSessionScheduleMock}
+ />
+ )
+}
diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx
new file mode 100644
index 000000000..ca3312bd6
--- /dev/null
+++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx
@@ -0,0 +1,114 @@
+import type { CreateGameSchedulePopUpFormValues } from "@repo/shared"
+import { gameSessionScheduleMock } from "@repo/shared/mocks"
+import { render, screen } from "@repo/ui/test-utils"
+import { noop } from "@yamada-ui/react"
+import { isValidElement, useState } from "react"
+import { Button } from "../../Primitive"
+import {
+ CreateGameSchedulePopUp,
+ type CreateGameSchedulePopUpProps,
+} from "./CreateGameSchedulePopUp"
+import * as CreateGameSchedulePopUpModule from "./index"
+
+const CreateGameSchedulePopUpExample = (props: CreateGameSchedulePopUpProps) => {
+ const [open, setOpen] = useState(false)
+
+ return (
+ <>
+
+ setOpen(false)} open={open} {...props} />
+ >
+ )
+}
+
+describe("", () => {
+ it("should re-export the CreateGameSchedulePopUp component and check if it exists", () => {
+ expect(CreateGameSchedulePopUpModule.CreateGameSchedulePopUp).toBeDefined()
+ expect(isValidElement()).toBeTruthy()
+ })
+
+ it("should have correct displayName", () => {
+ expect(CreateGameSchedulePopUp.displayName).toBe("CreateGameSchedulePopUp")
+ })
+
+ it("should render the dialog with default title", async () => {
+ const { user } = render()
+ await user.click(screen.getByText("Open pop up"))
+
+ expect(screen.getByText("Create Game Schedule")).toBeInTheDocument()
+ })
+
+ it("should render the dialog with a custom title", async () => {
+ const { user } = render()
+ await user.click(screen.getByText("Open pop up"))
+
+ expect(screen.getByText("Edit Game Schedule")).toBeInTheDocument()
+ })
+
+ it("should call onConfirm when submitted with valid data", async () => {
+ const handleConfirm = vi.fn((data: CreateGameSchedulePopUpFormValues) => data)
+
+ const { user } = render(
+ ,
+ )
+ await user.click(screen.getByText("Open pop up"))
+
+ await user.click(screen.getByTestId("submit"))
+ expect(handleConfirm).toBeCalled()
+ })
+
+ it("should call onClose when the back button is clicked", async () => {
+ const handleClose = vi.fn(noop)
+
+ const { user } = render()
+ await user.click(screen.getByText("Open pop up"))
+
+ await user.click(screen.getByTestId("back"))
+
+ expect(handleClose).toBeCalled()
+ })
+
+ it("should not submit when required fields are empty", async () => {
+ const handleConfirm = vi.fn((data: CreateGameSchedulePopUpFormValues) => data)
+
+ const { user } = render()
+ await user.click(screen.getByText("Open pop up"))
+
+ await user.click(screen.getByTestId("submit"))
+
+ expect(handleConfirm).not.toBeCalled()
+ expect(screen.getByText("Name is required")).toBeInTheDocument()
+ })
+
+ it("should pre-fill fields when scheduleToEdit is provided", async () => {
+ const { user } = render(
+ ,
+ )
+ await user.click(screen.getByText("Open pop up"))
+
+ expect(screen.getByTestId("name")).toHaveValue(gameSessionScheduleMock.name)
+ expect(screen.getByTestId("location")).toHaveValue(gameSessionScheduleMock.location)
+ expect(screen.getByTestId("capacity")).toHaveValue(gameSessionScheduleMock.capacity)
+ expect(screen.getByTestId("casual-capacity")).toHaveValue(
+ gameSessionScheduleMock.casualCapacity,
+ )
+ expect(screen.getByTestId("start-time")).toHaveValue("10:00")
+ expect(screen.getByTestId("end-time")).toHaveValue("12:00")
+ })
+
+ it("should reset form when closed and reopened", async () => {
+ const { user } = render()
+ await user.click(screen.getByText("Open pop up"))
+
+ await user.type(screen.getByTestId("name"), "Test Name")
+ expect(screen.getByTestId("name")).toHaveValue("Test Name")
+
+ await user.click(screen.getByTestId("back"))
+ await user.click(screen.getByText("Open pop up"))
+
+ expect(screen.getByTestId("name")).toHaveValue("")
+ })
+})
diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
new file mode 100644
index 000000000..a1eb78e3b
--- /dev/null
+++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
@@ -0,0 +1,236 @@
+"use client"
+
+import { zodResolver } from "@hookform/resolvers/zod"
+import {
+ CreateGameSchedulePopUpFormDataSchema,
+ type CreateGameSchedulePopUpFormValues,
+ isoToTimeInput,
+ Weekday,
+} from "@repo/shared"
+import type { GameSessionSchedule } from "@repo/shared/payload-types"
+import { Button, Heading, Select, TextInput } from "@repo/ui/components/Primitive"
+import { InputType } from "@repo/ui/components/Primitive/TextInput/types"
+import {
+ ArrowLeftIcon,
+ CalendarDaysIcon,
+ Clock3Icon,
+ MapPinIcon,
+ UserRoundIcon,
+} from "@yamada-ui/lucide"
+import {
+ ButtonGroup,
+ Center,
+ Dialog,
+ DialogBody,
+ DialogCloseButton,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ type DialogProps,
+ FormControl,
+ Grid,
+ GridItem,
+ VStack,
+} from "@yamada-ui/react"
+import type { FC } from "react"
+import { Controller, type SubmitHandler, useForm } from "react-hook-form"
+
+export interface CreateGameSchedulePopUpProps extends DialogProps {
+ title?: string
+ scheduleToEdit?: GameSessionSchedule | null
+ onConfirm?: (data: CreateGameSchedulePopUpFormValues) => void
+}
+
+const WeekdayOptions = Object.values(Weekday).map((day) => ({
+ value: day,
+ label: day.charAt(0).toUpperCase() + day.slice(1),
+}))
+
+export const CreateGameSchedulePopUp: FC = ({
+ title = "Create Game Schedule",
+ scheduleToEdit,
+ onConfirm,
+ ...props
+}) => {
+ const {
+ register,
+ control,
+ handleSubmit,
+ reset,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(CreateGameSchedulePopUpFormDataSchema),
+ defaultValues: scheduleToEdit
+ ? {
+ name: scheduleToEdit.name,
+ location: scheduleToEdit.location,
+ day: scheduleToEdit.day,
+ capacity: scheduleToEdit.capacity,
+ casualCapacity: scheduleToEdit.casualCapacity,
+ startTime: isoToTimeInput(scheduleToEdit.startTime),
+ endTime: isoToTimeInput(scheduleToEdit.endTime),
+ }
+ : undefined,
+ })
+
+ const handleClose = () => {
+ reset()
+ props.onClose?.()
+ }
+
+ const onSubmit: SubmitHandler = (data) => {
+ onConfirm?.(data)
+ handleClose()
+ }
+
+ return (
+
+ )
+}
+
+CreateGameSchedulePopUp.displayName = "CreateGameSchedulePopUp"
diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/index.ts b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/index.ts
new file mode 100644
index 000000000..8537f4cb8
--- /dev/null
+++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/index.ts
@@ -0,0 +1 @@
+export * from "./CreateGameSchedulePopUp"
diff --git a/packages/ui/src/components/Generic/CreateMemberPopUp/CreateMemberPopUp.tsx b/packages/ui/src/components/Generic/CreateMemberPopUp/CreateMemberPopUp.tsx
index cfc652d4c..5e04b33b7 100644
--- a/packages/ui/src/components/Generic/CreateMemberPopUp/CreateMemberPopUp.tsx
+++ b/packages/ui/src/components/Generic/CreateMemberPopUp/CreateMemberPopUp.tsx
@@ -321,9 +321,9 @@ export const CreateMemberPopUp: FC = ({
}
onClick={handleClose}
size="lg"
+ startIcon={}
w="full"
>
Back
diff --git a/packages/ui/src/components/Generic/CreateSessionPopUp/CreateSessionPopUp.tsx b/packages/ui/src/components/Generic/CreateSessionPopUp/CreateSessionPopUp.tsx
index 0d761786c..8c48543a5 100644
--- a/packages/ui/src/components/Generic/CreateSessionPopUp/CreateSessionPopUp.tsx
+++ b/packages/ui/src/components/Generic/CreateSessionPopUp/CreateSessionPopUp.tsx
@@ -323,9 +323,9 @@ export const CreateSessionPopUp: React.FC = ({
}
onClick={handleClose}
size="lg"
+ startIcon={}
w="full"
>
Back
diff --git a/packages/ui/src/components/Generic/index.ts b/packages/ui/src/components/Generic/index.ts
index d9e686b9e..868ac57f9 100644
--- a/packages/ui/src/components/Generic/index.ts
+++ b/packages/ui/src/components/Generic/index.ts
@@ -7,6 +7,7 @@ export * from "./BookingCard"
export * from "./BookingTimesCardGroup"
export * from "./CalendarSelectPopup"
export * from "./ConfirmationPopUp"
+export * from "./CreateGameSchedulePopUp"
export * from "./CreateSemesterPopUpFlow"
export * from "./DateCard"
export * from "./DateRangeDisplay"
From 8a2e51b0207532d154a278f53b51bdb843f4ff5c Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:31:14 +1300
Subject: [PATCH 02/13] docs(create-game-session-schedule-popup): add jsdoc to
components and props
---
.../CreateGameSchedulePopUp.tsx | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
index a1eb78e3b..bc397191d 100644
--- a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
+++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx
@@ -35,17 +35,38 @@ import {
import type { FC } from "react"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
+/**
+ * Props for the CreateGameSchedulePopUp component.
+ * @extends DialogProps
+ */
export interface CreateGameSchedulePopUpProps extends DialogProps {
+ /**
+ * Optional title for the popup.
+ */
title?: string
+ /**
+ * Optional schedule to edit.
+ */
scheduleToEdit?: GameSessionSchedule | null
+ /**
+ * Callback when confirming the form.
+ */
onConfirm?: (data: CreateGameSchedulePopUpFormValues) => void
}
+/**
+ * Options for weekdays, formatted for Select component.
+ */
const WeekdayOptions = Object.values(Weekday).map((day) => ({
value: day,
label: day.charAt(0).toUpperCase() + day.slice(1),
}))
+/**
+ * A popup dialog for creating or editing a game schedule.
+ * @param props - The props for the component.
+ * @returns The rendered component.
+ */
export const CreateGameSchedulePopUp: FC = ({
title = "Create Game Schedule",
scheduleToEdit,
From 679ac1f4a917621be1855b0d73df1e189a9a767d Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:32:18 +1300
Subject: [PATCH 03/13] fix(profile-section): avoid using deprecated `isOpen`
prop
---
apps/frontend/src/components/client/user/ProfileSection.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/src/components/client/user/ProfileSection.tsx b/apps/frontend/src/components/client/user/ProfileSection.tsx
index 370ba18c9..ddf6b5feb 100644
--- a/apps/frontend/src/components/client/user/ProfileSection.tsx
+++ b/apps/frontend/src/components/client/user/ProfileSection.tsx
@@ -29,7 +29,7 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => {
const deleteBookingMutation = useDeleteBooking()
const notice = useNotice()
const [bookingId, setBookingId] = useState(null)
- const { isOpen, onOpen, onClose } = useDisclosure()
+ const { open, onOpen, onClose } = useDisclosure()
const handleDeleteBooking = async (bookingId: string) => {
setBookingId(bookingId)
@@ -139,7 +139,7 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => {
loading: deleteBookingMutation.isPending,
}}
onClose={cancelDelete}
- open={isOpen}
+ open={open}
subtitle="Are you sure you want to delete this booking? This action cannot be undone."
title="Delete Booking"
/>
From 133cf3bed8e62929aa919a15a059253637fe2922 Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:33:01 +1300
Subject: [PATCH 04/13] feat(admin-semesters): support adding game session
schedules and editing
---
.../tabs/admin-semesters/AdminSemesters.tsx | 132 +++++++++++++++++-
1 file changed, 130 insertions(+), 2 deletions(-)
diff --git a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
index ef4eb21ed..e4e968c8c 100644
--- a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
+++ b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
@@ -1,10 +1,17 @@
import { Popup } from "@repo/shared/enums"
+import type { GameSessionSchedule } from "@repo/shared/payload-types"
+import type { CreateGameSchedulePopUpFormValues } from "@repo/shared/schemas"
import type { CreateSemesterData } from "@repo/shared/types"
-import { CreateSemesterPopUpFlow } from "@repo/ui/components/Generic"
+import { timeInputToIso } from "@repo/shared/utils"
+import { CreateGameSchedulePopUp, CreateSemesterPopUpFlow } from "@repo/ui/components/Generic"
import { Accordion, Button, Center, Dialog, useDisclosure, useNotice } from "@yamada-ui/react"
import { parseAsBoolean, useQueryState } from "nuqs"
import { useCallback, useState } from "react"
-import { useDeleteGameSessionSchedule } from "@/services/admin/game-session-schedule/AdminGameSessionScheduleMutations"
+import {
+ useCreateGameSessionSchedule,
+ useDeleteGameSessionSchedule,
+ useUpdateGameSessionSchedule,
+} from "@/services/admin/game-session-schedule/AdminGameSessionScheduleMutations"
import {
useCreateSemester,
useDeleteSemester,
@@ -79,6 +86,108 @@ export const AdminSemesters = () => {
[createSemesterMutation, notice],
)
+ // Create game session schedule logic
+ const [scheduleTargetSemesterId, setScheduleTargetSemesterId] = useState(null)
+ const {
+ open: openCreateSchedule,
+ onOpen: onOpenCreateSchedule,
+ onClose: onCloseCreateSchedule,
+ } = useDisclosure()
+ const createScheduleMutation = useCreateGameSessionSchedule()
+ const handleAddSchedule = useCallback(
+ (semesterId: string) => {
+ setScheduleTargetSemesterId(semesterId)
+ onOpenCreateSchedule()
+ },
+ [onOpenCreateSchedule],
+ )
+ const handleCreateScheduleConfirm = useCallback(
+ (data: CreateGameSchedulePopUpFormValues) => {
+ if (!scheduleTargetSemesterId) {
+ return
+ }
+ createScheduleMutation.mutate(
+ {
+ ...data,
+ semester: scheduleTargetSemesterId,
+ startTime: timeInputToIso(data.startTime),
+ endTime: timeInputToIso(data.endTime),
+ },
+ {
+ onSuccess: () => {
+ notice({
+ title: "Creation successful",
+ description: "Session schedule has been created",
+ status: "success",
+ })
+ onCloseCreateSchedule()
+ setScheduleTargetSemesterId(null)
+ },
+ onError: () => {
+ notice({
+ title: "Creation failed",
+ description: "Failed to create session schedule",
+ status: "error",
+ })
+ },
+ },
+ )
+ },
+ [createScheduleMutation, scheduleTargetSemesterId, notice, onCloseCreateSchedule],
+ )
+
+ // Edit game session schedule logic
+ const [scheduleToEdit, setScheduleToEdit] = useState(null)
+ const {
+ open: openEditSchedule,
+ onOpen: onOpenEditSchedule,
+ onClose: onCloseEditSchedule,
+ } = useDisclosure()
+ const updateScheduleMutation = useUpdateGameSessionSchedule()
+ const handleEditSchedule = useCallback(
+ (schedule: GameSessionSchedule) => {
+ setScheduleToEdit(schedule)
+ onOpenEditSchedule()
+ },
+ [onOpenEditSchedule],
+ )
+ const handleEditScheduleConfirm = useCallback(
+ (data: CreateGameSchedulePopUpFormValues) => {
+ if (!scheduleToEdit) {
+ return
+ }
+ updateScheduleMutation.mutate(
+ {
+ id: scheduleToEdit.id,
+ data: {
+ ...data,
+ startTime: timeInputToIso(data.startTime),
+ endTime: timeInputToIso(data.endTime),
+ },
+ },
+ {
+ onSuccess: () => {
+ notice({
+ title: "Update successful",
+ description: "Session schedule has been updated",
+ status: "success",
+ })
+ onCloseEditSchedule()
+ setScheduleToEdit(null)
+ },
+ onError: () => {
+ notice({
+ title: "Update failed",
+ description: "Failed to update session schedule",
+ status: "error",
+ })
+ },
+ },
+ )
+ },
+ [updateScheduleMutation, scheduleToEdit, notice, onCloseEditSchedule],
+ )
+
// Delete game session logic
const deleteScheduleMutation = useDeleteGameSessionSchedule()
const handleDeleteSchedule = useCallback(
@@ -127,11 +236,13 @@ export const AdminSemesters = () => {
handleAddSchedule(sem.id)}
onDeleteSchedule={handleDeleteSchedule}
onDeleteSemester={(semId) => {
setSemesterToDelete(semId)
onOpenDeleteSemester()
}}
+ onEditSchedule={handleEditSchedule}
semester={sem}
/>
))
@@ -140,6 +251,23 @@ export const AdminSemesters = () => {
)}
{/* Action flows and confirmations */}
+
+ {
+ onCloseEditSchedule()
+ setScheduleToEdit(null)
+ }}
+ onConfirm={handleEditScheduleConfirm}
+ open={openEditSchedule}
+ scheduleToEdit={scheduleToEdit} // Editing schedule
+ title="Edit Game Schedule"
+ />
{
setOpenCreateSemester(false)
From b73f3c1a628ac25b203a3e018c241c07c58bae7d Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:38:25 +1300
Subject: [PATCH 05/13] fix(admin-game-session-schedule-mutations): correct
query keys for deleting game session schedules
---
.../game-session-schedule/AdminGameSessionScheduleMutations.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts b/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts
index 16107ff0e..ca673969b 100644
--- a/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts
+++ b/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts
@@ -67,7 +67,7 @@ export const useDeleteGameSessionSchedule = () => {
onSuccess: () => {
queryClient.invalidateQueries({
// TODO: when get by id is implemented, only invalidate the updated id for get by id
- queryKey: [QueryKeys.GAME_SESSION_SCHEDULE_QUERY_KEY],
+ queryKey: [QueryKeys.GAME_SESSION_SCHEDULE_QUERY_KEY, QueryKeys.GAME_SESSION_QUERY_KEY],
})
queryClient.invalidateQueries({
queryKey: [QueryKeys.GAME_SESSION_QUERY_KEY],
From d34260e53476355c903762505e701d90a4f66c8c Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Wed, 11 Mar 2026 23:49:32 +1300
Subject: [PATCH 06/13] fix(date-utils): correct non `utc` usage to avoid
broken dates
---
.../src/data-layer/utils/DateUtils.test.ts | 27 +++++++++++++++++++
.../backend/src/data-layer/utils/DateUtils.ts | 6 ++---
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/apps/backend/src/data-layer/utils/DateUtils.test.ts b/apps/backend/src/data-layer/utils/DateUtils.test.ts
index 2a3fead8d..43ea6713a 100644
--- a/apps/backend/src/data-layer/utils/DateUtils.test.ts
+++ b/apps/backend/src/data-layer/utils/DateUtils.test.ts
@@ -33,6 +33,33 @@ describe("DateUtils", () => {
)
})
+ it("returns dates that all fall on the correct UTC weekday", () => {
+ const semester = {
+ ...semesterMock,
+ startDate: "2025-02-24",
+ endDate: "2025-05-30",
+ breakStart: "2025-04-14",
+ breakEnd: "2025-04-25",
+ }
+
+ const result = getWeeklySessionDates("friday" as Weekday, semester)
+
+ expect(result.length).toBeGreaterThan(0)
+ for (const date of result) {
+ expect(date.getUTCDay()).toBe(5) // 5 is friday index
+ }
+ })
+
+ it("returns dates with exactly 7 days between consecutive sessions", () => {
+ const result = getWeeklySessionDates("wednesday" as Weekday, semesterMock)
+
+ for (let i = 1; i < result.length; i++) {
+ const diff = result[i].getTime() - result[i - 1].getTime()
+ const daysDiff = diff / (1000 * 60 * 60 * 24)
+ expect(daysDiff === 7 || daysDiff > 7).toBe(true)
+ }
+ })
+
it("returns empty array if no sessions fall outside break", () => {
const semester = {
...semesterMock,
diff --git a/apps/backend/src/data-layer/utils/DateUtils.ts b/apps/backend/src/data-layer/utils/DateUtils.ts
index 48d198c2b..df2290f46 100644
--- a/apps/backend/src/data-layer/utils/DateUtils.ts
+++ b/apps/backend/src/data-layer/utils/DateUtils.ts
@@ -18,13 +18,13 @@ export function getWeeklySessionDates(day: Weekday, semester: Semester): Date[]
const sessionDate = new Date(semesterStart)
const dayOffSet = getDaysBetweenWeekdays(Object.values(Weekday)[sessionDate.getUTCDay()], day)
- sessionDate.setDate(sessionDate.getUTCDate() + dayOffSet)
+ sessionDate.setUTCDate(sessionDate.getUTCDate() + dayOffSet)
while (sessionDate <= semesterEnd) {
if (sessionDate < breakStart || sessionDate > breakEnd) {
dates.push(new Date(sessionDate))
}
- sessionDate.setDate(sessionDate.getUTCDate() + 7)
+ sessionDate.setUTCDate(sessionDate.getUTCDate() + 7)
}
return dates
@@ -40,7 +40,7 @@ export function getWeeklySessionDates(day: Weekday, semester: Semester): Date[]
export function createGameSessionTimes(schedule: GameSessionSchedule, date: Date) {
const day = date.getUTCDate()
const month = date.getUTCMonth()
- const year = date.getFullYear()
+ const year = date.getUTCFullYear()
const start = new Date(schedule.startTime)
const end = new Date(schedule.endTime)
From 2dfb6bee963239a1e64f64cc823186e72cc8a2f8 Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Thu, 12 Mar 2026 00:26:51 +1300
Subject: [PATCH 07/13] refactor(semester-name-popup): accept title as prop
---
.../SemesterNamePopUp/SemesterNamePopUp.test.tsx | 7 +++++++
.../SemesterNamePopUp/SemesterNamePopUp.tsx | 9 +++++++--
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.test.tsx b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.test.tsx
index 7246c0f56..4f409b313 100644
--- a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.test.tsx
+++ b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.test.tsx
@@ -101,4 +101,11 @@ describe("", () => {
// The form should prevent submission with empty name due to validation
// This test verifies the form behavior
})
+
+ it("should render custom title when provided", async () => {
+ const { user } = render()
+ await user.click(screen.getByText("Open Semester Name PopUp"))
+
+ expect(screen.getByText("Edit Semester")).toBeInTheDocument()
+ })
})
diff --git a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.tsx b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.tsx
index 50276d8e4..5b5045a8b 100644
--- a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.tsx
+++ b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/SemesterNamePopUp/SemesterNamePopUp.tsx
@@ -36,6 +36,11 @@ export interface SemesterNamePopUpProps {
* Handler called when the user clicks the cancel button or closes the dialog.
*/
onCancel?: () => void
+ /**
+ * The title to display in the dialog header.
+ * @default "Create New Semester"
+ */
+ title?: string
}
/**
@@ -52,7 +57,7 @@ export interface SemesterNamePopUpProps {
* @returns The SemesterNamePopUp dialog component.
*/
export const SemesterNamePopUp: FC = memo(
- ({ defaultValues, open, onConfirm, onCancel }) => {
+ ({ defaultValues, open, onConfirm, onCancel, title = "Create New Semester" }) => {
const {
register,
handleSubmit,
@@ -90,7 +95,7 @@ export const SemesterNamePopUp: FC = memo(
>
- Create New Semester
+ {title}
From c81d0ac03ab14e0ebe6c7b6d8d51fdb70c5c4d49 Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Thu, 12 Mar 2026 00:29:27 +1300
Subject: [PATCH 08/13] refactor(create-semester-flow): add support for
prefilled values
- Also updated tests
---
.../CreateSemesterPopUpFlow.test.tsx | 190 ++++++++++++++++++
.../CreateSemesterPopUpFlow.tsx | 55 ++++-
2 files changed, 237 insertions(+), 8 deletions(-)
diff --git a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.test.tsx b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.test.tsx
index 0254ca282..8897dc39d 100644
--- a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.test.tsx
+++ b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.test.tsx
@@ -1,3 +1,4 @@
+import { semesterMock } from "@repo/shared/mocks"
import { render, screen, waitFor } from "@repo/ui/test-utils"
import { Button } from "@yamada-ui/react"
import type { ComponentProps } from "react"
@@ -5,6 +6,16 @@ import { isValidElement, useState } from "react"
import * as CreateSemesterPopUpFlowModule from "."
import { CreateSemesterPopUpFlow } from "./CreateSemesterPopUpFlow"
+const initialValuesMock = {
+ name: semesterMock.name,
+ startDate: semesterMock.startDate,
+ endDate: semesterMock.endDate,
+ breakStart: semesterMock.breakStart,
+ breakEnd: semesterMock.breakEnd,
+ bookingOpenDay: semesterMock.bookingOpenDay,
+ bookingOpenTime: semesterMock.bookingOpenTime,
+}
+
const CreateSemesterPopUpFlowExample = (
props: Omit, "open">,
) => {
@@ -303,4 +314,183 @@ describe("", () => {
expect(onComplete).not.toHaveBeenCalled()
})
+
+ describe("initialValues", () => {
+ it("should pre-fill the semester name step with initialValues", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ expect((screen.getByPlaceholderText("Enter Semester Name") as HTMLInputElement).value).toBe(
+ semesterMock.name,
+ )
+ })
+
+ it("should skip to step 0 and preserve name when navigating forward with initialValues", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Dates")).toBeInTheDocument()
+ })
+ expect(screen.getByText(semesterMock.name)).toBeInTheDocument()
+ })
+
+ it("should pre-fill the booking settings step with initialValues", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Dates")).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/Semester Break/)).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toBeInTheDocument()
+ })
+
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toHaveTextContent("Monday")
+ expect((screen.getByLabelText("Booking Open Time") as HTMLInputElement).value).toBe("12:00")
+ }, 10_000)
+
+ it("should show the confirmation step with pre-filled data from initialValues", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Dates")).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/Semester Break/)).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Creation Confirmation")).toBeInTheDocument()
+ })
+ expect(screen.getByText(semesterMock.name)).toBeInTheDocument()
+ expect(screen.getByText("Monday")).toBeInTheDocument()
+ }, 10_000)
+
+ it("should call onComplete with pre-filled data when confirming with initialValues", async () => {
+ const onComplete = vi.fn()
+ const { user } = render(
+ ,
+ )
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Dates")).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/Semester Break/)).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Creation Confirmation")).toBeInTheDocument()
+ })
+ await user.click(screen.getByRole("button", { name: "Confirm" }))
+
+ expect(onComplete).toHaveBeenCalledOnce()
+ expect(onComplete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: semesterMock.name,
+ bookingOpenDay: semesterMock.bookingOpenDay,
+ bookingOpenTime: semesterMock.bookingOpenTime,
+ }),
+ )
+ }, 10_000)
+
+ it("should reset state to empty when closed even when initialValues were provided", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByLabelText("Close dialog"))
+
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("Enter Semester Name")).toBeInTheDocument()
+ })
+ expect((screen.getByPlaceholderText("Enter Semester Name") as HTMLInputElement).value).toBe(
+ "",
+ )
+ })
+ })
+
+ describe("confirmationTitle", () => {
+ it("should display the default confirmation title when not provided", async () => {
+ const { user } = render()
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() => expect(screen.getByText("Semester Dates")).toBeInTheDocument())
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() => expect(screen.getByText(/Semester Break/)).toBeInTheDocument())
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() =>
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toBeInTheDocument(),
+ )
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Creation Confirmation")).toBeInTheDocument()
+ })
+ }, 10_000)
+
+ it("should display a custom confirmationTitle on the confirmation step", async () => {
+ const { user } = render(
+ ,
+ )
+ await user.click(screen.getByRole("button", { name: "Open Flow" }))
+
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() => expect(screen.getByText("Semester Dates")).toBeInTheDocument())
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() => expect(screen.getByText(/Semester Break/)).toBeInTheDocument())
+ await user.click(screen.getByRole("button", { name: "Next" }))
+ await waitFor(() =>
+ expect(screen.getByRole("combobox", { name: "Select a day" })).toBeInTheDocument(),
+ )
+ await user.click(screen.getByRole("button", { name: "Next" }))
+
+ await waitFor(() => {
+ expect(screen.getByText("Semester Edit Confirmation")).toBeInTheDocument()
+ })
+ expect(screen.queryByText("Semester Creation Confirmation")).not.toBeInTheDocument()
+ }, 10_000)
+ })
})
diff --git a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.tsx b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.tsx
index d545ddf60..9579646fd 100644
--- a/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.tsx
+++ b/packages/ui/src/components/Generic/CreateSemesterPopUpFlow/CreateSemesterPopUpFlow.tsx
@@ -6,6 +6,7 @@ import type {
SemesterNamePopUpValues,
Weekday,
} from "@repo/shared"
+import { isoToTimeInput } from "@repo/shared/utils"
import { memo, useReducer } from "react"
import { SemesterCreatedPopUp } from "./SemesterCreatedPopUp"
import { SemesterDatePopUp } from "./SemesterDatePopUp"
@@ -27,6 +28,22 @@ const initialState: SemesterFlowState = {
step: 0,
}
+function buildInitialState(initialValues?: CreateSemesterData): SemesterFlowState {
+ if (!initialValues) {
+ return initialState
+ }
+ return {
+ step: 0,
+ name: initialValues.name,
+ startDate: initialValues.startDate,
+ endDate: initialValues.endDate,
+ breakStart: initialValues.breakStart,
+ breakEnd: initialValues.breakEnd,
+ bookingOpenDay: initialValues.bookingOpenDay,
+ bookingOpenTime: initialValues.bookingOpenTime,
+ }
+}
+
type SemesterFlowAction =
| { type: "NEXT" }
| { type: "PREV" }
@@ -87,16 +104,28 @@ interface CreateSemesterPopUpFlowProps {
* @default false
*/
open: boolean
-
/**
* Handler called when the flow is completed or cancelled.
*/
onClose?: () => void
-
/**
* Callback function to handle the completion of the semester creation flow.
*/
onComplete?: (data: CreateSemesterData) => void
+ /**
+ * Optional pre-filled values to seed the flow (e.g. when editing an existing semester).
+ */
+ initialValues?: CreateSemesterData
+ /**
+ * Optional title override for the confirmation step.
+ * @default "Semester Creation Confirmation"
+ */
+ confirmationTitle?: string
+ /**
+ * Optional title for the semester name step.
+ * @default "Create New Semester"
+ */
+ nameStepTitle?: string
}
/**
@@ -108,8 +137,15 @@ interface CreateSemesterPopUpFlowProps {
* @returns The CreateSemesterPopUpFlow component with all steps
*/
export const CreateSemesterPopUpFlow = memo(
- ({ open, onClose, onComplete }: CreateSemesterPopUpFlowProps) => {
- const [state, dispatch] = useReducer(reducer, initialState)
+ ({
+ open,
+ onClose,
+ onComplete,
+ initialValues,
+ confirmationTitle = "Semester Creation Confirmation",
+ nameStepTitle = "Create New Semester",
+ }: CreateSemesterPopUpFlowProps) => {
+ const [state, dispatch] = useReducer(reducer, initialValues, buildInitialState)
const handleSemesterNameSubmit = (data: SemesterNamePopUpValues) => {
dispatch({ type: "SET_SEMESTER_NAME", payload: data.name })
@@ -145,6 +181,8 @@ export const CreateSemesterPopUpFlow = memo(
onClose?.()
}
+ const bookingOpenTimeIso = state.bookingOpenTime
+
const handleComplete = () => {
// Can assume that the data fields are filled, otherwise leave to error handling
onComplete?.({
@@ -159,8 +197,8 @@ export const CreateSemesterPopUpFlow = memo(
handleClose()
}
- const bookingOpenTimeDisplay = state.bookingOpenTime
- ? `${String(new Date(state.bookingOpenTime).getUTCHours()).padStart(2, "0")}:${String(new Date(state.bookingOpenTime).getUTCMinutes()).padStart(2, "0")}`
+ const bookingOpenTimeDisplay = bookingOpenTimeIso
+ ? isoToTimeInput(bookingOpenTimeIso)
: undefined
const steps = [
@@ -168,11 +206,12 @@ export const CreateSemesterPopUpFlow = memo(
title: "Semester Name",
element: (
),
},
@@ -258,7 +297,7 @@ export const CreateSemesterPopUpFlow = memo(
onClose={handleClose}
onConfirm={handleComplete}
open={open && state.step === 4}
- title="Semester Creation Confirmation"
+ title={confirmationTitle}
/>
),
},
From 211e2021e8faf6896bff8040c9e1772778bc5471 Mon Sep 17 00:00:00 2001
From: Jeffery <61447509+jeffplays2005@users.noreply.github.com>
Date: Thu, 12 Mar 2026 00:30:20 +1300
Subject: [PATCH 09/13] feat(admin-semesters-tab): support editing semester
---
.../tabs/admin-semesters/AdminSemesters.tsx | 69 ++++++++++++++++++-
.../SemesterScheduleAccordionItem.tsx | 2 +-
.../AdminSemestersAccordionItem.test.tsx | 2 +-
.../AdminSemestersAccordionItem.tsx | 4 +-
4 files changed, 72 insertions(+), 5 deletions(-)
diff --git a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
index e4e968c8c..eaceaf291 100644
--- a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
+++ b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx
@@ -1,5 +1,5 @@
import { Popup } from "@repo/shared/enums"
-import type { GameSessionSchedule } from "@repo/shared/payload-types"
+import type { GameSessionSchedule, Semester } from "@repo/shared/payload-types"
import type { CreateGameSchedulePopUpFormValues } from "@repo/shared/schemas"
import type { CreateSemesterData } from "@repo/shared/types"
import { timeInputToIso } from "@repo/shared/utils"
@@ -15,6 +15,7 @@ import {
import {
useCreateSemester,
useDeleteSemester,
+ useUpdateSemester,
} from "@/services/admin/semester/AdminSemesterMutations"
import { useGetAllSemesters } from "@/services/semester/SemesterQueries"
import { SemesterScheduleAccordionItem } from "./SemesterScheduleAccordionItem"
@@ -86,6 +87,54 @@ export const AdminSemesters = () => {
[createSemesterMutation, notice],
)
+ // Edit semester logic
+ const [semesterToEdit, setSemesterToEdit] = useState(null)
+ const {
+ open: openUpdateSemester,
+ onOpen: onOpenUpdateSemester,
+ onClose: onCloseUpdateSemester,
+ } = useDisclosure()
+ const updateSemesterMutation = useUpdateSemester()
+ const handleEditSemester = useCallback(
+ (semester: Semester) => {
+ setSemesterToEdit(semester)
+ onOpenUpdateSemester()
+ },
+ [onOpenUpdateSemester],
+ )
+ const handleEditSemesterConfirm = useCallback(
+ (data: CreateSemesterData) => {
+ if (!semesterToEdit) {
+ return
+ }
+ updateSemesterMutation.mutate(
+ {
+ id: semesterToEdit.id,
+ data,
+ },
+ {
+ onSuccess: () => {
+ notice({
+ title: "Update successful",
+ description: "Semester has been updated",
+ status: "success",
+ })
+ onCloseUpdateSemester()
+ setSemesterToEdit(null)
+ },
+ onError: () => {
+ notice({
+ title: "Update failed",
+ description: "Failed to update semester",
+ status: "error",
+ })
+ },
+ },
+ )
+ },
+ [updateSemesterMutation, semesterToEdit, notice, onCloseUpdateSemester],
+ )
+
// Create game session schedule logic
const [scheduleTargetSemesterId, setScheduleTargetSemesterId] = useState(null)
const {
@@ -243,6 +292,7 @@ export const AdminSemesters = () => {
onOpenDeleteSemester()
}}
onEditSchedule={handleEditSchedule}
+ onEditSemester={handleEditSemester}
semester={sem}
/>
))
@@ -275,6 +325,23 @@ export const AdminSemesters = () => {
onComplete={handleCreateSemester}
open={!!openCreateSemester}
/>
+ {
+ onCloseUpdateSemester()
+ setSemesterToEdit(null)
+ }}
+ onComplete={handleEditSemesterConfirm}
+ open={openUpdateSemester}
+ />