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}
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/components/appointmentCard/RejectionReasonDisplay.tsx b/client/src/components/appointmentCard/RejectionReasonDisplay.tsx
new file mode 100644
index 00000000..5683f7d6
--- /dev/null
+++ b/client/src/components/appointmentCard/RejectionReasonDisplay.tsx
@@ -0,0 +1,24 @@
+import { Appointment } from "../../types/appointments.types";
+
+interface RejectionReasonDisplayProps {
+ appointment: Appointment;
+}
+
+export const RejectionReasonDisplay = ({
+ appointment,
+}: RejectionReasonDisplayProps) => {
+ if (appointment.status !== "rejected" || !appointment.rejectionReason) {
+ return null;
+ }
+
+ return (
+
+
+ Reason for Rejection:
+
+
+ {appointment.rejectionReason}
+
+
+ );
+};
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/client/src/types/appointments.types.ts b/client/src/types/appointments.types.ts
index dd28f4e6..79789a18 100644
--- a/client/src/types/appointments.types.ts
+++ b/client/src/types/appointments.types.ts
@@ -14,6 +14,7 @@ export interface Appointment {
time: string;
description?: string;
status: AppointmentStatus;
+ rejectionReason?: string;
videoCall?: string;
isRegularStudent?: boolean;
addedToRegularAt?: string;
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/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,
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 {
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,
+];