From a1e8ead6ef51ebc610ed2d4f6d1fc7963dc9633d Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 10:54:50 +0100 Subject: [PATCH 1/4] feat: rejection schema --- .../ui/statusButtons/StatusButtons.tsx | 6 ++++- .../mutations/useUpdateAppointmentMutation.ts | 11 ++++++++- .../TeacherAppointments.tsx | 24 +++++++++++++++++++ server/src/db/schemes/appointmentSchema.ts | 1 + .../src/db/schemes/types/appointment.types.ts | 1 + .../types/appointment/appointment.types.ts | 1 + 6 files changed, 42 insertions(+), 2 deletions(-) diff --git a/client/src/components/ui/statusButtons/StatusButtons.tsx b/client/src/components/ui/statusButtons/StatusButtons.tsx index 1ad6d28d..5541cf2e 100644 --- a/client/src/components/ui/statusButtons/StatusButtons.tsx +++ b/client/src/components/ui/statusButtons/StatusButtons.tsx @@ -26,7 +26,11 @@ export const StatusButtons = ({ if (disabled) return; event.preventDefault(); event.stopPropagation(); - setStatus(newStatus); + + if (newStatus !== "rejected") { + setStatus(newStatus); + } + onStatusChange?.(newStatus); }; diff --git a/client/src/features/appointments/mutations/useUpdateAppointmentMutation.ts b/client/src/features/appointments/mutations/useUpdateAppointmentMutation.ts index 084a23e5..b32a7046 100644 --- a/client/src/features/appointments/mutations/useUpdateAppointmentMutation.ts +++ b/client/src/features/appointments/mutations/useUpdateAppointmentMutation.ts @@ -11,14 +11,23 @@ import { getErrorMessage } from "../../../util/ErrorUtil"; interface UpdateAppointmentRequest { appointmentId: string; status: AppointmentStatus; + rejectionReason?: string; } const updateAppointmentStatus = async ( data: UpdateAppointmentRequest, ): Promise => { + const requestBody: { status: AppointmentStatus; rejectionReason?: string } = { + status: data.status, + }; + + if (data.rejectionReason) { + requestBody.rejectionReason = data.rejectionReason; + } + const response = await apiProtected.put( `/api/appointments/${data.appointmentId}/status`, - { status: data.status }, + requestBody, ); return response.data; }; diff --git a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx index 56bbfa37..c5c13043 100644 --- a/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx +++ b/client/src/pages/privetTeachersPages/teacherAppointments/TeacherAppointments.tsx @@ -14,6 +14,7 @@ import { useVideoCall } from "../../../features/appointments/hooks/useVideoCall" import { useAppointmentTime } from "../../../features/appointments/hooks/useAppointmentTime"; import { TeacherAppointmentsList } from "../../../components/teacherAppointmentCard/TeacherAppointmentsList"; import { RegularStudentScheduleModal } from "../../../components/regularStudentScheduleModal/RegularStudentScheduleModal"; +import { RejectAppointmentModal } from "../../../components/appointmentCard/RejectAppointmentModal"; import { useSetRegularStudentMutation } from "../../../features/appointments/mutations/useSetRegularStudentMutation"; import { useUpdateWeeklyScheduleMutation } from "../../../features/appointments/mutations/useUpdateWeeklyScheduleMutation"; import { useRemoveRegularStudentMutation } from "../../../features/appointments/mutations/useRemoveRegularStudentMutation"; @@ -30,6 +31,9 @@ export const TeacherAppointments = () => { const [selectedStudent, setSelectedStudent] = useState( null, ); + const [isRejectModalOpen, setIsRejectModalOpen] = useState(false); + const [appointmentToReject, setAppointmentToReject] = + useState(null); const { open: openModal } = useModalStore(); const { confirmStartCall } = useVideoCall(); @@ -74,6 +78,15 @@ export const TeacherAppointments = () => { appointmentId: string, newStatus: AppointmentStatus, ) => { + if (newStatus === "rejected") { + const appointment = appointments.find((apt) => apt.id === appointmentId); + if (appointment) { + setAppointmentToReject(appointment); + setIsRejectModalOpen(true); + } + return; + } + updateAppointmentMutation.mutate({ appointmentId, status: newStatus, @@ -241,6 +254,17 @@ export const TeacherAppointments = () => { .flatMap((apt) => apt.weeklySchedule || []) || [] } /> + + {appointmentToReject && ( + { + setIsRejectModalOpen(false); + setAppointmentToReject(null); + }} + /> + )} ); }; diff --git a/server/src/db/schemes/appointmentSchema.ts b/server/src/db/schemes/appointmentSchema.ts index 6b2e6eaa..a4605424 100644 --- a/server/src/db/schemes/appointmentSchema.ts +++ b/server/src/db/schemes/appointmentSchema.ts @@ -29,6 +29,7 @@ export const AppointmentSchema = new mongoose.Schema( enum: ["pending", "approved", "rejected"], default: "pending", }, + rejectionReason: { type: String, default: null }, videoCall: { type: String, default: null }, isRegularStudent: { type: Boolean, default: false }, weeklySchedule: [weeklyScheduleSlotSchema], diff --git a/server/src/db/schemes/types/appointment.types.ts b/server/src/db/schemes/types/appointment.types.ts index 28076b2f..914e8397 100644 --- a/server/src/db/schemes/types/appointment.types.ts +++ b/server/src/db/schemes/types/appointment.types.ts @@ -19,6 +19,7 @@ export interface AppointmentTypeDB { time: string; description?: string; status: "pending" | "approved" | "rejected"; + rejectionReason?: string; videoCall?: string; isRegularStudent?: boolean; weeklySchedule?: WeeklyScheduleSlot[]; diff --git a/server/src/types/appointment/appointment.types.ts b/server/src/types/appointment/appointment.types.ts index d4ae26d6..2b4d1f8d 100644 --- a/server/src/types/appointment/appointment.types.ts +++ b/server/src/types/appointment/appointment.types.ts @@ -15,6 +15,7 @@ export interface CreateAppointmentType { export interface UpdateAppointmentStatusType { status: "pending" | "approved" | "rejected"; + rejectionReason?: string; } export interface UpdateWeeklyScheduleType { From c40a4c59fac51d90e327024ab77e04c289567b46 Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 10:55:30 +0100 Subject: [PATCH 2/4] feat: rejection validation --- .../appointmentValidationMiddleware.ts | 7 +++++++ .../appointment/rejectionReasonValidation.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 server/src/validation/appointment/rejectionReasonValidation.ts diff --git a/server/src/validation/appointment/appointmentValidationMiddleware.ts b/server/src/validation/appointment/appointmentValidationMiddleware.ts index 35b1592a..5bb35d68 100644 --- a/server/src/validation/appointment/appointmentValidationMiddleware.ts +++ b/server/src/validation/appointment/appointmentValidationMiddleware.ts @@ -29,6 +29,13 @@ export const updateAppointmentStatusValidationMiddleware = () => [ .withMessage("Status is needed") .isIn(["pending", "approved", "rejected"]) .withMessage("Status must be pending, approved, or rejected"), + body("rejectionReason") + .if(body("status").equals("rejected")) + .notEmpty() + .withMessage("Rejection reason is required when rejecting an appointment") + .isLength({ min: 100, max: 500 }) + .withMessage("Rejection reason must be between 100 and 500 characters") + .trim(), ]; export const idParamValidationMiddleware = () => [ diff --git a/server/src/validation/appointment/rejectionReasonValidation.ts b/server/src/validation/appointment/rejectionReasonValidation.ts new file mode 100644 index 00000000..ea8b065c --- /dev/null +++ b/server/src/validation/appointment/rejectionReasonValidation.ts @@ -0,0 +1,16 @@ +import { check } from "express-validator"; + +export const rejectionReasonValidation = check("rejectionReason") + .if(check("status").equals("rejected")) + .notEmpty() + .withMessage("Rejection reason is required when rejecting an appointment") + .isLength({ min: 100, max: 500 }) + .withMessage("Rejection reason must be between 100 and 500 characters") + .trim(); + +export const appointmentStatusUpdateValidation = [ + check("status") + .isIn(["pending", "approved", "rejected"]) + .withMessage("Status must be pending, approved, or rejected"), + rejectionReasonValidation, +]; From d01923fd327c51382372bd95283f8635451c8ca7 Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 10:56:13 +0100 Subject: [PATCH 3/4] feat: rejection logic --- .../appointment/appointment.service.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/src/services/appointment/appointment.service.ts b/server/src/services/appointment/appointment.service.ts index b4df5bfb..89f3cfd8 100644 --- a/server/src/services/appointment/appointment.service.ts +++ b/server/src/services/appointment/appointment.service.ts @@ -111,6 +111,22 @@ export class AppointmentService { } async updateAppointmentStatus(id: string, data: UpdateAppointmentStatusType) { + if (data.status === "rejected") { + if (!data.rejectionReason) { + throw new Error( + "Rejection reason is required when rejecting an appointment", + ); + } + if ( + data.rejectionReason.length < 100 || + data.rejectionReason.length > 500 + ) { + throw new Error( + "Rejection reason must be between 100 and 500 characters", + ); + } + } + const updateData = { ...data, updatedAt: new Date(), @@ -258,6 +274,7 @@ export class AppointmentService { time: string; description?: string; status: string; + rejectionReason?: string; videoCall?: string; isRegularStudent?: boolean; weeklySchedule?: { day: string; hour: number }[]; @@ -281,6 +298,7 @@ export class AppointmentService { time: apt.time, description: apt.description, status: apt.status, + rejectionReason: apt.rejectionReason, videoCall: apt.videoCall, isRegularStudent: apt.isRegularStudent, weeklySchedule: apt.weeklySchedule, From 385212f689c270223cf3a51afd4961467faf4422 Mon Sep 17 00:00:00 2001 From: Dasha Date: Mon, 9 Mar 2026 10:58:03 +0100 Subject: [PATCH 4/4] feat: rejection components --- .../appointmentCard/AppointmentCard.tsx | 3 + .../RejectAppointmentModal.tsx | 132 ++++++++++++++++++ .../RejectionReasonDisplay.tsx | 24 ++++ client/src/types/appointments.types.ts | 1 + 4 files changed, 160 insertions(+) create mode 100644 client/src/components/appointmentCard/RejectAppointmentModal.tsx create mode 100644 client/src/components/appointmentCard/RejectionReasonDisplay.tsx diff --git a/client/src/components/appointmentCard/AppointmentCard.tsx b/client/src/components/appointmentCard/AppointmentCard.tsx index 7a941eed..4f5d28e4 100644 --- a/client/src/components/appointmentCard/AppointmentCard.tsx +++ b/client/src/components/appointmentCard/AppointmentCard.tsx @@ -3,6 +3,7 @@ import { AppointmentStatusBar } from "./AppointmentStatusBar"; import { AppointmentAvatar } from "./AppointmentAvatar"; import { AppointmentInfo } from "./AppointmentInfo"; import { AppointmentJoinButton } from "./AppointmentJoinButton"; +import { RejectionReasonDisplay } from "./RejectionReasonDisplay"; import { getStatusStyles, isInternalVideoCallLink, @@ -69,6 +70,8 @@ export const AppointmentCard = ({ onDelete={isPast ? onDelete : undefined} /> + + ); }; diff --git a/client/src/components/appointmentCard/RejectAppointmentModal.tsx b/client/src/components/appointmentCard/RejectAppointmentModal.tsx new file mode 100644 index 00000000..cbdc21f8 --- /dev/null +++ b/client/src/components/appointmentCard/RejectAppointmentModal.tsx @@ -0,0 +1,132 @@ +import React, { useState } from "react"; +import { Appointment } from "../../types/appointments.types"; +import { useUpdateAppointmentMutation } from "../../features/appointments/mutations/useUpdateAppointmentMutation"; + +interface RejectAppointmentModalProps { + appointment: Appointment; + isOpen: boolean; + onClose: () => void; +} + +export const RejectAppointmentModal = ({ + appointment, + isOpen, + onClose, +}: RejectAppointmentModalProps) => { + const [rejectionReason, setRejectionReason] = useState(""); + const [error, setError] = useState(""); + const updateAppointmentMutation = useUpdateAppointmentMutation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (rejectionReason.trim().length < 100) { + setError("Rejection reason must be at least 100 characters"); + return; + } + + if (rejectionReason.trim().length > 500) { + setError("Rejection reason must not exceed 500 characters"); + return; + } + + try { + await updateAppointmentMutation.mutateAsync({ + appointmentId: appointment.id, + status: "rejected", + rejectionReason: rejectionReason.trim(), + }); + onClose(); + setRejectionReason(""); + setError(""); + } catch { + setError("Failed to reject appointment. Please try again."); + } + }; + + const handleClose = () => { + onClose(); + setRejectionReason(""); + setError(""); + }; + + if (!isOpen) return null; + + return ( +
+
+

Reject Appointment

+ +
+

+ Student: {appointment.studentName} +

+

+ Lesson: {appointment.lesson} +

+

+ Date & Time: {appointment.date} at{" "} + {appointment.time} +

+
+ +
+
+ +