Compare commits

...

2 Commits

Author SHA1 Message Date
2bff3ed452 Merge pull request 'Add meeting management functionality to appointment detail page' (#46) from feat/booking-panel into master
Reviewed-on: http://35.207.46.142/ATTUNE-HEART-THERAPY/website/pulls/46
2025-12-04 15:36:43 +00:00
iamkiddy
74d7a35e60 Add meeting management functionality to appointment detail page
Implement start and end meeting features in the appointment detail component. Introduce new API endpoints for starting and ending meetings, and update the appointment model to include meeting status fields. Enhance UI to provide buttons for starting and ending meetings, improving user interaction and experience.
2025-12-04 15:36:14 +00:00
4 changed files with 153 additions and 22 deletions

View File

@ -20,7 +20,7 @@ import {
MapPin,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments } from "@/lib/actions/appointments";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -49,6 +49,8 @@ export default function AppointmentDetailPage() {
const [rejectionReason, setRejectionReason] = useState<string>("");
const [isScheduling, setIsScheduling] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
@ -207,6 +209,36 @@ export default function AppointmentDetailPage() {
toast.success(`${label} copied to clipboard`);
};
const handleStartMeeting = async () => {
if (!appointment) return;
setIsStartingMeeting(true);
try {
const updated = await startMeeting(appointment.id);
setAppointment(updated);
toast.success("Meeting started successfully");
} catch (error: any) {
toast.error(error.message || "Failed to start meeting");
} finally {
setIsStartingMeeting(false);
}
};
const handleEndMeeting = async () => {
if (!appointment) return;
setIsEndingMeeting(true);
try {
const updated = await endMeeting(appointment.id);
setAppointment(updated);
toast.success("Meeting ended successfully");
} catch (error: any) {
toast.error(error.message || "Failed to end meeting");
} finally {
setIsEndingMeeting(false);
}
};
if (loading) {
return (
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
@ -658,29 +690,81 @@ export default function AppointmentDetailPage() {
</div>
)}
{/* Join Meeting Button (if scheduled) */}
{/* Meeting Button (if scheduled) */}
{appointment.status === "scheduled" && appointment.moderator_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className="p-6">
{appointment.can_join_as_moderator ? (
<a
href={appointment.moderator_join_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
>
<Video className="w-5 h-5" />
Join Meeting as Moderator
</a>
) : (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting Not Available Yet
</button>
)}
<div className="p-6 space-y-3">
{(() => {
const canJoin = appointment.can_join_as_moderator === true || appointment.can_join_as_moderator === "true";
const startedAt = appointment.started_at || appointment.meeting_started_at;
const hasStarted = startedAt != null && startedAt !== "";
if (!canJoin) {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting Not Available
</button>
);
}
if (hasStarted) {
return (
<>
<a
href={appointment.moderator_join_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
>
<Video className="w-5 h-5" />
Join Now
</a>
<Button
onClick={handleEndMeeting}
disabled={isEndingMeeting}
variant="outline"
className={`w-full h-12 text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
>
{isEndingMeeting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Ending...
</>
) : (
<>
<X className="w-5 h-5 mr-2" />
End Meeting
</>
)}
</Button>
</>
);
}
return (
<Button
onClick={handleStartMeeting}
disabled={isStartingMeeting}
className="w-full bg-green-600 hover:bg-green-700 text-white h-12 text-base font-medium"
>
{isStartingMeeting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Starting...
</>
) : (
<>
<Video className="w-5 h-5 mr-2" />
Start Meeting
</>
)}
</Button>
);
})()}
</div>
</div>
)}

View File

@ -737,3 +737,47 @@ export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo>
return data;
}
export async function startMeeting(id: string): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.startMeeting(id), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return (data as AppointmentResponse).appointment || data;
}
export async function endMeeting(id: string): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.endMeeting(id), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return (data as AppointmentResponse).appointment || data;
}

View File

@ -29,6 +29,8 @@ export const API_ENDPOINTS = {
availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
startMeeting: (id: string) => `${API_BASE_URL}/meetings/appointments/${id}/start/`,
endMeeting: (id: string) => `${API_BASE_URL}/meetings/appointments/${id}/end/`,
},
} as const;

View File

@ -20,6 +20,7 @@ export interface Appointment {
jitsi_room_id?: string;
jitsi_meeting_created?: boolean;
meeting_started_at?: string;
started_at?: string; // Alternative field name from API
meeting_ended_at?: string;
meeting_duration_actual?: number;
meeting_info?: any;