diff --git a/src/app/(app)/schedule/page.tsx b/src/app/(app)/schedule/page.tsx index 1e79027..5c5179a 100644 --- a/src/app/(app)/schedule/page.tsx +++ b/src/app/(app)/schedule/page.tsx @@ -8,7 +8,7 @@ export default function Schedule() {
- +
diff --git a/src/app/(app)/settings/backoffice/students/[student_id]/page.tsx b/src/app/(app)/settings/backoffice/students/[student_id]/page.tsx new file mode 100644 index 0000000..553da5a --- /dev/null +++ b/src/app/(app)/settings/backoffice/students/[student_id]/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import CalendarView from "@/components/calendar/calendar"; +import SettingsWrapper from "@/components/settings-wrapper"; +import TabsGroup, { + PanelContainer, + Tab, + TabsContainer, + TabPanel, +} from "@/components/tabs"; +import { useForgotPassword } from "@/lib/mutations/session"; +import { + useGetStudentById, + useGetStudentScheduleById, +} from "@/lib/queries/backoffice"; +import { extractShifts, formatIShift } from "@/lib/utils"; +import clsx from "clsx"; +import { useParams } from "next/navigation"; +import { twMerge } from "tailwind-merge"; + +export default function Student() { + const params = useParams(); + + const { data: student } = useGetStudentById(params.student_id as string); + + const { data: studentSchedule } = useGetStudentScheduleById( + params.student_id as string, + ); + + const formattedShifts = formatIShift(extractShifts(studentSchedule || [])); + + const forgotPassword = useForgotPassword(); + + return ( + +
+
+
+

+ {`${student?.user.name} - `} + {student?.number} +

+
+
+ + + + + + + + + + + + {forgotPassword.isSuccess && ( +

+ Forgot Password Email was sent to the user +

+ )} + + {forgotPassword.isError && ( +

Something went wrong

+ )} +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/src/app/(app)/settings/backoffice/students/page.tsx b/src/app/(app)/settings/backoffice/students/page.tsx new file mode 100644 index 0000000..65723f3 --- /dev/null +++ b/src/app/(app)/settings/backoffice/students/page.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { AuthCheck } from "@/components/auth-check"; +import Avatar from "@/components/avatar"; +import SettingsWrapper from "@/components/settings-wrapper"; +import { useListStudents } from "@/lib/queries/backoffice"; +import { + FlopMetaParams, + FlopMetaResponse, + SortDirection, + Student, +} from "@/lib/types"; +import { firstLastName } from "@/lib/utils"; +import clsx from "clsx"; +import Link from "next/link"; +import { + useState, + createContext, + useContext, + useMemo, + useCallback, +} from "react"; +import { twMerge } from "tailwind-merge"; + +interface SortState { + column: string | null; + direction: SortDirection; +} + +interface ITableContext { + meta: FlopMetaResponse; + setCurrentPage: (page: number) => void; + sortState: SortState; + handleSort: (column: string) => void; + getSortDirection: (column: string) => SortDirection; +} + +const TableContext = createContext({ + meta: { + sort: [], + filters: [], + page_size: 8, + current_page: 0, + next_page: 1, + previous_page: null, + total_pages: 0, + has_next_page: true, + has_previous_page: false, + total_entries: 0, + }, + setCurrentPage: () => {}, + sortState: { column: null, direction: SortDirection.NONE }, + handleSort: () => {}, + getSortDirection: () => SortDirection.NONE, +}); + +function Table({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function TableHeader() { + return ( +
+ + + + +
+
+ ); +} + +function HeaderElement({ + title, + className, + value, + sortable = false, +}: { + title: string; + className?: string; + value?: string; + sortable?: boolean; +}) { + const { handleSort, getSortDirection } = useContext(TableContext); + const currentDirection = getSortDirection(value!); + + const getSortIcon = (direction: SortDirection) => { + switch (direction) { + case SortDirection.ASC: + return "keyboard_arrow_up"; + case SortDirection.DESC: + return "keyboard_arrow_down"; + default: + return "unfold_more"; + } + }; + + return ( +
+ +
+ ); +} + +function TableContent({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function UserCard({ student }: { student: Student }) { + return ( +
+
+ +

{firstLastName(student.user.name)}

+
+ +

{student.number}

+ +

{student.user.email}

+ +

{student.special_status}

+ +
+ + + edit + + +
+
+ ); +} + +function TablePagination({ + range, + entries, +}: { + range: string; + entries: number; +}) { + const { setCurrentPage, meta } = useContext(TableContext); + + return ( +
+

+ Showing {range} of{" "} + {entries}{" "} +

+ +
+ + +
+
+ ); +} + +export default function Students() { + const [currentPage, setCurrentPage] = useState(1); + const [search, setSearch] = useState(""); + + const [sortState, setSortState] = useState({ + column: null, + direction: SortDirection.NONE, + }); + + const handleSort = useCallback((column: string) => { + setSortState((prevState) => { + if (prevState.column === column) { + switch (prevState.direction) { + case SortDirection.NONE: + return { column, direction: SortDirection.ASC }; + case SortDirection.ASC: + return { column, direction: SortDirection.DESC }; + case SortDirection.DESC: + return { column: null, direction: SortDirection.NONE }; + default: + return { column, direction: SortDirection.ASC }; + } + } + return { column, direction: SortDirection.ASC }; + }); + + setCurrentPage(1); + }, []); + + const getSortDirection = useCallback( + (column: string): SortDirection => { + return sortState.column === column + ? sortState.direction + : SortDirection.NONE; + }, + [sortState], + ); + + const PAGE_SIZE = 8; + + const queryParams = useMemo(() => { + const filters = []; + + if (search.trim()) { + filters.push({ + field: "name", + op: "ilike_or", + value: search.trim(), + }); + } + + const params: FlopMetaParams = { + filters: filters, + page_size: PAGE_SIZE, + page: currentPage, + }; + + if (sortState.column && sortState.direction !== SortDirection.NONE) { + params["order_by[]"] = sortState.column; + params["order_directions[]"] = sortState.direction; + } + + return params; + }, [currentPage, search, sortState]); + + const { data: studentsResponse, isLoading } = useListStudents(queryParams); + + const meta = studentsResponse?.meta || queryParams; + const studentsList = studentsResponse?.users || []; + + const contextValue = { + meta: meta || { + sort: [], + filters: [], + page_size: PAGE_SIZE, + page: currentPage, + next_page: null, + previous_page: null, + total_pages: 0, + has_next_page: false, + has_previous_page: false, + total_entries: 0, + }, + setCurrentPage, + sortState, + handleSort, + getSortDirection, + }; + + return ( + + Pombo | Students + + +
+
+
+

Students

+
+ +
+
+ + search + + { + setSearch(e.target.value); + setCurrentPage(1); + }} + /> +
+
+
+ +
+
+ + + + + + {isLoading ? ( +

+ Loading... +

+ ) : studentsList.length > 0 ? ( + studentsList.map((student: Student) => ( + + )) + ) : ( +

No users

+ )} +
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 8a40a23..c1b7eb0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -110,13 +110,13 @@ input[type="number"] { .rbc-calendar { width: 100% !important; - height: calc(100dvh - 124px) !important; + height: calc(100dvh - var(--desktop_height_var, 124px)) !important; border-radius: 15px !important; } @media (max-width: 768px) { .rbc-calendar { - height: calc(100dvh - 154px) !important; + height: calc(100dvh - var(--mobile_height_var, 154px)) !important; } } diff --git a/src/components/schedule-calendar.tsx b/src/components/schedule-calendar.tsx index 399e1fe..a38e357 100644 --- a/src/components/schedule-calendar.tsx +++ b/src/components/schedule-calendar.tsx @@ -2,8 +2,8 @@ import CalendarView from "./calendar/calendar"; import { useContext } from "react"; -import moment from "moment"; import { ScheduleContext } from "@/contexts/schedule-provider"; +import { formatIShift } from "@/lib/utils"; export default function ScheduleCalendar() { const context = useContext(ScheduleContext); @@ -11,31 +11,7 @@ export default function ScheduleCalendar() { const { isEditing, editingShifts = [] } = context; // Converts an IShift to an Event - const formattedEvents = editingShifts.map((shift) => { - const [startHour, startMinute] = shift.start.split(":"); - const [endHour, endMinute] = shift.end.split(":"); - - return { - title: `${shift.shortCourseName} - ${shift.shiftType}${shift.shiftNumber}`, - start: moment() - .day(shift.weekday + 1) - .hour(+startHour) - .minute(+startMinute) - .toDate(), - /* (*) we're subtracting 1 minute here to solve an issue that occurs when - * the end time of an event is equal to the start time of another. - * this issue causes the event bellow to think it is overlapping with the top one, - * when the `dayLayoutAlgorithm` is set to `no-overlap`. - */ - end: moment() - .day(shift.weekday + 1) - .hour(+endHour) - .minute(+endMinute - 1) // (*) - .toDate(), - allDay: false, - resource: shift, - }; - }); + const formattedEvents = formatIShift(editingShifts); return (
diff --git a/src/components/sidebar-settings.tsx b/src/components/sidebar-settings.tsx index d62abc0..073ae74 100644 --- a/src/components/sidebar-settings.tsx +++ b/src/components/sidebar-settings.tsx @@ -54,11 +54,12 @@ export default function SidebarSettings() { - - + + + + + + {user.data && user.data.type === "admin" && ( diff --git a/src/contexts/schedule-provider.tsx b/src/contexts/schedule-provider.tsx index 2870c81..161f5ea 100644 --- a/src/contexts/schedule-provider.tsx +++ b/src/contexts/schedule-provider.tsx @@ -6,7 +6,8 @@ import { useGetStudentOriginalSchedule, useGetStudentSchedule, } from "@/lib/queries/courses"; -import { ICourse, IShift, IShiftsSorted } from "@/lib/types"; +import { IShift, IShiftsSorted } from "@/lib/types"; +import { extractShifts } from "@/lib/utils"; import { createContext, useEffect, useState } from "react"; interface IScheduleProvider { @@ -133,79 +134,6 @@ function sortShiftsByYearCourse(mixedShifts: IShift[]): IShiftsSorted { })); } -function extractShifts(courses: ICourse[]): IShift[] { - const { parentCourse, normalCourses } = courses.reduce( - (acc: { parentCourse: ICourse[]; normalCourses: ICourse[] }, course) => { - if (course.courses.length > 0) { - acc.parentCourse.push(course); - } else { - acc.normalCourses.push(course); - } - return acc; - }, - { parentCourse: [], normalCourses: [] }, - ); - - const shiftsWithNoParents = normalCourses.flatMap((course) => { - if (course.shifts && course.shifts.length > 0) { - return course.shifts.flatMap((shiftGroup) => - shiftGroup.timeslots.map((shift) => { - const WEEK_DAYS = [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - ]; - - const SHIFT_TYPES: Record = { - theoretical: "T", - theoretical_practical: "TP", - practical_laboratory: "PL", - tutorial_guidance: "OL", - }; - - const convertShiftType = (type: string): "PL" | "T" | "TP" | "OL" => { - return SHIFT_TYPES[type as keyof typeof SHIFT_TYPES]; - }; - - return { - id: shiftGroup.id, - courseName: course.name, - courseId: course.id, - shortCourseName: course.shortname, - professor: shiftGroup.professor ?? undefined, - weekday: WEEK_DAYS.indexOf(shift.weekday), - start: shift.start, - end: shift.end, - shiftType: convertShiftType(shiftGroup.type), - shiftNumber: shiftGroup.number, - building: shift.building - ? Number(shift.building) <= 3 - ? `CP${shift.building}` - : `Building ${shift.building}` - : null, - room: shift.room || null, - year: course.year, - semester: course.semester, - eventColor: "#C3E5F9", - textColor: "#227AAE", - status: shiftGroup.enrollment_status, - }; - }), - ); - } - return []; - }); - - const childShifts = - parentCourse.length > 0 - ? extractShifts(parentCourse.flatMap((c) => c.courses)) - : []; - - return [...shiftsWithNoParents, ...childShifts]; -} - export const ScheduleContext = createContext({ originalSchedule: [], currentSchedule: [], diff --git a/src/lib/backoffice.ts b/src/lib/backoffice.ts index 0484f3f..65bea5d 100644 --- a/src/lib/backoffice.ts +++ b/src/lib/backoffice.ts @@ -1,5 +1,5 @@ import { api } from "./api"; -import { IJobProps } from "./types"; +import { FlopMetaParams, IJobProps } from "./types"; export async function listJobs() { try { @@ -65,3 +65,34 @@ export async function getStatistics(course_id: string) { throw new Error("Failed to fetch statistics. Please try again later."); } } + +export async function listStudents(params: FlopMetaParams) { + try { + const res = await api.get("/students", { params }); + return res.data; + } catch { + throw new Error("Failed to list Students. Please try again later."); + } +} + +export async function getStudentScheduleById(student_id: string) { + try { + const res = await api.get(`/student/schedule/${student_id}`); + return res.data.courses; + } catch { + throw new Error( + "Failed to get student's schedule. Please try again later.", + ); + } +} + +export async function getStudentById(id: string) { + try { + const res = await api.get(`/student/${id}`); + return res.data.student; + } catch { + throw new Error( + `Failed to get student with id-${id}. Please try again later.`, + ); + } +} diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..7941785 --- /dev/null +++ b/src/lib/events.ts @@ -0,0 +1,41 @@ +import { api } from "./api"; + +export async function getEvents() { + try { + const res = await api.get("/events"); + return res.data.events; + } catch { + throw new Error(`Failed to fetch events. Please try again later.`); + } +} + +export async function getEventById(id: string) { + try { + const res = await api.get(`/events/${id}`); + return res.data.events; + } catch { + throw new Error( + `Failed to fetch event with id-${id}. Please try again later.`, + ); + } +} + +export async function getCategories() { + try { + const res = await api.get("/event_categories"); + return res.data.categories; + } catch { + throw new Error(`Failed to fetch categories. Please try again later.`); + } +} + +export async function getCategoryById(id: string) { + try { + const res = await api.get(`/event_categories/${id}`); + return res.data.categories; + } catch { + throw new Error( + `Failed to fetch category with id-${id}. Please try again later.`, + ); + } +} diff --git a/src/lib/queries/backoffice.ts b/src/lib/queries/backoffice.ts index cb0d997..726930b 100644 --- a/src/lib/queries/backoffice.ts +++ b/src/lib/queries/backoffice.ts @@ -3,9 +3,13 @@ import { exportGroupEnrollments, exportShiftGroups, getDegrees, + getStudentById, + getStudentScheduleById, getStatistics, listJobs, + listStudents, } from "../backoffice"; +import { FlopMetaParams } from "../types"; export function useListJobs() { return useQuery({ @@ -45,3 +49,25 @@ export function useGetStatistics(courseId?: string) { enabled: !!courseId, }); } + +export function useListStudents(params: FlopMetaParams) { + return useQuery({ + queryKey: ["students-list", params], + queryFn: () => listStudents(params), + placeholderData: (previousData) => previousData, + }); +} + +export function useGetStudentScheduleById(studentId: string) { + return useQuery({ + queryKey: [`student-${studentId}-schedule`, studentId], + queryFn: () => getStudentScheduleById(studentId), + }); +} + +export function useGetStudentById(id: string) { + return useQuery({ + queryKey: ["student", id], + queryFn: () => getStudentById(id), + }); +} diff --git a/src/lib/queries/events.ts b/src/lib/queries/events.ts new file mode 100644 index 0000000..8c61d42 --- /dev/null +++ b/src/lib/queries/events.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { + getCategories, + getCategoryById, + getEventById, + getEvents, +} from "../events"; + +export function useGetEvents() { + return useQuery({ + queryKey: ["events"], + queryFn: getEvents, + }); +} + +export function useGetEventById(id: string) { + return useQuery({ + queryKey: ["event", id], + queryFn: () => getEventById(id), + }); +} + +export function useGetCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: getCategories, + }); +} + +export function useGetCategoryById(id: string) { + return useQuery({ + queryKey: ["category", id], + queryFn: () => getCategoryById(id), + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0a64a15..bdc6f20 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4,6 +4,14 @@ export enum UserType { professor, } +export interface Student { + id: string; + number: string; + degree_year: number | null; + special_status: string; + user: User; +} + export interface User { id: string; name: string; @@ -106,3 +114,31 @@ export interface IItemProps { id: string; name: string; } + +export enum SortDirection { + NONE = "none", + ASC = "asc", + DESC = "desc", +} + +export interface FlopMetaParams { + "order_by[]"?: string; + "order_directions[]"?: string; + filters: { field: string; op: string; value: string }[]; + page_size: number; + page: number; +} + +export interface FlopMetaResponse { + sort: string[]; + filters: string[]; + page_size: number; + current_page: number; + + next_page?: number | null; + previous_page?: number | null; + total_pages?: number; + has_next_page?: boolean; + has_previous_page?: boolean; + total_entries?: number; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ab81fb5..c67d900 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,3 +1,6 @@ +import moment from "moment"; +import { ICourse, IShift } from "./types"; + export function firstLastName(name: string | undefined) { if (!name) return ""; @@ -12,6 +15,7 @@ export function firstLastName(name: string | undefined) { return name?.split(" ").filter(Boolean); } +// Converts a hex color to rgba, enabling opacity and darkening options export const editColor = (color: string, opacity: number, darken: number) => { const r = Math.floor(parseInt(color.slice(1, 3), 16) * darken); const g = Math.floor(parseInt(color.slice(3, 5), 16) * darken); @@ -21,3 +25,106 @@ export const editColor = (color: string, opacity: number, darken: number) => { return rgbaColor; }; + +// Converts an IShift to an Event +export function formatIShift(shifts: IShift[]) { + return shifts.map((shift) => { + const [startHour, startMinute] = shift.start.split(":"); + const [endHour, endMinute] = shift.end.split(":"); + + return { + title: `${shift.shortCourseName} - ${shift.shiftType}${shift.shiftNumber}`, + start: moment() + .day(shift.weekday + 1) + .hour(+startHour) + .minute(+startMinute) + .toDate(), + /* (*) we're subtracting 1 minute here to solve an issue that occurs when + * the end time of an event is equal to the start time of another. + * this issue causes the event bellow to think it is overlapping with the top one, + * when the `dayLayoutAlgorithm` is set to `no-overlap`. + */ + end: moment() + .day(shift.weekday + 1) + .hour(+endHour) + .minute(+endMinute - 1) // (*) + .toDate(), + allDay: false, + resource: shift, + }; + }); +} + +// Extracts IShifts from ICourses +export function extractShifts(courses: ICourse[]): IShift[] { + const { parentCourse, normalCourses } = courses.reduce( + (acc: { parentCourse: ICourse[]; normalCourses: ICourse[] }, course) => { + if (course.courses.length > 0) { + acc.parentCourse.push(course); + } else { + acc.normalCourses.push(course); + } + return acc; + }, + { parentCourse: [], normalCourses: [] }, + ); + + const shiftsWithNoParents = normalCourses.flatMap((course) => { + if (course.shifts && course.shifts.length > 0) { + return course.shifts.flatMap((shiftGroup) => + shiftGroup.timeslots.map((shift) => { + const WEEK_DAYS = [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + ]; + + const SHIFT_TYPES: Record = { + theoretical: "T", + theoretical_practical: "TP", + practical_laboratory: "PL", + tutorial_guidance: "OL", + }; + + const convertShiftType = (type: string): "PL" | "T" | "TP" | "OL" => { + return SHIFT_TYPES[type as keyof typeof SHIFT_TYPES]; + }; + + return { + id: shiftGroup.id, + courseName: course.name, + courseId: course.id, + shortCourseName: course.shortname, + professor: shiftGroup.professor ?? undefined, + weekday: WEEK_DAYS.indexOf(shift.weekday), + start: shift.start, + end: shift.end, + shiftType: convertShiftType(shiftGroup.type), + shiftNumber: shiftGroup.number, + building: shift.building + ? Number(shift.building) <= 3 + ? `CP${shift.building}` + : `Building ${shift.building}` + : null, + room: shift.room || null, + year: course.year, + semester: course.semester, + eventColor: "#C3E5F9", + textColor: "#227AAE", + status: shiftGroup.enrollment_status, + }; + }), + ); + } + return []; + }); + + const childShifts = + parentCourse.length > 0 + ? extractShifts(parentCourse.flatMap((c) => c.courses)) + : []; + + return [...shiftsWithNoParents, ...childShifts]; +}