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, MapPin,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; 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 { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -49,6 +49,8 @@ export default function AppointmentDetailPage() {
const [rejectionReason, setRejectionReason] = useState<string>(""); const [rejectionReason, setRejectionReason] = useState<string>("");
const [isScheduling, setIsScheduling] = useState(false); const [isScheduling, setIsScheduling] = useState(false);
const [isRejecting, setIsRejecting] = useState(false); const [isRejecting, setIsRejecting] = useState(false);
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
@ -207,6 +209,36 @@ export default function AppointmentDetailPage() {
toast.success(`${label} copied to clipboard`); 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) { if (loading) {
return ( return (
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}> <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> </div>
)} )}
{/* Join Meeting Button (if scheduled) */} {/* Meeting Button (if scheduled) */}
{appointment.status === "scheduled" && appointment.moderator_join_url && ( {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={`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"> <div className="p-6 space-y-3">
{appointment.can_join_as_moderator ? ( {(() => {
<a const canJoin = appointment.can_join_as_moderator === true || appointment.can_join_as_moderator === "true";
href={appointment.moderator_join_url} const startedAt = appointment.started_at || appointment.meeting_started_at;
target="_blank" const hasStarted = startedAt != null && startedAt !== "";
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`} if (!canJoin) {
> return (
<Video className="w-5 h-5" /> <button
Join Meeting as Moderator disabled
</a> 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"}`}
) : ( >
<button <Video className="w-5 h-5" />
disabled Meeting Not Available
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"}`} </button>
> );
<Video className="w-5 h-5" /> }
Meeting Not Available Yet
</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>
</div> </div>
)} )}

View File

@ -737,3 +737,47 @@ export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo>
return data; 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/`, availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`, checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`, 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; } as const;

View File

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