diff --git a/client/src/components/icons/ChevronLeft.tsx b/client/src/components/icons/ChevronLeft.tsx new file mode 100644 index 00000000..cdabb629 --- /dev/null +++ b/client/src/components/icons/ChevronLeft.tsx @@ -0,0 +1,35 @@ +import { + type Ref, + type SVGProps, + forwardRef, + memo, + type MemoExoticComponent, + type ForwardRefExoticComponent, +} from "react"; + +const ChevronLeftComponent = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); + +export const ChevronLeft = memo( + forwardRef(ChevronLeftComponent), +) as MemoExoticComponent>>; diff --git a/client/src/components/icons/ChevronRight.tsx b/client/src/components/icons/ChevronRight.tsx new file mode 100644 index 00000000..37c16064 --- /dev/null +++ b/client/src/components/icons/ChevronRight.tsx @@ -0,0 +1,35 @@ +import { + type Ref, + type SVGProps, + forwardRef, + memo, + type MemoExoticComponent, + type ForwardRefExoticComponent, +} from "react"; + +const ChevronRightComponent = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); + +export const ChevronRight = memo( + forwardRef(ChevronRightComponent), +) as MemoExoticComponent>>; diff --git a/client/src/components/teacherProfileSection/LessonSchedule.tsx b/client/src/components/teacherProfileSection/LessonSchedule.tsx index 9094d0ed..9a467bc2 100644 --- a/client/src/components/teacherProfileSection/LessonSchedule.tsx +++ b/client/src/components/teacherProfileSection/LessonSchedule.tsx @@ -7,19 +7,11 @@ interface TimeSlot { hour: number; } -interface BookedSlot { - day: string; - hour: number; - studentName: string; - lesson: string; -} - interface LessonScheduleProps { isOpen: boolean; onClose: () => void; onSave: (slots: TimeSlot[]) => Promise | void; initialSlots?: TimeSlot[]; - bookedSlots?: BookedSlot[]; } const DAYS = [ @@ -34,30 +26,20 @@ const DAYS = [ const HOURS = Array.from({ length: 17 }, (_, i) => i + 7); -const getFirstName = (fullName: string): string => { - return fullName.split(" ")[0] || fullName; -}; - export const LessonSchedule = ({ isOpen, onClose, onSave, initialSlots = [], - bookedSlots = [], }: LessonScheduleProps) => { const [selectedSlots, setSelectedSlots] = useState>( () => new Set(initialSlots.map((slot) => `${slot.day}-${slot.hour}`)), ); - const bookedSlotsSet = new Set( - bookedSlots.map((slot) => `${slot.day}-${slot.hour}`), - ); - if (!isOpen) return null; const toggleSlot = (day: string, hour: number) => { const key = `${day}-${hour}`; - if (bookedSlotsSet.has(key)) return; setSelectedSlots((prev) => { const newSet = new Set(prev); @@ -70,7 +52,6 @@ export const LessonSchedule = ({ }); }; - // Take selected times, save them, then close this popup. const handleSave = async () => { const slots: TimeSlot[] = Array.from(selectedSlots).map((key) => { const [day, hour] = key.split("-"); @@ -126,37 +107,17 @@ export const LessonSchedule = ({ {HOURS.map((hour) => { const key = `${day}-${hour}`; const isSelected = selectedSlots.has(key); - const isBooked = bookedSlotsSet.has(key); - const bookedInfo = bookedSlots.find( - (slot) => `${slot.day}-${slot.hour}` === key, - ); return ( toggleSlot(day, hour)} className={`border border-gray-600 p-1 transition-colors min-w-[60px] min-h-[50px] ${ - isBooked - ? "bg-purple-800 cursor-not-allowed" - : isSelected - ? "bg-purple-500 hover:bg-purple-600 cursor-pointer" - : "bg-gray-700 hover:bg-gray-600 cursor-pointer" + isSelected + ? "bg-purple-500 hover:bg-purple-600 cursor-pointer" + : "bg-gray-700 hover:bg-gray-600 cursor-pointer" }`} - > - {isBooked && bookedInfo && ( -
-
- {getFirstName(bookedInfo.studentName)} -
-
- {bookedInfo.lesson} -
-
- (Request) -
-
- )} - + > ); })} @@ -171,10 +132,6 @@ export const LessonSchedule = ({
free time lesson -
-
- booked lesson -
diff --git a/client/src/components/teacherSection/teacherSchedule/Calendar/Calendar.tsx b/client/src/components/teacherSection/teacherSchedule/Calendar/Calendar.tsx index 0b26c4ea..a0c5892d 100644 --- a/client/src/components/teacherSection/teacherSchedule/Calendar/Calendar.tsx +++ b/client/src/components/teacherSection/teacherSchedule/Calendar/Calendar.tsx @@ -1,3 +1,7 @@ +import React from "react"; +import { ChevronLeft } from "../../../icons/ChevronLeft"; +import { ChevronRight } from "../../../icons/ChevronRight"; + interface TimeProps { onDateSelect: (date: Date) => void; selectedDate: Date | null; @@ -22,12 +26,15 @@ export function Calendar({ onDateSelect, selectedDate }: TimeProps) { const weekDays = ["M", "T", "W", "Th", "F", "S", "Su"]; const currentDate = new Date(); + const [viewYear, setViewYear] = React.useState(currentDate.getFullYear()); + const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth()); + + const today = currentDate.getDate(); const currentYear = currentDate.getFullYear(); const currentMonth = currentDate.getMonth(); - const today = currentDate.getDate(); const handleDateClick = (day: number): void => { - const selectedDay = new Date(currentYear, currentMonth, day); + const selectedDay = new Date(viewYear, viewMonth, day); const todayDate = new Date(currentYear, currentMonth, today); todayDate.setHours(0, 0, 0, 0); selectedDay.setHours(0, 0, 0, 0); @@ -38,11 +45,44 @@ export function Calendar({ onDateSelect, selectedDate }: TimeProps) { }; const isPastDate = (day: number): boolean => { - return day < today; + const dateToCheck = new Date(viewYear, viewMonth, day); + const todayDate = new Date(currentYear, currentMonth, today); + todayDate.setHours(0, 0, 0, 0); + dateToCheck.setHours(0, 0, 0, 0); + return dateToCheck < todayDate; }; - const totalDays = new Date(currentYear, currentMonth + 1, 0).getDate(); - const startDay = new Date(currentYear, currentMonth, 1).getDay() || 7; + const isSelectedDate = (day: number): boolean => { + if (!selectedDate) return false; + return ( + selectedDate.getDate() === day && + selectedDate.getMonth() === viewMonth && + selectedDate.getFullYear() === viewYear + ); + }; + + const handlePreviousMonth = () => { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear(viewYear - 1); + } else { + setViewMonth(viewMonth - 1); + } + }; + + const handleNextMonth = () => { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear(viewYear + 1); + } else { + setViewMonth(viewMonth + 1); + } + }; + + const isCurrentMonth = viewYear === currentYear && viewMonth === currentMonth; + + const totalDays = new Date(viewYear, viewMonth + 1, 0).getDate(); + const startDay = new Date(viewYear, viewMonth, 1).getDay() || 7; const calendarDays = [ ...Array(startDay - 1).fill(null), @@ -51,10 +91,27 @@ export function Calendar({ onDateSelect, selectedDate }: TimeProps) { return (
-
+
+ +

- {months[currentMonth]} {currentYear} + {months[viewMonth]} {viewYear}

+ +
@@ -81,7 +138,7 @@ export function Calendar({ onDateSelect, selectedDate }: TimeProps) { className={`w-full h-full rounded-[40px] flex items-center justify-center text-sm sm:text-base font-normal leading-6 transition-colors ${ isPastDate(day) ? "text-gray-600 cursor-not-allowed opacity-50" - : selectedDate && day === selectedDate.getDate() + : isSelectedDate(day) ? "bg-[#7C86F7] text-white" : "text-white hover:bg-transparent hover:text-[#F3F2F5] sm:hover:bg-[#F3F2F5] sm:hover:text-black" }`} diff --git a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx index 290d122d..50d1cbfa 100644 --- a/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx +++ b/client/src/pages/privetTeachersPages/teacherProfile/TeacherProfile.tsx @@ -19,8 +19,6 @@ import { import { useMyProfileQuery } from "../../../features/teachers/query/useMyProfileQuery"; import { useUpdateMyProfileMutation } from "../../../features/teachers/mutations/useUpdateMyProfileMutation"; import { useModalStore } from "../../../store/modals.store"; -import { useTeacherAppointmentsQuery } from "../../../features/appointments/query/useTeacherAppointmentsQuery"; -import { mapAppointmentsToBookedSlots } from "../../../util/appointmentSchedule.util"; export type { LessonPrice } from "../../../components/teacherProfileSection/types"; @@ -37,11 +35,6 @@ export const TeacherProfile = () => { const { data: profile, isLoading } = useMyProfileQuery(); const updateProfileMutation = useUpdateMyProfileMutation(); const openModal = useModalStore((s) => s.open); - const { data: appointmentsData } = useTeacherAppointmentsQuery( - undefined, - 1, - 1000, - ); const [isEditing, setIsEditing] = useState(false); const [name, setName] = useState(""); @@ -322,11 +315,6 @@ export const TeacherProfile = () => { } }; - const getBookedSlots = () => { - const appointments = appointmentsData?.appointments || []; - return mapAppointmentsToBookedSlots(appointments); - }; - if (isLoading) { return (
@@ -416,7 +404,6 @@ export const TeacherProfile = () => { onClose={() => setIsScheduleOpen(false)} onSave={handleScheduleSave} initialSlots={schedule} - bookedSlots={getBookedSlots()} /> , + res: Response, + next: NextFunction, + ) { + try { + const teacherId = req.auth?.userId; + if (!teacherId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const appointment = await this.appointmentService.setRegularStudent( + req.params.id, + teacherId, + ); + + if (!appointment) { + return res.status(404).json({ message: "Appointment not found" }); + } + + res.json(appointment); + } catch (error) { + return next(error); + } + } + + async removeRegularStudentController( + req: RequestWithParams, + res: Response, + next: NextFunction, + ) { + try { + const teacherId = req.auth?.userId; + if (!teacherId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const appointment = await this.appointmentService.removeRegularStudent( + req.params.id, + teacherId, + ); + + if (!appointment) { + return res.status(404).json({ message: "Appointment not found" }); + } + + res.json(appointment); + } catch (error) { + return next(error); + } + } + + async updateWeeklyScheduleController( + req: RequestWithBody & + RequestWithParams, + res: Response, + next: NextFunction, + ) { + try { + const teacherId = req.auth?.userId; + if (!teacherId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const appointment = await this.appointmentService.updateWeeklySchedule( + req.params.id, + teacherId, + req.body.weeklySchedule, + ); + + if (!appointment) { + return res.status(404).json({ message: "Appointment not found" }); + } + + res.json(appointment); + } catch (error) { + return next(error); + } + } + + async getRegularStudentsController( + req: RequestWithQuery<{ page?: string; limit?: string }>, + res: Response, + next: NextFunction, + ) { + try { + const teacherId = req.auth?.userId; + if (!teacherId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + let page: number | undefined; + let limit: number | undefined; + + if (req.query.page) { + page = parseInt(req.query.page, 10); + if (isNaN(page)) { + return res.status(400).json({ message: "Invalid page parameter" }); + } + } + + if (req.query.limit) { + limit = parseInt(req.query.limit, 10); + if (isNaN(limit)) { + return res.status(400).json({ message: "Invalid limit parameter" }); + } + } + + const result = await this.appointmentService.getRegularStudentsByTeacher( + teacherId, + page, + limit, + ); + + res.json(result); + } catch (error) { + return next(error); + } + } + + async getRegularTeachersController( + req: RequestWithQuery<{ page?: string; limit?: string }>, + res: Response, + next: NextFunction, + ) { + try { + const studentId = req.auth?.userId; + if (!studentId) { + return res.status(401).json({ message: "Unauthorized" }); + } + + let page: number | undefined; + let limit: number | undefined; + + if (req.query.page) { + page = parseInt(req.query.page, 10); + if (isNaN(page)) { + return res.status(400).json({ message: "Invalid page parameter" }); + } + } + + if (req.query.limit) { + limit = parseInt(req.query.limit, 10); + if (isNaN(limit)) { + return res.status(400).json({ message: "Invalid limit parameter" }); + } + } + + const result = await this.appointmentService.getRegularTeachersByStudent( + studentId, + page, + limit, + ); + + res.json(result); + } catch (error) { + return next(error); + } + } } diff --git a/server/src/db/schemes/appointmentSchema.ts b/server/src/db/schemes/appointmentSchema.ts index 376567d3..0d5e2780 100644 --- a/server/src/db/schemes/appointmentSchema.ts +++ b/server/src/db/schemes/appointmentSchema.ts @@ -2,6 +2,14 @@ import mongoose from "mongoose"; import { AppointmentTypeDB } from "./types/appointment.types.js"; import { WithId } from "mongodb"; +const weeklyScheduleSlotSchema = new mongoose.Schema( + { + day: { type: String, required: true }, + hour: { type: Number, required: true }, + }, + { _id: false }, +); + export const AppointmentSchema = new mongoose.Schema( { id: { type: String, required: true, unique: true }, @@ -21,6 +29,9 @@ export const AppointmentSchema = new mongoose.Schema( default: "pending", }, videoCall: { type: String, default: null }, + isRegularStudent: { type: Boolean, default: false }, + weeklySchedule: [weeklyScheduleSlotSchema], + addedToRegularAt: { type: Date, default: null }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, }, diff --git a/server/src/db/schemes/types/appointment.types.ts b/server/src/db/schemes/types/appointment.types.ts index b65853e4..2ea51c40 100644 --- a/server/src/db/schemes/types/appointment.types.ts +++ b/server/src/db/schemes/types/appointment.types.ts @@ -1,3 +1,8 @@ +export interface WeeklyScheduleSlot { + day: string; + hour: number; +} + export interface AppointmentTypeDB { id: string; studentId: string; @@ -11,6 +16,9 @@ export interface AppointmentTypeDB { time: string; status: "pending" | "approved" | "rejected"; videoCall?: string; + isRegularStudent?: boolean; + weeklySchedule?: WeeklyScheduleSlot[]; + addedToRegularAt?: Date; createdAt: Date; updatedAt: Date; } diff --git a/server/src/repositories/commandRepositories/appointment.command.ts b/server/src/repositories/commandRepositories/appointment.command.ts index a1056838..f57c5f79 100644 --- a/server/src/repositories/commandRepositories/appointment.command.ts +++ b/server/src/repositories/commandRepositories/appointment.command.ts @@ -32,4 +32,42 @@ export class AppointmentCommand { }); } } + + async setRegularStudent( + id: string, + ): Promise | null> { + return await AppointmentModel.findOneAndUpdate( + { id }, + { + isRegularStudent: true, + addedToRegularAt: new Date(), + }, + { new: true }, + ).exec(); + } + + async removeRegularStudent( + id: string, + ): Promise | null> { + return await AppointmentModel.findOneAndUpdate( + { id }, + { + isRegularStudent: false, + weeklySchedule: [], + addedToRegularAt: null, + }, + { new: true }, + ).exec(); + } + + async updateWeeklySchedule( + id: string, + weeklySchedule: { day: string; hour: number }[], + ): Promise | null> { + return await AppointmentModel.findOneAndUpdate( + { id }, + { weeklySchedule }, + { new: true }, + ).exec(); + } } diff --git a/server/src/repositories/queryRepositories/appointment.query.ts b/server/src/repositories/queryRepositories/appointment.query.ts index 3d9626e9..555b891a 100644 --- a/server/src/repositories/queryRepositories/appointment.query.ts +++ b/server/src/repositories/queryRepositories/appointment.query.ts @@ -189,4 +189,106 @@ export class AppointmentQuery { }; }); } + + async getRegularStudentsByTeacher( + teacherId: string, + page?: number, + limit?: number, + ): Promise<{ + appointments: WithId[]; + total: number; + totalPages: number; + }> { + try { + const query = { + teacherId, + isRegularStudent: true, + }; + const total = await AppointmentModel.countDocuments(query); + + if (page !== undefined && limit !== undefined) { + if (page < 1 || limit < 1) { + throw new Error("Page and limit must be positive numbers"); + } + + const skip = (page - 1) * limit; + const appointments = await AppointmentModel.find(query) + .sort({ addedToRegularAt: -1 }) + .skip(skip) + .limit(limit) + .lean(); + + return { + appointments, + total, + totalPages: Math.ceil(total / limit), + }; + } + + const appointments = await AppointmentModel.find(query) + .sort({ addedToRegularAt: -1 }) + .lean(); + + return { + appointments, + total, + totalPages: Math.ceil(total / 10) || 1, + }; + } catch (err: unknown) { + throw new Error("Something went wrong with regular students search", { + cause: err, + }); + } + } + + async getRegularTeachersByStudent( + studentId: string, + page?: number, + limit?: number, + ): Promise<{ + appointments: WithId[]; + total: number; + totalPages: number; + }> { + try { + const query = { + studentId, + isRegularStudent: true, + }; + const total = await AppointmentModel.countDocuments(query); + + if (page !== undefined && limit !== undefined) { + if (page < 1 || limit < 1) { + throw new Error("Page and limit must be positive numbers"); + } + + const skip = (page - 1) * limit; + const appointments = await AppointmentModel.find(query) + .sort({ addedToRegularAt: -1 }) + .skip(skip) + .limit(limit) + .lean(); + + return { + appointments, + total, + totalPages: Math.ceil(total / limit), + }; + } + + const appointments = await AppointmentModel.find(query) + .sort({ addedToRegularAt: -1 }) + .lean(); + + return { + appointments, + total, + totalPages: Math.ceil(total / 10) || 1, + }; + } catch (err: unknown) { + throw new Error("Something went wrong with regular teachers search", { + cause: err, + }); + } + } } diff --git a/server/src/routes/appointmentRoute.ts b/server/src/routes/appointmentRoute.ts index 7d12c468..4ca88286 100644 --- a/server/src/routes/appointmentRoute.ts +++ b/server/src/routes/appointmentRoute.ts @@ -33,6 +33,50 @@ appointmentRouter.post( appointmentController.createAppointmentController.bind(appointmentController), ); +appointmentRouter.get( + "/regular/students", + authMiddleware.handle, + appointmentController.getRegularStudentsController.bind( + appointmentController, + ), +); + +appointmentRouter.get( + "/regular/teachers", + authMiddleware.handle, + appointmentController.getRegularTeachersController.bind( + appointmentController, + ), +); + +appointmentRouter.post( + "/:id/set-regular", + idParamValidationMiddleware(), + errorMiddleware, + authMiddleware.handle, + appointmentController.setRegularStudentController.bind(appointmentController), +); + +appointmentRouter.delete( + "/:id/remove-regular", + idParamValidationMiddleware(), + errorMiddleware, + authMiddleware.handle, + appointmentController.removeRegularStudentController.bind( + appointmentController, + ), +); + +appointmentRouter.put( + "/:id/weekly-schedule", + idParamValidationMiddleware(), + errorMiddleware, + authMiddleware.handle, + appointmentController.updateWeeklyScheduleController.bind( + appointmentController, + ), +); + appointmentRouter.get( "/:id", idParamValidationMiddleware(), diff --git a/server/src/services/appointment/appointment.service.ts b/server/src/services/appointment/appointment.service.ts index a1331d00..df09bc48 100644 --- a/server/src/services/appointment/appointment.service.ts +++ b/server/src/services/appointment/appointment.service.ts @@ -75,6 +75,8 @@ export class AppointmentService { time: data.time, status: "pending" as const, videoCall: `https://meet.google.com/${data.teacherId}-${data.studentId}-${Date.now()}`, + isRegularStudent: false, + weeklySchedule: [], createdAt: new Date(), updatedAt: new Date(), }; @@ -303,6 +305,9 @@ export class AppointmentService { time: string; status: string; videoCall?: string; + isRegularStudent?: boolean; + weeklySchedule?: { day: string; hour: number }[]; + addedToRegularAt?: Date; }; const priceStr = @@ -322,6 +327,111 @@ export class AppointmentService { time: apt.time, status: apt.status, videoCall: apt.videoCall, + isRegularStudent: apt.isRegularStudent, + weeklySchedule: apt.weeklySchedule, + addedToRegularAt: apt.addedToRegularAt, + }; + } + + async setRegularStudent(id: string, teacherId: string) { + const appointment = await this.appointmentQuery.getAppointmentById(id); + + if (!appointment) { + throw new Error("Appointment not found"); + } + + if (appointment.teacherId !== teacherId) { + throw new Error("Unauthorized to modify this appointment"); + } + + const updated = await this.appointmentCommand.setRegularStudent(id); + return updated ? this.formatAppointmentResponse(updated) : null; + } + + async removeRegularStudent(id: string, teacherId: string) { + const appointment = await this.appointmentQuery.getAppointmentById(id); + + if (!appointment) { + throw new Error("Appointment not found"); + } + + if (appointment.teacherId !== teacherId) { + throw new Error("Unauthorized to modify this appointment"); + } + + const updated = await this.appointmentCommand.removeRegularStudent(id); + return updated ? this.formatAppointmentResponse(updated) : null; + } + + async updateWeeklySchedule( + id: string, + teacherId: string, + weeklySchedule: { day: string; hour: number }[], + ) { + const appointment = await this.appointmentQuery.getAppointmentById(id); + + if (!appointment) { + throw new Error("Appointment not found"); + } + + if (appointment.teacherId !== teacherId) { + throw new Error("Unauthorized to modify this appointment"); + } + + if (!appointment.isRegularStudent) { + throw new Error("Cannot set weekly schedule for a non-regular student"); + } + + const updated = await this.appointmentCommand.updateWeeklySchedule( + id, + weeklySchedule, + ); + return updated ? this.formatAppointmentResponse(updated) : null; + } + + async getRegularStudentsByTeacher( + teacherId: string, + page?: number, + limit?: number, + ) { + const result = await this.appointmentQuery.getRegularStudentsByTeacher( + teacherId, + page, + limit, + ); + const appointmentsWithNames = await this.appointmentQuery.populateNames( + result.appointments, + ); + + return { + appointments: appointmentsWithNames.map((apt) => + this.formatAppointmentResponse(apt), + ), + total: result.total, + totalPages: result.totalPages, + }; + } + + async getRegularTeachersByStudent( + studentId: string, + page?: number, + limit?: number, + ) { + const result = await this.appointmentQuery.getRegularTeachersByStudent( + studentId, + page, + limit, + ); + const appointmentsWithNames = await this.appointmentQuery.populateNames( + result.appointments, + ); + + return { + appointments: appointmentsWithNames.map((apt) => + this.formatAppointmentResponse(apt), + ), + total: result.total, + totalPages: result.totalPages, }; } } diff --git a/server/src/types/appointment/appointment.types.ts b/server/src/types/appointment/appointment.types.ts index 8eb26e17..3a642647 100644 --- a/server/src/types/appointment/appointment.types.ts +++ b/server/src/types/appointment/appointment.types.ts @@ -1,3 +1,7 @@ +import { WeeklyScheduleSlot } from "../../db/schemes/types/appointment.types.js"; + +export { WeeklyScheduleSlot }; + export interface CreateAppointmentType { studentId: string; teacherId: string; @@ -11,3 +15,7 @@ export interface CreateAppointmentType { export interface UpdateAppointmentStatusType { status: "pending" | "approved" | "rejected"; } + +export interface UpdateWeeklyScheduleType { + weeklySchedule: WeeklyScheduleSlot[]; +}