diff --git a/client/src/api/auth/types.ts b/client/src/api/auth/types.ts index 35eab2ff..47b0bbbb 100644 --- a/client/src/api/auth/types.ts +++ b/client/src/api/auth/types.ts @@ -1,5 +1,6 @@ import { StudentType } from "../student/student.type"; import { TeacherType } from "../teacher/teacher.type"; +import { ModeratorType } from "../moderator/moderator.type.ts"; export type Role = "student" | "teacher" | "moderator"; @@ -14,7 +15,7 @@ export type RegisterFinalType = RegisterFormTypes & { role: Role; }; -export type UserType = StudentType | TeacherType; +export type UserType = StudentType | TeacherType | ModeratorType; export type LoginFormTypes = { email: string; diff --git a/client/src/api/moderator/moderator.api.ts b/client/src/api/moderator/moderator.api.ts index e9500000..6b312eea 100644 --- a/client/src/api/moderator/moderator.api.ts +++ b/client/src/api/moderator/moderator.api.ts @@ -1,7 +1,20 @@ import { LoginFormTypes } from "../auth/types.ts"; -import { apiPublic } from "../api.ts"; +import { apiProtected, apiPublic } from "../api.ts"; +import { TeacherStatus } from "../teacher/teacher.type.ts"; export async function loginModeratorApi(data: LoginFormTypes) { const res = await apiPublic.post("/api/moderator/auth/login", data); return res.data as { accessToken: string }; } + +export async function moderatorChangeTeacherStatusApi({ + id, + status, +}: { + id: string; + status: TeacherStatus; +}) { + return await apiProtected.patch(`/api/moderator/teachers/${id}/status`, { + status, + }); +} diff --git a/client/src/api/moderator/moderator.type.ts b/client/src/api/moderator/moderator.type.ts new file mode 100644 index 00000000..33908dba --- /dev/null +++ b/client/src/api/moderator/moderator.type.ts @@ -0,0 +1,9 @@ +export type ModeratorType = { + id: string; + firstName: string; + lastName: string; + email: string; + profileImageUrl: string | null; + createdAt: Date; + role: "moderator"; +}; diff --git a/client/src/api/teacher/teacher.api.ts b/client/src/api/teacher/teacher.api.ts index 9ff67cf7..ff89df7b 100644 --- a/client/src/api/teacher/teacher.api.ts +++ b/client/src/api/teacher/teacher.api.ts @@ -1,6 +1,7 @@ import { apiProtected, apiPublic } from "../api.ts"; import { TeacherOutputModel, + TeachersForModeratorQuery, TeachersQuery, TeacherType, UpdateTeacherProfileInput, @@ -14,11 +15,31 @@ export async function getAllTeachersApi(query: TeachersQuery) { return res.data; } +export async function getAllTeachersForModeratorApi( + query: TeachersForModeratorQuery, +) { + const res = await apiProtected.get( + "/api/teachers/get-teachers-moderator", + { + params: query, + }, + ); + + return res.data; +} + export async function getTeacherByIdApi(teacherId: string) { const res = await apiPublic.get(`/api/teachers/${teacherId}`); return res.data; } +export async function getTeacherByIdForModeratorApi(teacherId: string) { + const res = await apiProtected.get( + `/api/teachers/${teacherId}/moderator`, + ); + return res.data; +} + type ApiSlot = { start: string; end: string }; type ApiAvailability = { monday: ApiSlot[]; diff --git a/client/src/api/teacher/teacher.type.ts b/client/src/api/teacher/teacher.type.ts index c81fdbb5..65f73fbe 100644 --- a/client/src/api/teacher/teacher.type.ts +++ b/client/src/api/teacher/teacher.type.ts @@ -1,5 +1,10 @@ import { Role } from "../auth/types"; - +export type TeacherStatus = + | "draft" + | "pending" + | "active" + | "rejected" + | "blocked"; type EducationItem = { degree: string; institution: string; @@ -58,6 +63,7 @@ export type TeacherType = { availability: AvailabilityItem; address: AddressItem; createdAt: Date; + status: TeacherStatus; role: Role; }; @@ -71,6 +77,7 @@ export type TeacherOutputModel = { export type SortDirection = "asc" | "desc"; export type SortBy = "createdAt" | "priceFrom" | "rating"; +export type SortByTeachersForModerator = "status" | "createdAt"; export type TeachersQuery = { subject?: string; @@ -83,6 +90,13 @@ export type TeachersQuery = { pageSize?: number; }; +export type TeachersForModeratorQuery = { + sortBy?: SortByTeachersForModerator; + sortDirection?: SortDirection; + pageNumber?: number; + pageSize?: number; +}; + export type UpdateTeacherProfileInput = { firstName?: string; lastName?: string; diff --git a/client/src/components/cardsList/CardsList.tsx b/client/src/components/cardsList/CardsList.tsx index 715b8884..7cbfb78f 100644 --- a/client/src/components/cardsList/CardsList.tsx +++ b/client/src/components/cardsList/CardsList.tsx @@ -1,12 +1,18 @@ import { TeacherCard } from "../teacherCard/teacherCard"; -import { TeacherType } from "../../api/teacher/teacher.type"; +import { TeacherStatus, TeacherType } from "../../api/teacher/teacher.type"; import { motion, type Variants, useReducedMotion } from "framer-motion"; import { useRef } from "react"; type CardsListType = { cards: TeacherType[]; + changeStatus?: (id: string, status: TeacherStatus) => void; + isStatusPending?: boolean; }; -export const CardsList = ({ cards }: CardsListType) => { +export const CardsList = ({ + cards, + changeStatus, + isStatusPending, +}: CardsListType) => { const reduceMotion = useReducedMotion(); const listVariants: Variants = { @@ -44,7 +50,11 @@ export const CardsList = ({ cards }: CardsListType) => { > {cards.map((teacher) => ( - + ))} diff --git a/client/src/components/sidebar/sidebarMenuItems.ts b/client/src/components/sidebar/sidebarMenuItems.ts index 007d5b44..a2db76a0 100644 --- a/client/src/components/sidebar/sidebarMenuItems.ts +++ b/client/src/components/sidebar/sidebarMenuItems.ts @@ -5,6 +5,8 @@ import Chat from "../icons/Chat"; import UsersIcon from "../icons/UsersIcon"; import { chatRoutes, + moderatorBase, + moderatorPrivatesRoutesVariables, studentBase, studentPrivatesRoutesVariables, teacherBase, @@ -63,3 +65,16 @@ export const defaultTeacherMenuItems: MenuItem[] = [ icon: Chat, }, ]; + +export const defaultModeratorMenuItems: MenuItem[] = [ + { + name: "Teachers", + link: joinPath(moderatorBase, moderatorPrivatesRoutesVariables.teachers), + icon: DashboardIcon, + }, + { + name: "Chat", + link: joinPath(moderatorBase, chatRoutes.root), + icon: Chat, + }, +]; diff --git a/client/src/components/statusChanger/StatusChange.tsx b/client/src/components/statusChanger/StatusChange.tsx new file mode 100644 index 00000000..a42d9fca --- /dev/null +++ b/client/src/components/statusChanger/StatusChange.tsx @@ -0,0 +1,37 @@ +import { + getStatusButtonClass, + statusOptions, + statusUi, +} from "../../util/statusButtons.tsx"; +import { Button } from "../ui/button/Button.tsx"; +import { TeacherStatus } from "../../api/teacher/teacher.type.ts"; + +type StatusChangeProps = { + id: string; + changeStatus: (id: string, status: TeacherStatus) => void; + status: TeacherStatus; +}; + +export const StatusChange = ({ + changeStatus, + id, + status, +}: StatusChangeProps) => { + return ( + <> +
+ {statusOptions.map((s) => ( + + ))} +
+ + ); +}; diff --git a/client/src/components/teacherCard/teacherCard.tsx b/client/src/components/teacherCard/teacherCard.tsx index 7621e0c4..227f67dd 100644 --- a/client/src/components/teacherCard/teacherCard.tsx +++ b/client/src/components/teacherCard/teacherCard.tsx @@ -1,18 +1,49 @@ import { Button } from "../ui/button/Button"; import { Rating } from "../rating/Rating"; -import { TeacherType } from "../../api/teacher/teacher.type"; +import { TeacherStatus, TeacherType } from "../../api/teacher/teacher.type"; import { useNavigate } from "react-router-dom"; import { getAvatarUrl } from "../../api/upload/upload.api"; import DefaultAvatarIcon from "../icons/DefaultAvatarIcon"; +import { cva, VariantProps } from "class-variance-authority"; +import { twMerge } from "tailwind-merge"; +import { useAuthSessionStore } from "../../store/authSession.store.ts"; +import { Loader } from "../loader/Loader.tsx"; +import { StatusChange } from "../statusChanger/StatusChange.tsx"; type TeacherCardType = { teacher: TeacherType; showBookButton?: boolean; + changeStatus?: (id: string, status: TeacherStatus) => void; + isStatusPending?: boolean; }; +const teacherCardVariants = cva( + "flex flex-col border bg-[#15141D80] px-4 sm:px-6 md:px-9 py-6 md:py-9 rounded-[25px]", + { + variants: { + status: { + active: "border-blue-500", + rejected: "border-danger", + blocked: "border-light-600", + draft: "border-yellow-400", + pending: "border-purple-400", + }, + }, + defaultVariants: { status: "active" }, + }, +); + +export type TeacherCardVariantsProps = VariantProps; +const teacherCardClassName = ( + props: TeacherCardVariantsProps, + className?: string, +) => twMerge(teacherCardVariants(props), className); + export const TeacherCard = ({ teacher, + changeStatus, showBookButton = true, + isStatusPending, }: TeacherCardType) => { const { id, @@ -25,27 +56,38 @@ export const TeacherCard = ({ priceFrom, bio, rating, + status, } = teacher; const navigate = useNavigate(); - + const user = useAuthSessionStore((state) => state.user); const avatarUrl = getAvatarUrl(profileImageUrl || null); const handleBookClick = () => { + if (user?.role === "moderator") { + navigate(`/moderator/teachers/${id}`); + return; + } navigate(`/teacher/${id}`); }; + + const onChangeStatus = (id: string, status: TeacherStatus) => { + changeStatus?.(id, status); + }; + return ( -
-
+ {Boolean(changeStatus) && ( + + )} +
+
-
-
+
- {avatarUrl ? ( - person - ) : ( - - )} -
-
-

- {firstName} {lastName} -

-
-
-
- {subjects.length > 0 && ( -
- {subjects.map((subject) => ( - - {subject.subjectName} teacher - - ))} + > + {avatarUrl ? ( + person + ) : ( + + )}
- )} -
-

- Experience — {experience} {experience === 1 ? "year" : "years"} -

-

- Education —{" "} - {education.length > 0 - ? education.map((item, index) => ( - - {item.institution} - {index < education.length - 1 ? ", " : ""} - - )) - : "Not specified"} -

-

+

+ {firstName} {lastName} +

+
+
+
+ {subjects.length > 0 && ( +
+ {subjects.map((subject) => ( + + {subject.subjectName} teacher + + ))} +
+ )} +
+

+ Experience — {experience} {experience === 1 ? "year" : "years"} +

+

+ Education —{" "} + {education.length > 0 + ? education.map((item, index) => ( + + {item.institution} + {index < education.length - 1 ? ", " : ""} + + )) + : "Not specified"} +

+

- {bio} -

+ > + {bio} +

+
+
+
+ + {priceFrom} euro + + + 1 hour + + + {showBookButton && ( + + )} + First lesson - free +
-
-
- - {priceFrom} euro - - - 1 hour - - - {showBookButton && ( - - )} - First lesson - free -
+ {isStatusPending && }
); }; diff --git a/client/src/features/moderator/mutation/useChangeStatus.ts b/client/src/features/moderator/mutation/useChangeStatus.ts new file mode 100644 index 00000000..59fa6256 --- /dev/null +++ b/client/src/features/moderator/mutation/useChangeStatus.ts @@ -0,0 +1,118 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { queryKeys } from "../../queryKeys.ts"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; +import { moderatorChangeTeacherStatusApi } from "../../../api/moderator/moderator.api.ts"; +import { + TeacherOutputModel, + TeacherStatus, + TeacherType, +} from "../../../api/teacher/teacher.type.ts"; +import { + patchPublicList, + patchTeacherInList, +} from "../../../util/patchTeachersList.util.ts"; + +type Vars = { id: string; status: TeacherStatus }; +type Ctx = { + prevPublicLists: Array<[readonly unknown[], TeacherOutputModel | undefined]>; + prevModeratorLists: Array< + [readonly unknown[], TeacherOutputModel | undefined] + >; + prevTeacherModeratorDetail: TeacherType | undefined; +}; + +export function useChangeStatusMutation() { + const qc = useQueryClient(); + const success = useNotificationStore((s) => s.success); + const notifyError = useNotificationStore((s) => s.error); + + return useMutation({ + mutationFn: (vars) => + moderatorChangeTeacherStatusApi(vars).then(() => undefined), + onMutate: async ({ id, status }) => { + await qc.cancelQueries({ queryKey: queryKeys.teachers.all }); + + const prevPublicLists = qc.getQueriesData({ + queryKey: ["teachers", "publicList"], + }); + + const prevModeratorLists = qc.getQueriesData({ + queryKey: ["teachers", "moderatorList"], + }); + + const prevTeacherModeratorDetail = qc.getQueryData( + queryKeys.teacherModerator(id), + ); + + prevPublicLists.forEach(([key, data]) => { + if (!data) { + return; + } + qc.setQueryData( + key, + patchPublicList(data, id, status), + ); + }); + + prevModeratorLists.forEach(([key, data]) => { + if (!data) { + return; + } + qc.setQueryData( + key, + patchTeacherInList(data, id, status), + ); + }); + + if (prevTeacherModeratorDetail) { + qc.setQueryData(queryKeys.teacherModerator(id), { + ...prevTeacherModeratorDetail, + status, + }); + } + + return { + prevPublicLists, + prevModeratorLists, + prevTeacherModeratorDetail, + }; + }, + onSuccess: async () => { + success("Status has been successfully changed"); + await qc.invalidateQueries({ queryKey: ["teachers", "publicList"] }); + }, + onError: (error, _vars, ctx) => { + const msg = getErrorMessage(error); + notifyError(msg); + if (!ctx) { + return; + } + ctx.prevPublicLists.forEach(([key, data]) => { + if (!data) { + return; + } + qc.setQueryData(key, data); + }); + + ctx.prevModeratorLists.forEach(([key, data]) => { + if (!data) { + return; + } + qc.setQueryData(key, data); + }); + + if (ctx.prevTeacherModeratorDetail) { + qc.setQueryData( + _vars ? queryKeys.teacherModerator(_vars.id) : ["_"], + ctx.prevTeacherModeratorDetail, + ); + } + }, + onSettled: async (_d, _e, vars) => { + await qc.invalidateQueries({ + queryKey: queryKeys.teacherModerator(vars.id), + }); + }, + }); +} diff --git a/client/src/features/queryKeys.ts b/client/src/features/queryKeys.ts index 528d5267..6869a6da 100644 --- a/client/src/features/queryKeys.ts +++ b/client/src/features/queryKeys.ts @@ -1,4 +1,7 @@ -import { TeachersQuery } from "../api/teacher/teacher.type.ts"; +import { + TeachersForModeratorQuery, + TeachersQuery, +} from "../api/teacher/teacher.type.ts"; export const queryKeys = { me: ["auth", "me"] as const, @@ -10,10 +13,16 @@ export const queryKeys = { teachers: { all: ["teachers"] as const, myProfile: () => ["teachers", "me"] as const, - list: (params: TeachersQuery) => ["teachers", "list", params] as const, + publicList: (params: TeachersQuery) => + ["teachers", "publicList", params] as const, + + moderatorList: (params: TeachersForModeratorQuery) => + ["teachers", "moderatorList", params] as const, }, - teacher: (id: string) => ["teachers", id] as const, - teachersList: (params: TeachersQuery) => ["teachers", params] as const, + + teacherPublic: (id: string) => ["teachers", "publicDetail", id] as const, + teacherModerator: (id: string) => + ["teachers", "moderatorDetail", id] as const, appointments: ["appointments"] as const, teacherAppointments: (teacherId: string, page?: number, limit?: number) => diff --git a/client/src/features/teachers/mutations/useUpdateMyProfileMutation.ts b/client/src/features/teachers/mutations/useUpdateMyProfileMutation.ts index ed1e0dfe..7c5296d5 100644 --- a/client/src/features/teachers/mutations/useUpdateMyProfileMutation.ts +++ b/client/src/features/teachers/mutations/useUpdateMyProfileMutation.ts @@ -12,9 +12,14 @@ export const useUpdateMyProfileMutation = () => { queryClient.invalidateQueries({ queryKey: queryKeys.teachers.myProfile(), }); + queryClient.invalidateQueries({ queryKey: queryKeys.teachers.all }); + + queryClient.invalidateQueries({ + queryKey: queryKeys.teacherPublic(updatedTeacher.id), + }); queryClient.invalidateQueries({ - queryKey: queryKeys.teacher(updatedTeacher.id), + queryKey: queryKeys.teacherModerator(updatedTeacher.id), }); }, }); diff --git a/client/src/features/teachers/query/useTeacherForModeratorQuery.tsx b/client/src/features/teachers/query/useTeacherForModeratorQuery.tsx new file mode 100644 index 00000000..0de563c5 --- /dev/null +++ b/client/src/features/teachers/query/useTeacherForModeratorQuery.tsx @@ -0,0 +1,31 @@ +import { TeachersForModeratorQuery } from "../../../api/teacher/teacher.type.ts"; +import { useNotificationStore } from "../../../store/notification.store.ts"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { queryKeys } from "../../queryKeys.ts"; +import { getAllTeachersForModeratorApi } from "../../../api/teacher/teacher.api.ts"; +import { useEffect } from "react"; +import { getErrorMessage } from "../../../util/ErrorUtil.ts"; + +export function useTeachersForModeratorQuery( + params: TeachersForModeratorQuery, +) { + const notifyError = useNotificationStore((s) => s.error); + + const query = useQuery({ + queryKey: queryKeys.teachers.moderatorList(params), + queryFn: () => getAllTeachersForModeratorApi(params), + retry: false, + placeholderData: keepPreviousData, + staleTime: 20 * 60 * 1000, + refetchOnMount: "always", + }); + + useEffect(() => { + if (query.isError) { + const msg = getErrorMessage(query.error); + notifyError(msg ?? "Failed to load teachers"); + } + }, [query.isError, query.isSuccess, query.error, notifyError]); + + return query; +} diff --git a/client/src/features/teachers/query/useTeacherQuery.ts b/client/src/features/teachers/query/useTeacherQuery.ts index dd85cf5b..67b5e4f2 100644 --- a/client/src/features/teachers/query/useTeacherQuery.ts +++ b/client/src/features/teachers/query/useTeacherQuery.ts @@ -1,11 +1,24 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "../../queryKeys"; -import { getTeacherByIdApi } from "../../../api/teacher/teacher.api"; +import { + getTeacherByIdApi, + getTeacherByIdForModeratorApi, +} from "../../../api/teacher/teacher.api"; +import { useAuthSessionStore } from "../../../store/authSession.store.ts"; export const useTeacherQuery = (teacherId: string) => { + const role = useAuthSessionStore((s) => s.user?.role); + const isModerator = role === "moderator"; + return useQuery({ - queryKey: queryKeys.teacher(teacherId), - queryFn: () => getTeacherByIdApi(teacherId), - enabled: !!teacherId, + queryKey: isModerator + ? queryKeys.teacherModerator(teacherId) + : queryKeys.teacherPublic(teacherId), + queryFn: () => + isModerator + ? getTeacherByIdForModeratorApi(teacherId) + : getTeacherByIdApi(teacherId), + enabled: Boolean(teacherId), + retry: false, }); }; diff --git a/client/src/features/teachers/query/useTeachersQuery.tsx b/client/src/features/teachers/query/useTeachersQuery.tsx index a3b47f0c..bc029ed6 100644 --- a/client/src/features/teachers/query/useTeachersQuery.tsx +++ b/client/src/features/teachers/query/useTeachersQuery.tsx @@ -11,11 +11,12 @@ export function useTeachersQuery(params: TeachersQuery) { const notifyError = useNotificationStore((s) => s.error); const query = useQuery({ - queryKey: queryKeys.teachersList(params), + queryKey: queryKeys.teachers.publicList(params), queryFn: () => getAllTeachersApi(params), retry: false, placeholderData: keepPreviousData, staleTime: 20 * 60 * 1000, + refetchOnMount: "always", }); useEffect(() => { diff --git a/client/src/layouts/PrivateLayout.tsx b/client/src/layouts/PrivateLayout.tsx index 9dae692a..18322c75 100644 --- a/client/src/layouts/PrivateLayout.tsx +++ b/client/src/layouts/PrivateLayout.tsx @@ -1,6 +1,7 @@ import { Outlet } from "react-router-dom"; import { Sidebar } from "../components/sidebar/Sidebar.tsx"; import { + defaultModeratorMenuItems, defaultStudentMenuItems, defaultTeacherMenuItems, } from "../components/sidebar/sidebarMenuItems.ts"; @@ -15,12 +16,16 @@ export const PrivateLayout = () => { const accessToken = useAuthSessionStore((s) => s.accessToken); const connect = useSocketStore((s) => s.connect); const disconnect = useSocketStore((s) => s.disconnect); - const items = - user?.role === "teacher" - ? defaultTeacherMenuItems - : defaultStudentMenuItems; - usePresenceSubscribe(); + let items; + + if (user?.role === "teacher") { + items = defaultTeacherMenuItems; + } else if (user?.role === "student") { + items = defaultStudentMenuItems; + } else { + items = defaultModeratorMenuItems; + } useEffect(() => { if (!accessToken) { diff --git a/client/src/pages/moderatorTeachersPage/ModeratorTeachersPage.tsx b/client/src/pages/moderatorTeachersPage/ModeratorTeachersPage.tsx new file mode 100644 index 00000000..c100c0d0 --- /dev/null +++ b/client/src/pages/moderatorTeachersPage/ModeratorTeachersPage.tsx @@ -0,0 +1,77 @@ +import { CardsList } from "../../components/cardsList/CardsList"; +import { Pagination } from "../../components/ui/pagination/Pagination"; +import { useRef, useState } from "react"; +import { TeachersCardsSkeletonList } from "../../components/skeletons/TeachersCardsSkeletonList.tsx"; +import { + SortByTeachersForModerator, + TeacherStatus, +} from "../../api/teacher/teacher.type.ts"; +import { useTeachersForModeratorQuery } from "../../features/teachers/query/useTeacherForModeratorQuery.tsx"; +import { useChangeStatusMutation } from "../../features/moderator/mutation/useChangeStatus.ts"; + +export const ModeratorTeachersPage = () => { + const [page, setPage] = useState(1); + const [sortBy] = useState("status"); + const listTopRef = useRef(null); + + const { mutate, isPending } = useChangeStatusMutation(); + + const handlePageChange = (page: number) => { + setPage(page); + + requestAnimationFrame(() => { + listTopRef.current?.scrollIntoView({ + block: "start", + }); + }); + }; + + const changeStatus = (id: string, status: TeacherStatus) => { + mutate({ id, status }); + }; + + const { data, isFetching } = useTeachersForModeratorQuery({ + pageNumber: page, + pageSize: 10, + sortBy, + }); + + return ( +
+

TEACHERS

+
+
+ {isFetching ? ( + + ) : data?.items?.length ? ( + + ) : ( +
No teachers
+ )} +
+ +
+
+
+
+ ); +}; diff --git a/client/src/pages/teacherDetail/teacherDetail.tsx b/client/src/pages/teacherDetail/teacherDetail.tsx index 1cc11f2f..3b314d70 100644 --- a/client/src/pages/teacherDetail/teacherDetail.tsx +++ b/client/src/pages/teacherDetail/teacherDetail.tsx @@ -9,16 +9,22 @@ import { useState } from "react"; import { useTeacherQuery } from "../../features/teachers/query/useTeacherQuery"; import { TeacherCardSkeleton } from "../../components/skeletons/TeacherCardSkeleton"; import { ReviewsManager } from "../../components/teacherSection/Reviews/ReviewsManager"; - +import { useMatch } from "react-router-dom"; +import { useChangeStatusMutation } from "../../features/moderator/mutation/useChangeStatus.ts"; +import { TeacherStatus } from "../../api/teacher/teacher.type.ts"; type TabType = "about" | "subjects" | "schedule"; export const TeacherDetail = () => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const [activeTab, setActiveTab] = useState("subjects"); - + const isModeratorRoute = Boolean(useMatch("/moderator/*")); const { data: teacher, isLoading, error } = useTeacherQuery(id || ""); - + const { mutate: changeStatusMutation, isPending: isChangeStatusPending } = + useChangeStatusMutation(); + const changeStatus = (id: string, status: TeacherStatus) => { + changeStatusMutation({ id, status }); + }; const handleBack = () => { navigate(-1); }; @@ -82,7 +88,12 @@ export const TeacherDetail = () => { Back
- +
diff --git a/client/src/router/RoleIndexRedirect.tsx b/client/src/router/RoleIndexRedirect.tsx index c0eeee68..b96c2621 100644 --- a/client/src/router/RoleIndexRedirect.tsx +++ b/client/src/router/RoleIndexRedirect.tsx @@ -1,6 +1,9 @@ import { useAuthSessionStore } from "../store/authSession.store.ts"; import { Navigate, useLocation } from "react-router-dom"; -import { teacherPrivatesRoutesVariables } from "./routesVariables/pathVariables.ts"; +import { + moderatorPrivatesRoutesVariables, + teacherPrivatesRoutesVariables, +} from "./routesVariables/pathVariables.ts"; export const RoleIndexRedirect = () => { const user = useAuthSessionStore((s) => s.user); @@ -9,12 +12,22 @@ export const RoleIndexRedirect = () => { return ; } - return user.role === "teacher" ? ( - - ) : ( - - ); + if (user.role === "teacher") { + return ( + + ); + } else if (user.role === "student") { + return ; + } else if (user.role === "moderator") { + return ( + + ); + } + return ; }; diff --git a/client/src/router/router.tsx b/client/src/router/router.tsx index d5ec1c1f..215b543e 100644 --- a/client/src/router/router.tsx +++ b/client/src/router/router.tsx @@ -11,6 +11,7 @@ import { RequireRole } from "./RequireRole.tsx"; import { RoleIndexRedirect } from "./RoleIndexRedirect.tsx"; import { teacherPrivateRoutes } from "./routesVariables/teacherPrivateRoutes.tsx"; import { VideoCallPage } from "../pages/videoCall/VideoCallPage.tsx"; +import { moderatorPrivateRoutes } from "./routesVariables/moderatorPrivateRoutes.tsx"; export const router = createBrowserRouter([ { @@ -33,6 +34,17 @@ export const router = createBrowserRouter([ ), }, + { + path: "/moderator", + element: ( + + + + + + ), + children: moderatorPrivateRoutes, + }, { path: "/clients-dashboard", element: ( diff --git a/client/src/router/routesVariables/moderatorPrivateRoutes.tsx b/client/src/router/routesVariables/moderatorPrivateRoutes.tsx new file mode 100644 index 00000000..ebfa3bbb --- /dev/null +++ b/client/src/router/routesVariables/moderatorPrivateRoutes.tsx @@ -0,0 +1,29 @@ +import { RouteObject } from "react-router-dom"; +import { + chatRoutes, + moderatorPrivatesRoutesVariables, +} from "./pathVariables.ts"; +import { ChatPage } from "../../pages/chat/chatPage/ChatPage.tsx"; +import { EmptyChat } from "../../pages/chat/EmptyChat/EmptyChat.tsx"; +import { ChatDialogPage } from "../../pages/chat/chatDialogPage/ChatDialogPage.tsx"; +import { ModeratorTeachersPage } from "../../pages/moderatorTeachersPage/ModeratorTeachersPage.tsx"; +import { TeacherDetail } from "../../pages/teacherDetail/teacherDetail.tsx"; + +export const moderatorPrivateRoutes: RouteObject[] = [ + { + path: moderatorPrivatesRoutesVariables.teachers, + element: , + }, + { + path: moderatorPrivatesRoutesVariables.teacher, + element: , + }, + { + path: chatRoutes.root, + element: , + children: [ + { index: true, element: }, + { path: chatRoutes.dialog, element: }, + ], + }, +]; diff --git a/client/src/router/routesVariables/pathVariables.ts b/client/src/router/routesVariables/pathVariables.ts index bc2d3b15..c9a1fcd5 100644 --- a/client/src/router/routesVariables/pathVariables.ts +++ b/client/src/router/routesVariables/pathVariables.ts @@ -1,5 +1,6 @@ export const studentBase = "/clients-dashboard"; export const teacherBase = "/teacher"; +export const moderatorBase = "/moderator"; export const publicRoutesVariables = { home: "/", @@ -41,3 +42,8 @@ export const teacherPrivatesRoutesVariables = { profile: "profile", appointments: "teacher-appointments", }; + +export const moderatorPrivatesRoutesVariables = { + teachers: "teachers", + teacher: "teachers/:id", +}; diff --git a/client/src/store/authSession.store.ts b/client/src/store/authSession.store.ts index 6e7a218b..b2089c37 100644 --- a/client/src/store/authSession.store.ts +++ b/client/src/store/authSession.store.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { UserType } from "../api/auth/types"; -type AccountType = "student" | "teacher"; +type AccountType = "student" | "teacher" | "moderator"; type AuthSessionState = { user: UserType | null; diff --git a/client/src/util/patchTeachersList.util.ts b/client/src/util/patchTeachersList.util.ts new file mode 100644 index 00000000..0b064a1a --- /dev/null +++ b/client/src/util/patchTeachersList.util.ts @@ -0,0 +1,24 @@ +import { + TeacherOutputModel, + TeacherStatus, +} from "../api/teacher/teacher.type.ts"; + +export const patchTeacherInList = ( + data: TeacherOutputModel, + id: string, + status: TeacherStatus, +): TeacherOutputModel => ({ + ...data, + items: data.items.map((t) => (t.id === id ? { ...t, status } : t)), +}); + +export const patchPublicList = ( + data: TeacherOutputModel, + id: string, + status: TeacherStatus, +): TeacherOutputModel => { + if (status !== "active") { + return { ...data, items: data.items.filter((t) => t.id !== id) }; + } + return patchTeacherInList(data, id, status); +}; diff --git a/client/src/util/statusButtons.tsx b/client/src/util/statusButtons.tsx new file mode 100644 index 00000000..53cb3805 --- /dev/null +++ b/client/src/util/statusButtons.tsx @@ -0,0 +1,56 @@ +import { twMerge } from "tailwind-merge"; +import { TeacherStatus } from "../api/teacher/teacher.type.ts"; + +export const statusUi: Record< + TeacherStatus, + { label: string; ring: string; bg: string; text: string } +> = { + active: { + label: "Active", + ring: "border-blue-500", + bg: "bg-blue-500", + text: "text-white", + }, + rejected: { + label: "Rejected", + ring: "border-red-500", + bg: "bg-red-500", + text: "text-white", + }, + blocked: { + label: "Blocked", + ring: "border-amber-900", + bg: "bg-amber-900", + text: "text-white", + }, + draft: { + label: "Draft", + ring: "border-yellow-400", + bg: "bg-yellow-400", + text: "text-dark-900", + }, + pending: { + label: "Pending", + ring: "border-purple-400", + bg: "bg-purple-400", + text: "text-white", + }, +}; +export const statusOptions: TeacherStatus[] = [ + "draft", + "pending", + "active", + "rejected", + "blocked", +]; +export const getStatusButtonClass = (s: TeacherStatus, isActive: boolean) => { + const ui = statusUi[s]; + + const base = "min-h-8 px-4 py-1 rounded-full border-2 transition"; + + const active = `${ui.bg} ${ui.text} ${ui.ring} shadow-sm`; + + const inactive = `bg-transparent ${ui.ring} text-light-100 hover:${ui.bg} hover:${ui.text}`; + + return twMerge(base, isActive ? active : inactive); +}; diff --git a/server/src/controllers/teacher.controller.ts b/server/src/controllers/teacher.controller.ts index c1a4dc00..6d57ec48 100644 --- a/server/src/controllers/teacher.controller.ts +++ b/server/src/controllers/teacher.controller.ts @@ -15,6 +15,7 @@ import { QueryTeacherInput, TeacherOutputModel, UpdateTeacherProfileInput, + QueryTeacherForModeratorInput, } from "../types/teacher/teacher.types.js"; @injectable() @@ -55,13 +56,36 @@ export class TeacherController { maxRating: req.query.maxRating, }; - const teachers = await this.teacherQuery.getAllTeachers(sortData); + const teachers = await this.teacherQuery.getAllActiveTeachers(sortData); return res.status(200).send(teachers); } catch (err) { return next(err); } } + + async getAllTeachersForModerator( + req: RequestWithQuery, + res: ResponseWithData, + next: NextFunction, + ) { + try { + const sortData: QueryTeacherForModeratorInput = { + sortBy: req.query.sortBy, + sortDirection: req.query.sortDirection, + pageNumber: req.query.pageNumber, + pageSize: req.query.pageSize, + }; + + const teachers = + await this.teacherQuery.getAllTeachersForModerator(sortData); + + return res.status(200).send(teachers); + } catch (err) { + return next(err); + } + } + async getTeacherById( req: RequestWithParams, res: Response, @@ -70,7 +94,7 @@ export class TeacherController { try { const teacher = await this.teacherQuery.getTeacherById(req.params.id); - if (!teacher) { + if (!teacher || teacher.status !== "active") { return res.status(404).json({ message: "Teacher not found" }); } @@ -80,6 +104,25 @@ export class TeacherController { } } + async getTeacherByIdForModerator( + req: RequestWithParams, + res: Response, + next: NextFunction, + ) { + const { id } = req.params; + + try { + const teacher = await this.teacherQuery.getTeacherById(id); + + if (!teacher) { + return res.status(404).json({ message: "Teacher not found" }); + } + return res.status(200).json(teacher); + } catch (err) { + return next(err); + } + } + async getMyWeeklyAvailability( req: Request, res: Response, diff --git a/server/src/repositories/queryRepositories/teacher.query.ts b/server/src/repositories/queryRepositories/teacher.query.ts index 63005d05..96000fd3 100644 --- a/server/src/repositories/queryRepositories/teacher.query.ts +++ b/server/src/repositories/queryRepositories/teacher.query.ts @@ -5,6 +5,7 @@ import { TeacherViewType, AvailabilityView, UpdateTeacherProfileInput, + QueryTeacherForModeratorInput, } from "../../types/teacher/teacher.types.js"; import { TeacherModel } from "../../db/schemes/teacherSchema.js"; import { teacherMapper } from "../../utils/mappers/teacher.mapper.js"; @@ -13,7 +14,7 @@ import { buildTeacherFilter } from "../../utils/teachersFilter.js"; @injectable() export class TeacherQuery { - async getAllTeachers( + async getAllActiveTeachers( queries: QueryTeacherInput, ): Promise { try { @@ -22,7 +23,7 @@ export class TeacherQuery { const sortBy = queries.sortBy ?? "createdAt"; const sortDirection = queries.sortDirection ?? "desc"; const filter = buildTeacherFilter(queries); - + filter.status = "active"; const items = await TeacherModel.find(filter) .sort(filterForSort(sortBy, sortDirection)) .skip((pageNumber - 1) * +pageSize) @@ -47,6 +48,39 @@ export class TeacherQuery { } } + async getAllTeachersForModerator( + queries: QueryTeacherForModeratorInput, + ): Promise { + try { + const pageNumber = queries.pageNumber ?? 1; + const pageSize = queries.pageSize ?? 10; + const sortBy = queries.sortBy ?? "createdAt"; + const sortDirection = queries.sortDirection ?? "desc"; + + const items = await TeacherModel.find() + .sort(filterForSort(sortBy, sortDirection)) + .skip((pageNumber - 1) * +pageSize) + .limit(+pageSize) + .lean(); + + const totalCount = await TeacherModel.countDocuments(); + + const pagesCount = Math.ceil(totalCount / +pageSize); + + return { + pagesCount, + page: pageNumber, + pageSize, + totalCount, + items: items.map(teacherMapper), + }; + } catch (err: unknown) { + throw new Error("Something went wrong with getting all teachers", { + cause: err, + }); + } + } + async getTeacherByEmail(email: string): Promise { try { const teacher = await TeacherModel.findOne({ email }).lean(); diff --git a/server/src/routes/teacherRoute.ts b/server/src/routes/teacherRoute.ts index aa270bbe..64fa7ec4 100644 --- a/server/src/routes/teacherRoute.ts +++ b/server/src/routes/teacherRoute.ts @@ -21,6 +21,13 @@ teacherRouter.get( teacherController.getAllTeachers.bind(teacherController), ); +teacherRouter.get( + "/get-teachers-moderator", + authMiddleware.handle, + requireRole("moderator"), + teacherController.getAllTeachersForModerator.bind(teacherController), +); + teacherRouter.get( "/me", authMiddleware.handle, @@ -57,6 +64,13 @@ teacherRouter.get( teacherController.getTeacherById.bind(teacherController), ); +teacherRouter.get( + "/:id/moderator", + authMiddleware.handle, + requireRole("moderator"), + teacherController.getTeacherByIdForModerator.bind(teacherController), +); + teacherRouter.delete( "/:id", authMiddleware.handle, diff --git a/server/src/types/teacher/teacher.types.ts b/server/src/types/teacher/teacher.types.ts index c4e5cb84..f8ae9c55 100644 --- a/server/src/types/teacher/teacher.types.ts +++ b/server/src/types/teacher/teacher.types.ts @@ -84,6 +84,13 @@ export type QueryTeacherInput = { pageSize?: number; }; +export type QueryTeacherForModeratorInput = { + sortBy?: SortBy; + sortDirection?: SortDirection; + pageNumber?: number; + pageSize?: number; +}; + export type TeacherOutputModel = { pagesCount?: number; page?: number;