Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/components/appointmentCard/AppointmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -69,6 +70,8 @@ export const AppointmentCard = ({
onDelete={isPast ? onDelete : undefined}
/>
</div>

<RejectionReasonDisplay appointment={appointment} />
</div>
);
};
132 changes: 132 additions & 0 deletions client/src/components/appointmentCard/RejectAppointmentModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed inset-0 backdrop-blur-sm bg-white/5 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<h2 className="text-xl font-semibold mb-4">Reject Appointment</h2>

<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
<strong>Student:</strong> {appointment.studentName}
</p>
<p className="text-sm text-gray-600 mb-2">
<strong>Lesson:</strong> {appointment.lesson}
</p>
<p className="text-sm text-gray-600 mb-4">
<strong>Date & Time:</strong> {appointment.date} at{" "}
{appointment.time}
</p>
</div>

<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="rejectionReason"
className="block text-sm font-medium text-gray-700 mb-2"
>
Reason for Rejection *
</label>
<textarea
id="rejectionReason"
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={6}
placeholder="Please explain why you are rejecting this appointment (100-500 characters)..."
required
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Minimum 100 characters</span>
<span
className={rejectionReason.length > 500 ? "text-red-500" : ""}
>
{rejectionReason.length}/500
</span>
</div>
</div>

{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-700">{error}</p>
</div>
)}

<div className="flex gap-3">
<button
type="button"
onClick={handleClose}
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
disabled={updateAppointmentMutation.isPending}
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={
updateAppointmentMutation.isPending ||
rejectionReason.trim().length < 100
}
>
{updateAppointmentMutation.isPending
? "Rejecting..."
: "Reject Appointment"}
</button>
</div>
</form>
</div>
</div>
);
};
24 changes: 24 additions & 0 deletions client/src/components/appointmentCard/RejectionReasonDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">
Reason for Rejection:
</h4>
<p className="text-sm text-red-700 leading-relaxed">
{appointment.rejectionReason}
</p>
</div>
);
};
6 changes: 5 additions & 1 deletion client/src/components/ui/statusButtons/StatusButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ export const StatusButtons = ({
if (disabled) return;
event.preventDefault();
event.stopPropagation();
setStatus(newStatus);

if (newStatus !== "rejected") {
setStatus(newStatus);
}

onStatusChange?.(newStatus);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@ import { getErrorMessage } from "../../../util/ErrorUtil";
interface UpdateAppointmentRequest {
appointmentId: string;
status: AppointmentStatus;
rejectionReason?: string;
}

const updateAppointmentStatus = async (
data: UpdateAppointmentRequest,
): Promise<Appointment> => {
const requestBody: { status: AppointmentStatus; rejectionReason?: string } = {
status: data.status,
};

if (data.rejectionReason) {
requestBody.rejectionReason = data.rejectionReason;
}

const response = await apiProtected.put<Appointment>(
`/api/appointments/${data.appointmentId}/status`,
{ status: data.status },
requestBody,
);
return response.data;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +31,9 @@ export const TeacherAppointments = () => {
const [selectedStudent, setSelectedStudent] = useState<Appointment | null>(
null,
);
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
const [appointmentToReject, setAppointmentToReject] =
useState<Appointment | null>(null);

const { open: openModal } = useModalStore();
const { confirmStartCall } = useVideoCall();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -241,6 +254,17 @@ export const TeacherAppointments = () => {
.flatMap((apt) => apt.weeklySchedule || []) || []
}
/>

{appointmentToReject && (
<RejectAppointmentModal
appointment={appointmentToReject}
isOpen={isRejectModalOpen}
onClose={() => {
setIsRejectModalOpen(false);
setAppointmentToReject(null);
}}
/>
)}
</div>
);
};
1 change: 1 addition & 0 deletions client/src/types/appointments.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface Appointment {
time: string;
description?: string;
status: AppointmentStatus;
rejectionReason?: string;
videoCall?: string;
isRegularStudent?: boolean;
addedToRegularAt?: string;
Expand Down
1 change: 1 addition & 0 deletions server/src/db/schemes/appointmentSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const AppointmentSchema = new mongoose.Schema<AppointmentTypeDB>(
enum: ["pending", "approved", "rejected"],
default: "pending",
},
rejectionReason: { type: String, default: null },
videoCall: { type: String, default: null },
isRegularStudent: { type: Boolean, default: false },
weeklySchedule: [weeklyScheduleSlotSchema],
Expand Down
1 change: 1 addition & 0 deletions server/src/db/schemes/types/appointment.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface AppointmentTypeDB {
time: string;
description?: string;
status: "pending" | "approved" | "rejected";
rejectionReason?: string;
videoCall?: string;
isRegularStudent?: boolean;
weeklySchedule?: WeeklyScheduleSlot[];
Expand Down
18 changes: 18 additions & 0 deletions server/src/services/appointment/appointment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -258,6 +274,7 @@ export class AppointmentService {
time: string;
description?: string;
status: string;
rejectionReason?: string;
videoCall?: string;
isRegularStudent?: boolean;
weeklySchedule?: { day: string; hour: number }[];
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/src/types/appointment/appointment.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CreateAppointmentType {

export interface UpdateAppointmentStatusType {
status: "pending" | "approved" | "rejected";
rejectionReason?: string;
}

export interface UpdateWeeklyScheduleType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => [
Expand Down
16 changes: 16 additions & 0 deletions server/src/validation/appointment/rejectionReasonValidation.ts
Original file line number Diff line number Diff line change
@@ -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,
];
Loading