diff --git a/src/components/gameClass/GameClassByClassGroupDisplay.tsx b/src/components/gameClass/GameClassByClassGroupDisplay.tsx new file mode 100644 index 0000000..5f299b3 --- /dev/null +++ b/src/components/gameClass/GameClassByClassGroupDisplay.tsx @@ -0,0 +1,45 @@ +import { useGetGameClassesByClassGroup } from "@/hooks/GameClassHooks"; +import DangerMessage from "../message/DangerMessage"; + + +export default function GameClassByClassGroupDisplay({ + classGroupId +}:{ + classGroupId: string; +}){ + const gameClassesQuery = useGetGameClassesByClassGroup(classGroupId); + const displayId = crypto.randomUUID().replaceAll("-", ""); + + + if(gameClassesQuery.status === "pending"){ + return (
Loading...
); + } + else if(gameClassesQuery.status === "error"){ + return (Error: {gameClassesQuery.error.message}); + } + else{ + return ( +
+ { + gameClassesQuery.data.map((gameClass) => ( +
+ { + gameClass.gameClassIcon && + + } + {gameClass.gameClassName} +
+ )) + } +
+ ); + } +} diff --git a/src/components/gameClass/GameClassesSelector.tsx b/src/components/gameClass/GameClassesSelector.tsx new file mode 100644 index 0000000..37e4a46 --- /dev/null +++ b/src/components/gameClass/GameClassesSelector.tsx @@ -0,0 +1,82 @@ +import { useGetGameClasses } from "@/hooks/GameClassHooks"; +import { useEffect, useState } from "react"; +import DangerMessage from "../message/DangerMessage"; + +export function GameClassesSelector({ + gameId, + gameClassIds, + onChange +}:{ + gameId: string; + gameClassIds?: string[]; + onChange?: (gameClassIds: string[]) => void; +}){ + const [ selectedGameClassIds, setSelectedGameClassIds ] = useState(gameClassIds ?? []); + const selectorId = crypto.randomUUID().replaceAll("-", ""); + + + const gameClassesQuery = useGetGameClasses(gameId, 0, 100, undefined); + + + const updateSelectedGameClassIds = (selectedGameClassId: string) => { + if(selectedGameClassIds.includes(selectedGameClassId)){ + setSelectedGameClassIds(selectedGameClassIds.filter((id) => id !== selectedGameClassId)); + } + else{ + setSelectedGameClassIds([...selectedGameClassIds, selectedGameClassId]); + } + } + + + useEffect(() => { + onChange?.(selectedGameClassIds); + }, [ selectedGameClassIds, onChange ]); + + + if(gameClassesQuery.status === "pending"){ + return
Loading...
+ } + else if(gameClassesQuery.status === "error"){ + return Error loading Game Classes: {gameClassesQuery.error.message} + } + else{ + return ( +
+ { + gameClassesQuery.data.map((gameClass) => ( +
+ updateSelectedGameClassIds(e.target.value)} + /> + +
+ )) + } +
+ ); + } +} diff --git a/src/hooks/ClassGroupHooks.ts b/src/hooks/ClassGroupHooks.ts new file mode 100644 index 0000000..1ac8389 --- /dev/null +++ b/src/hooks/ClassGroupHooks.ts @@ -0,0 +1,135 @@ +import { ClassGroup } from "@/interface/ClassGroup"; +import { api } from "@/util/AxiosUtil"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + + +export function useGetClassGroups(raidGroupId: string, page: number, pageSize: number, searchTerm?: string){ + return useQuery({ + queryKey: ["classGroups", raidGroupId, { page, pageSize, searchTerm }], + queryFn: async () => { + const params = new URLSearchParams(); + params.append("page", page.toString()); + params.append("pageSize", pageSize.toString()); + if(searchTerm){ + params.append("searchTerm", searchTerm); + } + + const response = await api.get(`/raidGroup/${raidGroupId}/classGroup?${params}`); + + if(response.status !== 200){ + throw new Error("Failed to get class groups"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data as ClassGroup[]; + } + }); +} + +export function useGetClassGroupsCount(raidGroupId: string, searchTerm?: string){ + const searchParams = new URLSearchParams(); + if(searchTerm){ + searchParams.append("searchTerm", searchTerm); + } + + + return useQuery({ + queryKey: ["classGroups", "count", searchTerm], + queryFn: async () => { + const response = await api.get(`/raidGroup/${raidGroupId}/classGroup/count?${searchParams}`); + + if(response.status !== 200){ + throw new Error("Failed to get class groups count"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data.count as number; + } + }); +} + + +export function useCreateClassGroup(raidGroupId: string){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["createClassGroup"], + mutationFn: async ({classGroupName, gameClassIds}:{classGroupName: string; gameClassIds: string[];}) => { + const response = await api.post(`/raidGroup/${raidGroupId}/classGroup`, + { + classGroup: { + classGroupName: classGroupName, + raidGroupId: raidGroupId + }, + gameClassIds + } + ); + + if(response.status !== 200){ + throw new Error("Failed to create class group"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: ["classGroups"]}); + } + }); +} + +export function useUpdateClassGroup(raidGroupId: string){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["updateClassGroup"], + mutationFn: async ({classGroup, gameClassIds}:{classGroup: ClassGroup; gameClassIds: string[];}) => { + console.log("Hit"); + const response = await api.put(`/raidGroup/${raidGroupId}/classGroup/${classGroup.classGroupId}`, + { + classGroup, + gameClassIds + } + ); + + if(response.status !== 200){ + throw new Error("Failed to update class group"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: ["gameClasses", "classGroups"]}); + queryClient.invalidateQueries({queryKey: ["classGroups"]}); + } + }); +} + +export function useDeleteClassGroup(raidGroupId: string, classGroupId: string){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["deleteClassGroup", classGroupId, raidGroupId], + mutationFn: async () => { + const response = await api.delete(`/raidGroup/${raidGroupId}/classGroup/${classGroupId}`); + + if(response.status !== 200){ + throw new Error("Failed to delete class group"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({queryKey: ["classGroups"]}); + } + }); +} diff --git a/src/hooks/GameClassHooks.ts b/src/hooks/GameClassHooks.ts index 705b846..464d2bf 100644 --- a/src/hooks/GameClassHooks.ts +++ b/src/hooks/GameClassHooks.ts @@ -28,6 +28,26 @@ export function useGetGameClasses(gameId: string, page: number, pageSize: number }); } +export function useGetGameClassesByClassGroup(classGroupId: string){ + return useQuery({ + queryKey: ["gameClasses", "classGroups", classGroupId], + queryFn: async () => { + const response = await api.get(`/gameClass/classGroup/${classGroupId}`); + + if(response.status !== 200){ + throw new Error("Failed to get game classes"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data as GameClass[]; + }, + enabled: !!classGroupId + }); + +} + export function useGetGameClassesCount(gameId: string, searchTerm?: string){ const searchParams = new URLSearchParams(); if(searchTerm){ diff --git a/src/interface/ClassGroup.ts b/src/interface/ClassGroup.ts new file mode 100644 index 0000000..21c66aa --- /dev/null +++ b/src/interface/ClassGroup.ts @@ -0,0 +1,5 @@ +export interface ClassGroup { + classGroupId?: string; + raidGroupId: string; + classGroupName: string; +} diff --git a/src/pages/protected/RaidGroupPage.tsx b/src/pages/protected/RaidGroupPage.tsx index 390370a..2899093 100644 --- a/src/pages/protected/RaidGroupPage.tsx +++ b/src/pages/protected/RaidGroupPage.tsx @@ -3,6 +3,7 @@ import { useGetRaidGroup } from "@/hooks/RaidGroupHooks"; import { RaidGroup } from "@/interface/RaidGroup"; import RaidGroupCalendarDisplay from "@/ui/calendar/RaidGroupCalendarDisplay"; import RaidGroupHeader from "@/ui/calendar/RaidGroupHeader"; +import ClassGroupsTab from "@/ui/classGroup/ClassGroupsTab"; import PersonTab from "@/ui/person/PersonTab"; import { useEffect, useState } from "react"; import { Navigate, useParams } from "react-router"; @@ -45,6 +46,10 @@ export default function RaidGroupPage(){ { tabHeader: "People", tabContent: + }, + { + tabHeader: "Class Groups", + tabContent: } ]; diff --git a/src/ui/classGroup/ClassGroupButtons.tsx b/src/ui/classGroup/ClassGroupButtons.tsx new file mode 100644 index 0000000..a21ff15 --- /dev/null +++ b/src/ui/classGroup/ClassGroupButtons.tsx @@ -0,0 +1,38 @@ +import { ButtonProps } from "@/components/button/Button"; +import DangerButton from "@/components/button/DangerButton"; +import PrimaryButton from "@/components/button/PrimaryButton"; +import { BsPencilFill, BsTrash3 } from "react-icons/bs"; + + +export default function ClassGroupButtons({ + buttonProps, + showClassGroupModal, + showDeleteClassGroupModal +}:{ + buttonProps: ButtonProps; + showClassGroupModal: () => void; + showDeleteClassGroupModal: () => void; +}){ + return ( +
+ + + + + + +
+ ); +} diff --git a/src/ui/classGroup/ClassGroupList.tsx b/src/ui/classGroup/ClassGroupList.tsx new file mode 100644 index 0000000..13d9535 --- /dev/null +++ b/src/ui/classGroup/ClassGroupList.tsx @@ -0,0 +1,102 @@ +import { ButtonProps } from "@/components/button/Button"; +import GameClassByClassGroupDisplay from "@/components/gameClass/GameClassByClassGroupDisplay"; +import Table from "@/components/table/Table"; +import { useGetGameClassesByClassGroup } from "@/hooks/GameClassHooks"; +import { ClassGroup } from "@/interface/ClassGroup"; +import { RaidGroup } from "@/interface/RaidGroup"; +import { useState } from "react"; +import ClassGroupButtons from "./ClassGroupButtons"; +import ClassGroupModal from "./modal/ClassGroupModal"; +import DeleteClassGroupModal from "./modal/DeleteClassGroupModal"; + + +export default function ClassGroupList({ + classGroups, + raidGroup +}:{ + classGroups: ClassGroup[]; + raidGroup: RaidGroup; +}){ + const [ selectedClassGroup, setSelectedClassGroup ] = useState(); + const [ displayClassGroupModal, setDisplayClassGroupModal ] = useState(false); + const [ displayDeleteClassGroupModal, setDisplayDeleteClassGroupModal ] = useState(false); + const gameClassesQuery = useGetGameClassesByClassGroup(selectedClassGroup?.classGroupId ?? ""); + + + const buttonProps: ButtonProps = { + variant: "ghost", + size: "md", + shape: "square" + }; + + + const headElements: React.ReactNode[] = [ +
+ Name +
, +
+ Classes +
, +
+ Actions +
+ ]; + + const bodyElements: React.ReactNode[][] = classGroups.map((classGroup) => [ +
+ {classGroup.classGroupName} +
, +
+ +
, +
+
+   +
+ { + setSelectedClassGroup(classGroup); + setDisplayClassGroupModal(true); + }} + showDeleteClassGroupModal={() => { + setSelectedClassGroup(classGroup); + setDisplayDeleteClassGroupModal(true); + }} + /> +
+ ]); + + + return ( + <> + + { setDisplayClassGroupModal(false); setSelectedClassGroup(undefined); }} + classGroup={selectedClassGroup} + raidGroup={raidGroup} + selectedGameClasses={gameClassesQuery.data ?? []} + /> + { setDisplayDeleteClassGroupModal(false); setSelectedClassGroup(undefined); }} + raidGroupId={selectedClassGroup?.raidGroupId ?? ""} + classGroup={selectedClassGroup} + /> + + ); +} diff --git a/src/ui/classGroup/ClassGroupListSkeleton.tsx b/src/ui/classGroup/ClassGroupListSkeleton.tsx new file mode 100644 index 0000000..624ecf4 --- /dev/null +++ b/src/ui/classGroup/ClassGroupListSkeleton.tsx @@ -0,0 +1,72 @@ +import { ButtonShape, ButtonSizeType, ButtonVariant } from "@/components/button/Button"; +import Table from "@/components/table/Table"; +import { elementBg } from "@/util/SkeletonUtil"; +import ClassGroupButtons from "./ClassGroupButtons"; + +export default function ClassGroupListSkeleton(){ + const headerElements: React.ReactElement[] = [ +
+ Name +
, +
+ Classes +
, +
+ Actions +
+ ]; + + const bodyElements: React.ReactNode[][] = [ + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton(), + ClassGroupSkeleton() + ]; + + + return ( +
+ ); +} + + +function ClassGroupSkeleton(): React.ReactNode[]{ + const buttonsProps = { + buttonProps: { + variant: "ghost" as ButtonVariant, + size: "md" as ButtonSizeType, + shape: "square" as ButtonShape, + disabled: true + }, + showClassGroupModal: () => {}, + showDeleteClassGroupModal: () => {} + } + + const elements: React.ReactNode[] = [ +
, +
, +
+
 
+ +
+ ]; + + return elements; +} diff --git a/src/ui/classGroup/ClassGroupsLoader.tsx b/src/ui/classGroup/ClassGroupsLoader.tsx new file mode 100644 index 0000000..420bee4 --- /dev/null +++ b/src/ui/classGroup/ClassGroupsLoader.tsx @@ -0,0 +1,42 @@ +import DangerMessage from "@/components/message/DangerMessage"; +import { useGetClassGroups } from "@/hooks/ClassGroupHooks"; +import { useGetGameClasses } from "@/hooks/GameClassHooks"; +import { RaidGroup } from "@/interface/RaidGroup"; +import ClassGroupList from "./ClassGroupList"; + + +export default function ClassGroupsLoader({ + page, + pageSize, + searchTerm, + raidGroup, + gameId +}:{ + page: number; + pageSize: number; + searchTerm?: string; + raidGroup: RaidGroup; + gameId: string; +}){ + const classGroupsQuery = useGetClassGroups(raidGroup?.raidGroupId ?? "", page - 1, pageSize, searchTerm); + const gameClassesQuery = useGetGameClasses(gameId, 0, 100); + + + if((classGroupsQuery.status === "pending") || (gameClassesQuery.status === "pending")){ + return
Loading...
+ } + else if(classGroupsQuery.status === "error"){ + return Error: {classGroupsQuery.error.message} + } + else if(gameClassesQuery.status === "error"){ + return Error: {gameClassesQuery.error.message} + } + else{ + return ( + + ); + } +} diff --git a/src/ui/classGroup/ClassGroupsTab.tsx b/src/ui/classGroup/ClassGroupsTab.tsx new file mode 100644 index 0000000..3c84d33 --- /dev/null +++ b/src/ui/classGroup/ClassGroupsTab.tsx @@ -0,0 +1,108 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import TextInput from "@/components/input/TextInput"; +import Pagination from "@/components/pagination/Pagination"; +import { useGetClassGroupsCount } from "@/hooks/ClassGroupHooks"; +import { RaidGroup } from "@/interface/RaidGroup"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import ClassGroupsLoader from "./ClassGroupsLoader"; +import ClassGroupModal from "./modal/ClassGroupModal"; + + +export default function ClassGroupsTab({ + raidGroup +}:{ + raidGroup: RaidGroup; +}){ + const [ displayCreateClassGroupModal, setDisplayCreateClassGroupModal ] = useState(false); + const [ page, setPage ] = useState(1); + const [ totalPages, setTotalPages ] = useState(1); + const [ searchTerm, setSearchTerm ] = useState(""); + const [ sentSearchTerm, setSentSearchTerm ] = useState(""); + const pageSize = 10; + const modalId = crypto.randomUUID().replaceAll("-", ""); + + + const classGroupsCountQuery = useGetClassGroupsCount(raidGroup.raidGroupId!, sentSearchTerm); + + + const updateSearchTerm = useDebouncedCallback((newSearchTerm: string) => { + setSentSearchTerm(newSearchTerm); + }, 1000); + + + useEffect(() => { + updateSearchTerm(searchTerm ?? ""); + }, [ searchTerm, updateSearchTerm ]); + + + useEffect(() => { + if(classGroupsCountQuery.status === "success"){ + setTotalPages(Math.ceil(classGroupsCountQuery.data / pageSize)); + } + }, [ classGroupsCountQuery ]); + + + return ( + <> +
+
+   +
+ {/* Add Class Group Button */} +
+ setDisplayCreateClassGroupModal(true)} + > + Create Class Group + + setDisplayCreateClassGroupModal(false)} + classGroup={undefined} + selectedGameClasses={[]} + raidGroup={raidGroup} + /> +
+ {/* Class Group Search Box */} +
+
+ setSearchTerm(e.target.value)} + placeholder="Search" + /> +
+
+
+ {/* Class Group List */} + + {/* Pagination */} +
+ +
+ + ); +} diff --git a/src/ui/classGroup/modal/ClassGroupModal.tsx b/src/ui/classGroup/modal/ClassGroupModal.tsx new file mode 100644 index 0000000..977378a --- /dev/null +++ b/src/ui/classGroup/modal/ClassGroupModal.tsx @@ -0,0 +1,121 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import SecondaryButton from "@/components/button/SecondaryButton"; +import { GameClassesSelector } from "@/components/gameClass/GameClassesSelector"; +import TextInput from "@/components/input/TextInput"; +import RaidBuilderModal from "@/components/modal/RaidBuilderModal"; +import { useCreateClassGroup, useUpdateClassGroup } from "@/hooks/ClassGroupHooks"; +import { ClassGroup } from "@/interface/ClassGroup"; +import { GameClass } from "@/interface/GameClass"; +import { RaidGroup } from "@/interface/RaidGroup"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect, useState } from "react"; + + +export default function ClassGroupModal({ + display, + close, + classGroup, + raidGroup, + selectedGameClasses +}:{ + display: boolean; + close: () => void; + classGroup: ClassGroup | undefined; + raidGroup: RaidGroup; + selectedGameClasses: GameClass[]; +}){ + const [ classGroupName, setClassGroupName ] = useState(classGroup?.classGroupName ?? ""); + const [ selectedGameClassIds, setSelectedGameClassIds ] = useState(selectedGameClasses.map(gc => gc.gameClassId ?? "")); + const modalId = crypto.randomUUID().replace("-", ""); + + + useEffect(() => { + setClassGroupName(classGroup?.classGroupName ?? ""); + setSelectedGameClassIds(selectedGameClasses.map(gc => gc.gameClassId ?? "")); + }, [classGroup, selectedGameClasses]); + + + const createClassGroupMutate = useCreateClassGroup(raidGroup.raidGroupId ?? ""); + const updateClassGroupMutate = useUpdateClassGroup(raidGroup.raidGroupId ?? ""); + const { addSuccessMessage, addErrorMessage } = useTimedModal(); + + + useEffect(() => { + if(createClassGroupMutate.status === "success"){ + createClassGroupMutate.reset(); + addSuccessMessage("Class Group Created"); + close(); + } + else if(createClassGroupMutate.status === "error"){ + createClassGroupMutate.reset(); + addErrorMessage(`Error creating class group ${classGroupName}: ${createClassGroupMutate.error.message}`); + console.log(createClassGroupMutate.error); + } + else if(updateClassGroupMutate.status === "success"){ + updateClassGroupMutate.reset(); + addSuccessMessage("Class Group Updated"); + close(); + } + else if(updateClassGroupMutate.status === "error"){ + updateClassGroupMutate.reset(); + addErrorMessage(`Error updating class group ${classGroupName}: ${updateClassGroupMutate.error.message}`); + console.log(updateClassGroupMutate.error); + } + }); + + + const createClassGroup = () => { + createClassGroupMutate.mutate({ classGroupName, gameClassIds: selectedGameClassIds }); + } + + const updateClassGroup = () => { + updateClassGroupMutate.mutate({ + classGroup: { + classGroupId: classGroup?.classGroupId, + raidGroupId: raidGroup.raidGroupId ?? "", + classGroupName + }, + gameClassIds: selectedGameClassIds + }); + } + + + return ( + + setClassGroupName(e.target.value)} + /> + gc.gameClassId ?? "")} + onChange={(gameClassIds) => setSelectedGameClassIds(gameClassIds)} + /> +
+ } + modalFooter={ + <> + + {classGroup ? "Update" : "Create"} + + + Cancel + + + } + /> + ); +} diff --git a/src/ui/classGroup/modal/DeleteClassGroupModal.tsx b/src/ui/classGroup/modal/DeleteClassGroupModal.tsx new file mode 100644 index 0000000..39c03e9 --- /dev/null +++ b/src/ui/classGroup/modal/DeleteClassGroupModal.tsx @@ -0,0 +1,66 @@ +import DangerButton from "@/components/button/DangerButton"; +import SecondaryButton from "@/components/button/SecondaryButton"; +import RaidBuilderModal from "@/components/modal/RaidBuilderModal"; +import { useDeleteClassGroup } from "@/hooks/ClassGroupHooks"; +import { ClassGroup } from "@/interface/ClassGroup"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect } from "react"; + + +export default function DeleteClassGroupModal({ + display, + close, + classGroup, + raidGroupId +}:{ + display: boolean; + close: () => void; + classGroup?: ClassGroup; + raidGroupId: string; +}){ + const deleteClassGroupMutate = useDeleteClassGroup(raidGroupId, classGroup?.classGroupId ?? ""); + const { addSuccessMessage, addErrorMessage } = useTimedModal(); + + + const deleteAccount = () => { + deleteClassGroupMutate.mutate(); + } + + + useEffect(() => { + if(deleteClassGroupMutate.status === "success"){ + deleteClassGroupMutate.reset(); + addSuccessMessage("Class Group Deleted"); + close(); + } + else if(deleteClassGroupMutate.status === "error"){ + deleteClassGroupMutate.reset(); + addErrorMessage(`Error deleting class group: ${deleteClassGroupMutate.error.message}`); + console.log(deleteClassGroupMutate.error); + } + }); + + + return ( + + + Delete + + + Cancel + + + } + /> + ); +}