1030 lines
25 KiB
Markdown
1030 lines
25 KiB
Markdown
|
|
# Booking Rescheduling Guide
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
The rescheduling system allows users to move their existing bookings to different time slots. This guide covers the backend implementation, frontend components, and best practices for handling booking rescheduling.
|
||
|
|
|
||
|
|
## Backend Implementation
|
||
|
|
|
||
|
|
### Rescheduling Endpoint
|
||
|
|
|
||
|
|
**Endpoint**: `PUT /api/bookings/:id/reschedule`
|
||
|
|
**Authentication**: Required (JWT Token)
|
||
|
|
**Permission**: Users can only reschedule their own bookings
|
||
|
|
|
||
|
|
### Request Structure
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"new_schedule_id": 5
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Business Rules
|
||
|
|
|
||
|
|
1. **Time Constraint**: Booking can only be rescheduled at least 2 hours before the original scheduled time
|
||
|
|
2. **Ownership**: Users can only reschedule their own bookings
|
||
|
|
3. **Availability**: New time slot must be available
|
||
|
|
4. **Status**: Only `scheduled` bookings can be rescheduled
|
||
|
|
5. **Automatic Cleanup**: Old Jitsi meeting is deleted, new one is created
|
||
|
|
6. **Notifications**: Reminder notifications are updated automatically
|
||
|
|
|
||
|
|
### Response Examples
|
||
|
|
|
||
|
|
#### Success Response (200)
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"message": "Booking rescheduled successfully"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### Error Responses
|
||
|
|
|
||
|
|
**Unauthorized (403)**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"error": "You can only reschedule your own bookings"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Time Constraint (400)**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"error": "This booking cannot be rescheduled (must be at least 2 hours before scheduled time)"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Slot Unavailable (409)**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"error": "The new time slot is not available"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Booking Not Found (404)**:
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"error": "Booking or new schedule not found"
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Frontend Implementation
|
||
|
|
|
||
|
|
### 1. Rescheduling Modal Component
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { format, addDays, isBefore, addHours } from 'date-fns';
|
||
|
|
|
||
|
|
const RescheduleModal = ({ booking, isOpen, onClose, onSuccess }) => {
|
||
|
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||
|
|
const [availableSlots, setAvailableSlots] = useState([]);
|
||
|
|
const [selectedSlot, setSelectedSlot] = useState(null);
|
||
|
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
const [error, setError] = useState('');
|
||
|
|
|
||
|
|
const apiService = {
|
||
|
|
async getAvailableSlots(date) {
|
||
|
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||
|
|
const response = await fetch(`/api/schedules?date=${dateStr}`);
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error('Failed to fetch available slots');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.json();
|
||
|
|
},
|
||
|
|
|
||
|
|
async rescheduleBooking(bookingId, newScheduleId) {
|
||
|
|
const response = await fetch(`/api/bookings/${bookingId}/reschedule`, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
new_schedule_id: newScheduleId
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const error = await response.json();
|
||
|
|
throw new Error(error.error || 'Failed to reschedule booking');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.json();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen) {
|
||
|
|
loadAvailableSlots();
|
||
|
|
}
|
||
|
|
}, [selectedDate, isOpen]);
|
||
|
|
|
||
|
|
const loadAvailableSlots = async () => {
|
||
|
|
setIsLoading(true);
|
||
|
|
setError('');
|
||
|
|
|
||
|
|
try {
|
||
|
|
const data = await apiService.getAvailableSlots(selectedDate);
|
||
|
|
// Filter out the current booking's slot and past slots
|
||
|
|
const filteredSlots = (data.slots || []).filter(slot => {
|
||
|
|
const slotTime = new Date(slot.start_time);
|
||
|
|
const currentBookingTime = new Date(booking.scheduled_at);
|
||
|
|
|
||
|
|
// Don't show the current booking's slot
|
||
|
|
if (format(slotTime, 'yyyy-MM-dd HH:mm') === format(currentBookingTime, 'yyyy-MM-dd HH:mm')) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Don't show past slots
|
||
|
|
if (isBefore(slotTime, new Date())) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only show available slots
|
||
|
|
return slot.is_available && slot.remaining_slots > 0;
|
||
|
|
});
|
||
|
|
|
||
|
|
setAvailableSlots(filteredSlots);
|
||
|
|
} catch (error) {
|
||
|
|
setError('Failed to load available slots');
|
||
|
|
console.error('Error loading slots:', error);
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleReschedule = async () => {
|
||
|
|
if (!selectedSlot) {
|
||
|
|
setError('Please select a new time slot');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsLoading(true);
|
||
|
|
setError('');
|
||
|
|
|
||
|
|
try {
|
||
|
|
await apiService.rescheduleBooking(booking.id, selectedSlot.id);
|
||
|
|
onSuccess();
|
||
|
|
onClose();
|
||
|
|
} catch (error) {
|
||
|
|
setError(error.message);
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const canReschedule = () => {
|
||
|
|
const scheduledTime = new Date(booking.scheduled_at);
|
||
|
|
const twoHoursFromNow = addHours(new Date(), 2);
|
||
|
|
return scheduledTime > twoHoursFromNow;
|
||
|
|
};
|
||
|
|
|
||
|
|
const getNextTwoWeeks = () => {
|
||
|
|
const days = [];
|
||
|
|
for (let i = 0; i < 14; i++) {
|
||
|
|
days.push(addDays(new Date(), i));
|
||
|
|
}
|
||
|
|
return days;
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!isOpen) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="modal-overlay">
|
||
|
|
<div className="modal-content reschedule-modal">
|
||
|
|
<div className="modal-header">
|
||
|
|
<h3>Reschedule Appointment</h3>
|
||
|
|
<button onClick={onClose} className="close-btn">×</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="modal-body">
|
||
|
|
{/* Current Booking Info */}
|
||
|
|
<div className="current-booking">
|
||
|
|
<h4>Current Appointment</h4>
|
||
|
|
<div className="booking-info">
|
||
|
|
<strong>Date:</strong> {format(new Date(booking.scheduled_at), 'MMMM dd, yyyy')}
|
||
|
|
<br />
|
||
|
|
<strong>Time:</strong> {format(new Date(booking.scheduled_at), 'HH:mm')}
|
||
|
|
<br />
|
||
|
|
<strong>Duration:</strong> {booking.duration} minutes
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{!canReschedule() ? (
|
||
|
|
<div className="reschedule-restriction">
|
||
|
|
<p className="warning">
|
||
|
|
⚠️ This booking cannot be rescheduled because it's less than 2 hours away.
|
||
|
|
Please contact support if you need to make changes.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{/* Date Selection */}
|
||
|
|
<div className="date-selection">
|
||
|
|
<h4>Select New Date</h4>
|
||
|
|
<div className="date-grid">
|
||
|
|
{getNextTwoWeeks().map((date) => (
|
||
|
|
<button
|
||
|
|
key={date.toISOString()}
|
||
|
|
onClick={() => setSelectedDate(date)}
|
||
|
|
className={`date-btn ${
|
||
|
|
format(date, 'yyyy-MM-dd') === format(selectedDate, 'yyyy-MM-dd')
|
||
|
|
? 'active'
|
||
|
|
: ''
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<div className="day-name">{format(date, 'EEE')}</div>
|
||
|
|
<div className="day-number">{format(date, 'dd')}</div>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Time Slot Selection */}
|
||
|
|
<div className="time-selection">
|
||
|
|
<h4>Available Times for {format(selectedDate, 'MMMM dd, yyyy')}</h4>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div className="error-message">{error}</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="loading">Loading available times...</div>
|
||
|
|
) : availableSlots.length === 0 ? (
|
||
|
|
<div className="no-slots">
|
||
|
|
No available time slots for this date. Please select another date.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="time-slots">
|
||
|
|
{availableSlots.map((slot) => (
|
||
|
|
<button
|
||
|
|
key={slot.id}
|
||
|
|
onClick={() => setSelectedSlot(slot)}
|
||
|
|
className={`time-slot ${selectedSlot?.id === slot.id ? 'selected' : ''}`}
|
||
|
|
>
|
||
|
|
<div className="time">
|
||
|
|
{format(new Date(slot.start_time), 'HH:mm')} -
|
||
|
|
{format(new Date(slot.end_time), 'HH:mm')}
|
||
|
|
</div>
|
||
|
|
<div className="availability">
|
||
|
|
{slot.remaining_slots} slot{slot.remaining_slots > 1 ? 's' : ''} available
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="modal-footer">
|
||
|
|
{canReschedule() && (
|
||
|
|
<button
|
||
|
|
onClick={handleReschedule}
|
||
|
|
disabled={!selectedSlot || isLoading}
|
||
|
|
className="btn btn-primary"
|
||
|
|
>
|
||
|
|
{isLoading ? 'Rescheduling...' : 'Confirm Reschedule'}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button onClick={onClose} className="btn btn-secondary">
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default RescheduleModal;
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Booking Management Component
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
import { format, isBefore, addHours } from 'date-fns';
|
||
|
|
import RescheduleModal from './RescheduleModal';
|
||
|
|
|
||
|
|
const MyBookings = () => {
|
||
|
|
const [bookings, setBookings] = useState([]);
|
||
|
|
const [isLoading, setIsLoading] = useState(true);
|
||
|
|
const [selectedBooking, setSelectedBooking] = useState(null);
|
||
|
|
const [showRescheduleModal, setShowRescheduleModal] = useState(false);
|
||
|
|
|
||
|
|
const apiService = {
|
||
|
|
async getUserBookings() {
|
||
|
|
const response = await fetch('/api/bookings', {
|
||
|
|
headers: {
|
||
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error('Failed to fetch bookings');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.json();
|
||
|
|
},
|
||
|
|
|
||
|
|
async cancelBooking(bookingId) {
|
||
|
|
const response = await fetch(`/api/bookings/${bookingId}/cancel`, {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: {
|
||
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const error = await response.json();
|
||
|
|
throw new Error(error.error || 'Failed to cancel booking');
|
||
|
|
}
|
||
|
|
|
||
|
|
return response.json();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadBookings();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const loadBookings = async () => {
|
||
|
|
setIsLoading(true);
|
||
|
|
try {
|
||
|
|
const data = await apiService.getUserBookings();
|
||
|
|
setBookings(data.bookings || []);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error loading bookings:', error);
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleReschedule = (booking) => {
|
||
|
|
setSelectedBooking(booking);
|
||
|
|
setShowRescheduleModal(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCancelBooking = async (bookingId) => {
|
||
|
|
if (!confirm('Are you sure you want to cancel this booking?')) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await apiService.cancelBooking(bookingId);
|
||
|
|
await loadBookings(); // Refresh the list
|
||
|
|
alert('Booking cancelled successfully');
|
||
|
|
} catch (error) {
|
||
|
|
alert(`Error cancelling booking: ${error.message}`);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const canReschedule = (booking) => {
|
||
|
|
const scheduledTime = new Date(booking.scheduled_at);
|
||
|
|
const twoHoursFromNow = addHours(new Date(), 2);
|
||
|
|
return booking.status === 'scheduled' && scheduledTime > twoHoursFromNow;
|
||
|
|
};
|
||
|
|
|
||
|
|
const canCancel = (booking) => {
|
||
|
|
const scheduledTime = new Date(booking.scheduled_at);
|
||
|
|
const twentyFourHoursFromNow = addHours(new Date(), 24);
|
||
|
|
return booking.status === 'scheduled' && scheduledTime > twentyFourHoursFromNow;
|
||
|
|
};
|
||
|
|
|
||
|
|
const getStatusColor = (status) => {
|
||
|
|
switch (status) {
|
||
|
|
case 'scheduled': return 'status-scheduled';
|
||
|
|
case 'completed': return 'status-completed';
|
||
|
|
case 'cancelled': return 'status-cancelled';
|
||
|
|
default: return 'status-default';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const getPaymentStatusColor = (status) => {
|
||
|
|
switch (status) {
|
||
|
|
case 'succeeded': return 'payment-success';
|
||
|
|
case 'pending': return 'payment-pending';
|
||
|
|
case 'failed': return 'payment-failed';
|
||
|
|
default: return 'payment-default';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return <div className="loading">Loading your bookings...</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="my-bookings">
|
||
|
|
<h2>My Appointments</h2>
|
||
|
|
|
||
|
|
{bookings.length === 0 ? (
|
||
|
|
<div className="no-bookings">
|
||
|
|
<p>You don't have any bookings yet.</p>
|
||
|
|
<button className="btn btn-primary">Book Your First Session</button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="bookings-list">
|
||
|
|
{bookings.map((booking) => (
|
||
|
|
<div key={booking.id} className="booking-card">
|
||
|
|
<div className="booking-header">
|
||
|
|
<div className="booking-date">
|
||
|
|
<div className="date">
|
||
|
|
{format(new Date(booking.scheduled_at), 'MMM dd, yyyy')}
|
||
|
|
</div>
|
||
|
|
<div className="time">
|
||
|
|
{format(new Date(booking.scheduled_at), 'HH:mm')}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="booking-status">
|
||
|
|
<span className={`status ${getStatusColor(booking.status)}`}>
|
||
|
|
{booking.status.charAt(0).toUpperCase() + booking.status.slice(1)}
|
||
|
|
</span>
|
||
|
|
<span className={`payment-status ${getPaymentStatusColor(booking.payment_status)}`}>
|
||
|
|
Payment: {booking.payment_status}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="booking-details">
|
||
|
|
<div className="detail-item">
|
||
|
|
<strong>Duration:</strong> {booking.duration} minutes
|
||
|
|
</div>
|
||
|
|
<div className="detail-item">
|
||
|
|
<strong>Amount:</strong> ${booking.amount?.toFixed(2) || '0.00'}
|
||
|
|
</div>
|
||
|
|
{booking.notes && (
|
||
|
|
<div className="detail-item">
|
||
|
|
<strong>Notes:</strong> {booking.notes}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{booking.jitsi_room_url && booking.status === 'scheduled' && (
|
||
|
|
<div className="detail-item">
|
||
|
|
<strong>Meeting Link:</strong>{' '}
|
||
|
|
<a
|
||
|
|
href={booking.jitsi_room_url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="meeting-link"
|
||
|
|
>
|
||
|
|
Join Video Session
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="booking-actions">
|
||
|
|
{canReschedule(booking) && (
|
||
|
|
<button
|
||
|
|
onClick={() => handleReschedule(booking)}
|
||
|
|
className="btn btn-outline-primary btn-sm"
|
||
|
|
>
|
||
|
|
Reschedule
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{canCancel(booking) && (
|
||
|
|
<button
|
||
|
|
onClick={() => handleCancelBooking(booking.id)}
|
||
|
|
className="btn btn-outline-danger btn-sm"
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{booking.status === 'scheduled' && !canReschedule(booking) && !canCancel(booking) && (
|
||
|
|
<span className="no-actions">
|
||
|
|
Too close to appointment time for changes
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Reschedule Modal */}
|
||
|
|
<RescheduleModal
|
||
|
|
booking={selectedBooking}
|
||
|
|
isOpen={showRescheduleModal}
|
||
|
|
onClose={() => {
|
||
|
|
setShowRescheduleModal(false);
|
||
|
|
setSelectedBooking(null);
|
||
|
|
}}
|
||
|
|
onSuccess={() => {
|
||
|
|
loadBookings(); // Refresh bookings after successful reschedule
|
||
|
|
alert('Appointment rescheduled successfully!');
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default MyBookings;
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. CSS Styles for Rescheduling Components
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* Reschedule Modal Styles */
|
||
|
|
.modal-overlay {
|
||
|
|
position: fixed;
|
||
|
|
top: 0;
|
||
|
|
left: 0;
|
||
|
|
right: 0;
|
||
|
|
bottom: 0;
|
||
|
|
background: rgba(0, 0, 0, 0.5);
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
z-index: 1000;
|
||
|
|
}
|
||
|
|
|
||
|
|
.reschedule-modal {
|
||
|
|
background: white;
|
||
|
|
border-radius: 8px;
|
||
|
|
width: 90%;
|
||
|
|
max-width: 600px;
|
||
|
|
max-height: 90vh;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: center;
|
||
|
|
padding: 20px;
|
||
|
|
border-bottom: 1px solid #eee;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-header h3 {
|
||
|
|
margin: 0;
|
||
|
|
color: #333;
|
||
|
|
}
|
||
|
|
|
||
|
|
.close-btn {
|
||
|
|
background: none;
|
||
|
|
border: none;
|
||
|
|
font-size: 24px;
|
||
|
|
cursor: pointer;
|
||
|
|
color: #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-body {
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.current-booking {
|
||
|
|
background: #f8f9fa;
|
||
|
|
padding: 15px;
|
||
|
|
border-radius: 6px;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.current-booking h4 {
|
||
|
|
margin: 0 0 10px 0;
|
||
|
|
color: #333;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-info {
|
||
|
|
color: #666;
|
||
|
|
line-height: 1.5;
|
||
|
|
}
|
||
|
|
|
||
|
|
.reschedule-restriction {
|
||
|
|
text-align: center;
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.warning {
|
||
|
|
color: #856404;
|
||
|
|
background: #fff3cd;
|
||
|
|
border: 1px solid #ffeaa7;
|
||
|
|
padding: 15px;
|
||
|
|
border-radius: 6px;
|
||
|
|
margin: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-selection h4,
|
||
|
|
.time-selection h4 {
|
||
|
|
margin: 20px 0 15px 0;
|
||
|
|
color: #333;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-grid {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||
|
|
gap: 10px;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-btn {
|
||
|
|
padding: 10px 5px;
|
||
|
|
border: 1px solid #ddd;
|
||
|
|
border-radius: 6px;
|
||
|
|
background: white;
|
||
|
|
cursor: pointer;
|
||
|
|
text-align: center;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-btn:hover {
|
||
|
|
border-color: #007bff;
|
||
|
|
background: #f8f9ff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-btn.active {
|
||
|
|
background: #007bff;
|
||
|
|
color: white;
|
||
|
|
border-color: #007bff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.day-name {
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: bold;
|
||
|
|
margin-bottom: 2px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.day-number {
|
||
|
|
font-size: 16px;
|
||
|
|
font-weight: bold;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slots {
|
||
|
|
display: grid;
|
||
|
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slot {
|
||
|
|
padding: 15px;
|
||
|
|
border: 1px solid #ddd;
|
||
|
|
border-radius: 6px;
|
||
|
|
background: white;
|
||
|
|
cursor: pointer;
|
||
|
|
text-align: left;
|
||
|
|
transition: all 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slot:hover {
|
||
|
|
border-color: #007bff;
|
||
|
|
background: #f8f9ff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slot.selected {
|
||
|
|
background: #007bff;
|
||
|
|
color: white;
|
||
|
|
border-color: #007bff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slot .time {
|
||
|
|
font-weight: bold;
|
||
|
|
margin-bottom: 5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slot .availability {
|
||
|
|
font-size: 12px;
|
||
|
|
opacity: 0.8;
|
||
|
|
}
|
||
|
|
|
||
|
|
.modal-footer {
|
||
|
|
padding: 20px;
|
||
|
|
border-top: 1px solid #eee;
|
||
|
|
display: flex;
|
||
|
|
gap: 10px;
|
||
|
|
justify-content: flex-end;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Booking Cards Styles */
|
||
|
|
.my-bookings {
|
||
|
|
max-width: 800px;
|
||
|
|
margin: 0 auto;
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bookings-list {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-card {
|
||
|
|
border: 1px solid #ddd;
|
||
|
|
border-radius: 8px;
|
||
|
|
padding: 20px;
|
||
|
|
background: white;
|
||
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-header {
|
||
|
|
display: flex;
|
||
|
|
justify-content: space-between;
|
||
|
|
align-items: flex-start;
|
||
|
|
margin-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-date .date {
|
||
|
|
font-size: 18px;
|
||
|
|
font-weight: bold;
|
||
|
|
color: #333;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-date .time {
|
||
|
|
font-size: 16px;
|
||
|
|
color: #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-status {
|
||
|
|
display: flex;
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: flex-end;
|
||
|
|
gap: 5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status {
|
||
|
|
padding: 4px 8px;
|
||
|
|
border-radius: 4px;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: bold;
|
||
|
|
text-transform: uppercase;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-scheduled {
|
||
|
|
background: #d4edda;
|
||
|
|
color: #155724;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-completed {
|
||
|
|
background: #cce5ff;
|
||
|
|
color: #004085;
|
||
|
|
}
|
||
|
|
|
||
|
|
.status-cancelled {
|
||
|
|
background: #f8d7da;
|
||
|
|
color: #721c24;
|
||
|
|
}
|
||
|
|
|
||
|
|
.payment-status {
|
||
|
|
padding: 2px 6px;
|
||
|
|
border-radius: 3px;
|
||
|
|
font-size: 11px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.payment-success {
|
||
|
|
background: #d4edda;
|
||
|
|
color: #155724;
|
||
|
|
}
|
||
|
|
|
||
|
|
.payment-pending {
|
||
|
|
background: #fff3cd;
|
||
|
|
color: #856404;
|
||
|
|
}
|
||
|
|
|
||
|
|
.payment-failed {
|
||
|
|
background: #f8d7da;
|
||
|
|
color: #721c24;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-details {
|
||
|
|
margin-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.detail-item {
|
||
|
|
margin-bottom: 8px;
|
||
|
|
color: #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.meeting-link {
|
||
|
|
color: #007bff;
|
||
|
|
text-decoration: none;
|
||
|
|
font-weight: bold;
|
||
|
|
}
|
||
|
|
|
||
|
|
.meeting-link:hover {
|
||
|
|
text-decoration: underline;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-actions {
|
||
|
|
display: flex;
|
||
|
|
gap: 10px;
|
||
|
|
align-items: center;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-actions {
|
||
|
|
color: #666;
|
||
|
|
font-style: italic;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-outline-primary {
|
||
|
|
background: transparent;
|
||
|
|
color: #007bff;
|
||
|
|
border: 1px solid #007bff;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-outline-primary:hover {
|
||
|
|
background: #007bff;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-outline-danger {
|
||
|
|
background: transparent;
|
||
|
|
color: #dc3545;
|
||
|
|
border: 1px solid #dc3545;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-outline-danger:hover {
|
||
|
|
background: #dc3545;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.btn-sm {
|
||
|
|
padding: 6px 12px;
|
||
|
|
font-size: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.error-message {
|
||
|
|
background: #f8d7da;
|
||
|
|
color: #721c24;
|
||
|
|
padding: 10px;
|
||
|
|
border-radius: 4px;
|
||
|
|
margin-bottom: 15px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.loading {
|
||
|
|
text-align: center;
|
||
|
|
padding: 20px;
|
||
|
|
color: #666;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-slots {
|
||
|
|
text-align: center;
|
||
|
|
color: #666;
|
||
|
|
font-style: italic;
|
||
|
|
padding: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-bookings {
|
||
|
|
text-align: center;
|
||
|
|
padding: 40px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.no-bookings p {
|
||
|
|
color: #666;
|
||
|
|
margin-bottom: 20px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Responsive Design */
|
||
|
|
@media (max-width: 768px) {
|
||
|
|
.reschedule-modal {
|
||
|
|
width: 95%;
|
||
|
|
margin: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-header {
|
||
|
|
flex-direction: column;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-status {
|
||
|
|
align-items: flex-start;
|
||
|
|
}
|
||
|
|
|
||
|
|
.date-grid {
|
||
|
|
grid-template-columns: repeat(auto-fill, minmax(50px, 1fr));
|
||
|
|
}
|
||
|
|
|
||
|
|
.time-slots {
|
||
|
|
grid-template-columns: 1fr;
|
||
|
|
}
|
||
|
|
|
||
|
|
.booking-actions {
|
||
|
|
flex-direction: column;
|
||
|
|
align-items: stretch;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Advanced Features
|
||
|
|
|
||
|
|
### 1. Bulk Rescheduling (Admin Feature)
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
const BulkReschedule = () => {
|
||
|
|
const [selectedBookings, setSelectedBookings] = useState([]);
|
||
|
|
const [newScheduleId, setNewScheduleId] = useState('');
|
||
|
|
|
||
|
|
const handleBulkReschedule = async () => {
|
||
|
|
const results = await Promise.allSettled(
|
||
|
|
selectedBookings.map(bookingId =>
|
||
|
|
apiService.rescheduleBooking(bookingId, newScheduleId)
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||
|
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||
|
|
|
||
|
|
alert(`Rescheduled ${successful} bookings successfully. ${failed} failed.`);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bulk-reschedule">
|
||
|
|
{/* Implementation for bulk operations */}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Rescheduling with Conflict Detection
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
const detectScheduleConflicts = (bookings, newScheduleTime) => {
|
||
|
|
return bookings.filter(booking => {
|
||
|
|
const bookingTime = new Date(booking.scheduled_at);
|
||
|
|
const newTime = new Date(newScheduleTime);
|
||
|
|
|
||
|
|
// Check for time conflicts (within 1 hour)
|
||
|
|
const timeDiff = Math.abs(bookingTime - newTime);
|
||
|
|
return timeDiff < 60 * 60 * 1000; // 1 hour in milliseconds
|
||
|
|
});
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
### 3. Rescheduling History Tracking
|
||
|
|
|
||
|
|
```jsx
|
||
|
|
const ReschedulingHistory = ({ bookingId }) => {
|
||
|
|
const [history, setHistory] = useState([]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
// Fetch rescheduling history for the booking
|
||
|
|
fetchReschedulingHistory(bookingId);
|
||
|
|
}, [bookingId]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="rescheduling-history">
|
||
|
|
<h4>Rescheduling History</h4>
|
||
|
|
{history.map((entry, index) => (
|
||
|
|
<div key={index} className="history-entry">
|
||
|
|
<div>From: {format(new Date(entry.old_time), 'MMM dd, yyyy HH:mm')}</div>
|
||
|
|
<div>To: {format(new Date(entry.new_time), 'MMM dd, yyyy HH:mm')}</div>
|
||
|
|
<div>Changed: {format(new Date(entry.changed_at), 'MMM dd, yyyy HH:mm')}</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing Rescheduling
|
||
|
|
|
||
|
|
### Postman Test Scenarios
|
||
|
|
|
||
|
|
1. **Successful Rescheduling**:
|
||
|
|
```javascript
|
||
|
|
// Test script for successful reschedule
|
||
|
|
pm.test("Booking rescheduled successfully", function () {
|
||
|
|
pm.response.to.have.status(200);
|
||
|
|
const response = pm.response.json();
|
||
|
|
pm.expect(response.message).to.include("rescheduled successfully");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
2. **Time Constraint Violation**:
|
||
|
|
```javascript
|
||
|
|
// Test rescheduling too close to appointment time
|
||
|
|
pm.test("Cannot reschedule within 2 hours", function () {
|
||
|
|
pm.response.to.have.status(400);
|
||
|
|
const response = pm.response.json();
|
||
|
|
pm.expect(response.error).to.include("2 hours before");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
3. **Unauthorized Rescheduling**:
|
||
|
|
```javascript
|
||
|
|
// Test rescheduling someone else's booking
|
||
|
|
pm.test("Cannot reschedule other user's booking", function () {
|
||
|
|
pm.response.to.have.status(403);
|
||
|
|
const response = pm.response.json();
|
||
|
|
pm.expect(response.error).to.include("your own bookings");
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
1. **Always validate time constraints** before allowing rescheduling
|
||
|
|
2. **Show clear error messages** for different failure scenarios
|
||
|
|
3. **Provide visual feedback** during the rescheduling process
|
||
|
|
4. **Automatically refresh** booking lists after successful operations
|
||
|
|
5. **Handle edge cases** like fully booked slots gracefully
|
||
|
|
6. **Implement optimistic updates** where appropriate
|
||
|
|
7. **Track rescheduling history** for audit purposes
|
||
|
|
8. **Send notifications** for successful rescheduling
|
||
|
|
9. **Clean up resources** (Jitsi rooms, reminders) properly
|
||
|
|
10. **Test thoroughly** with different user scenarios
|
||
|
|
|
||
|
|
This comprehensive rescheduling system provides a smooth user experience while maintaining business rules and data integrity.
|