time picker changes
This commit is contained in:
@@ -413,17 +413,30 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "start and end query params required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
|
||||
// Load UK timezone
|
||||
ukLocation, err := time.LoadLocation("Europe/London")
|
||||
if err != nil {
|
||||
http.Error(w, "failed to load timezone", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse dates in UK timezone
|
||||
start, err := time.ParseInLocation("2006-01-02", startStr, ukLocation)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid start date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
end, err := time.ParseInLocation("2006-01-02", endStr, ukLocation)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid end date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set to start of day (00:00:00) and end of day (23:59:59)
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation)
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
|
||||
|
||||
// Load default hours
|
||||
defaultMap := map[int]DefaultHours{}
|
||||
defRows, _ := db.DB.Query(r.Context(), `
|
||||
@@ -480,30 +493,36 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Load bookings with their total duration (sum of all services)
|
||||
bookingRows, _ := db.DB.Query(r.Context(), `
|
||||
SELECT
|
||||
b.start_time,
|
||||
COALESCE(SUM(s.duration_minutes), 0) as total_duration
|
||||
FROM bookings b
|
||||
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
||||
LEFT JOIN services s ON bs.service_id = s.id
|
||||
WHERE b.start_time >= $1
|
||||
AND b.start_time < $2 + INTERVAL '1 day'
|
||||
AND b.status IN ('confirmed', 'pending')
|
||||
GROUP BY b.id, b.start_time
|
||||
ORDER BY b.start_time
|
||||
`, start, end)
|
||||
bookingRows, err := db.DB.Query(r.Context(), `
|
||||
SELECT
|
||||
b.start_time,
|
||||
COALESCE(SUM(s.duration_minutes), 0) as total_duration
|
||||
FROM bookings b
|
||||
LEFT JOIN booking_services bs ON b.id = bs.booking_id
|
||||
LEFT JOIN services s ON bs.service_id = s.id
|
||||
WHERE b.start_time >= $1
|
||||
AND b.start_time <= $2
|
||||
GROUP BY b.id, b.start_time
|
||||
ORDER BY b.start_time
|
||||
`, start, end)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "failed to query bookings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
bookings := map[string][]TimeSlot{} // date -> booked slots
|
||||
for bookingRows.Next() {
|
||||
var startTime time.Time
|
||||
var durationMinutes int
|
||||
if err := bookingRows.Scan(&startTime, &durationMinutes); err == nil {
|
||||
dateStr := startTime.Format("2006-01-02")
|
||||
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
|
||||
// Convert to UK timezone for date formatting
|
||||
startTimeUK := startTime.In(ukLocation)
|
||||
dateStr := startTimeUK.Format("2006-01-02")
|
||||
endTime := startTimeUK.Add(time.Duration(durationMinutes) * time.Minute)
|
||||
|
||||
bookings[dateStr] = append(bookings[dateStr], TimeSlot{
|
||||
StartTime: startTime.Format("15:04"),
|
||||
StartTime: startTimeUK.Format("15:04"),
|
||||
EndTime: endTime.Format("15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,17 +81,30 @@
|
||||
}
|
||||
];
|
||||
|
||||
// Working hours state
|
||||
// Working hours and available hours state
|
||||
let workingHours = $state<Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> | null>(null);
|
||||
|
||||
let availableHours = $state<Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
> | null>(null);
|
||||
|
||||
let loadingWorkingHours = $state<boolean>(false);
|
||||
let loadingAvailableHours = $state<boolean>(false);
|
||||
|
||||
const workingHoursCache = new Map<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
>();
|
||||
|
||||
const availableHoursCache = new Map<
|
||||
string,
|
||||
Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }>
|
||||
>();
|
||||
|
||||
// Initialize date boundaries
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
@@ -99,7 +112,7 @@
|
||||
const maxDate = new Date();
|
||||
maxDate.setMonth(today.getMonth() + 6);
|
||||
|
||||
// Create CalendarDate objects directly without toCalendar conversion
|
||||
// Create CalendarDate objects
|
||||
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
|
||||
const tomorrowDate = new CalendarDate(
|
||||
tomorrow.getFullYear(),
|
||||
@@ -111,26 +124,32 @@
|
||||
maxDate.getMonth() + 1,
|
||||
maxDate.getDate()
|
||||
);
|
||||
|
||||
let placeholder = $state<CalendarDate>(tomorrowDate);
|
||||
|
||||
$effect(() => {
|
||||
const monthKey = `${placeholder.year}-${String(placeholder.month).padStart(2, '0')}`;
|
||||
if (!workingHoursCache.has(monthKey)) {
|
||||
fetchWorkingHoursForMonth(placeholder);
|
||||
if (!workingHoursCache.has(monthKey) || !availableHoursCache.has(monthKey)) {
|
||||
fetchHoursForMonth(placeholder);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch working hours for a given month
|
||||
async function fetchWorkingHoursForMonth(date: CalendarDate) {
|
||||
// Fetch both working hours and available hours for a given month
|
||||
async function fetchHoursForMonth(date: CalendarDate) {
|
||||
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
|
||||
if (workingHoursCache.has(monthKey)) {
|
||||
// Use a timeout to ensure state updates properly
|
||||
|
||||
// Use cached data if available
|
||||
if (workingHoursCache.has(monthKey) && availableHoursCache.has(monthKey)) {
|
||||
setTimeout(() => {
|
||||
workingHours = workingHoursCache.get(monthKey)!;
|
||||
availableHours = availableHoursCache.get(monthKey)!;
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
loadingWorkingHours = true;
|
||||
loadingAvailableHours = true;
|
||||
|
||||
try {
|
||||
// Calculate start and end of month
|
||||
const startOfMonth = new CalendarDate(date.year, date.month, 1);
|
||||
@@ -143,9 +162,12 @@
|
||||
const startStr = startOfMonth.toString();
|
||||
const endStr = endOfMonth.toString();
|
||||
|
||||
const response = await fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
// Fetch working hours
|
||||
const workingHoursResponse = await fetch(
|
||||
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!workingHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${workingHoursResponse.status}`);
|
||||
}
|
||||
|
||||
type WorkingHoursDay = {
|
||||
@@ -157,43 +179,78 @@
|
||||
source: string;
|
||||
};
|
||||
|
||||
const data: Array<WorkingHoursDay> = await response.json();
|
||||
const workingHoursData: Array<WorkingHoursDay> = await workingHoursResponse.json();
|
||||
const workingHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; startTime: string; endTime: string }
|
||||
> = {};
|
||||
|
||||
const hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
data.forEach((day) => {
|
||||
hoursMap[day.date] = {
|
||||
workingHoursData.forEach((day) => {
|
||||
workingHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
startTime: day.startTime,
|
||||
endTime: day.endTime
|
||||
};
|
||||
});
|
||||
|
||||
workingHoursCache.set(monthKey, hoursMap);
|
||||
workingHours = hoursMap;
|
||||
workingHoursCache.set(monthKey, workingHoursMap);
|
||||
workingHours = workingHoursMap;
|
||||
|
||||
// Fetch available hours
|
||||
const availableHoursResponse = await fetch(
|
||||
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`
|
||||
);
|
||||
if (!availableHoursResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${availableHoursResponse.status}`);
|
||||
}
|
||||
|
||||
type AvailableHoursDay = {
|
||||
date: string;
|
||||
weekday: number;
|
||||
isOpen: boolean;
|
||||
slots: Array<{ startTime: string; endTime: string }>;
|
||||
source: string;
|
||||
};
|
||||
|
||||
const availableHoursData: Array<AvailableHoursDay> = await availableHoursResponse.json();
|
||||
const availableHoursMap: Record<
|
||||
string,
|
||||
{ isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }
|
||||
> = {};
|
||||
|
||||
availableHoursData.forEach((day) => {
|
||||
availableHoursMap[day.date] = {
|
||||
isOpen: day.isOpen,
|
||||
slots: day.slots
|
||||
};
|
||||
});
|
||||
|
||||
availableHoursCache.set(monthKey, availableHoursMap);
|
||||
availableHours = availableHoursMap;
|
||||
|
||||
// Set default selected date if not set
|
||||
if (!selectedDate) {
|
||||
setDefaultSelectedDate(hoursMap);
|
||||
setDefaultSelectedDate(workingHoursMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch working hours:', error);
|
||||
console.error('Failed to fetch hours:', error);
|
||||
// Fallback to current date if API fails
|
||||
if (!selectedDate) {
|
||||
selectedDate = minDate;
|
||||
}
|
||||
} finally {
|
||||
loadingWorkingHours = false;
|
||||
loadingAvailableHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default selected date to next available working day (starting from tomorrow)
|
||||
// Set default selected date to next available working day
|
||||
function setDefaultSelectedDate(
|
||||
hoursMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }>
|
||||
) {
|
||||
const currentDate = new Date();
|
||||
let nextDate = new Date(currentDate);
|
||||
|
||||
// Check next 30 days for available working day (starting from tomorrow, i=1)
|
||||
for (let i = 1; i < 30; i++) {
|
||||
nextDate = new Date(currentDate);
|
||||
nextDate.setDate(currentDate.getDate() + i);
|
||||
@@ -209,7 +266,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to tomorrow if no working day found
|
||||
if (!selectedDate) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
@@ -223,36 +279,23 @@
|
||||
|
||||
/**
|
||||
* Calculates the end time based on a 24-hour start time and a duration in minutes.
|
||||
* @param startTime - Time string in "HH:MM:SS" format (e.g., "09:00:00").
|
||||
* @param durationMinutes - The total duration of the service in minutes.
|
||||
* @returns Time string in "HH:MM" 24-hour format (e.g., "10:30").
|
||||
*/
|
||||
function calculateEndTime(startTime: string, durationMinutes: number): string {
|
||||
const [hours, minutes] = startTime.split(':').map(Number);
|
||||
|
||||
// Create a fixed date object to manipulate the time
|
||||
const date = new Date();
|
||||
// Set time on the fixed date
|
||||
date.setHours(hours, minutes, 0, 0);
|
||||
|
||||
// Add duration in minutes
|
||||
date.setMinutes(date.getMinutes() + durationMinutes);
|
||||
|
||||
// Format back to HH:MM (24-hour format)
|
||||
const endHours = date.getHours().toString().padStart(2, '0');
|
||||
const endMinutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
return `${endHours}:${endMinutes}`;
|
||||
}
|
||||
|
||||
/** Format time from HH:MM:SS (or HH:MM) to human-readable 12-hour format, with "Noon" for 12:00 PM */
|
||||
/** Format time from HH:MM:SS to human-readable 12-hour format */
|
||||
function formatTime(time: string): string {
|
||||
// Handle cases where seconds might be present (HH:MM:SS) or not (HH:MM)
|
||||
const parts = time.split(':').map(Number);
|
||||
const hours = parts[0];
|
||||
const minutes = parts.length > 1 ? parts[1] : 0;
|
||||
|
||||
// Special case for 12:00
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
@@ -265,19 +308,10 @@
|
||||
}
|
||||
|
||||
// Initialize with current month
|
||||
fetchWorkingHoursForMonth(tomorrowDate);
|
||||
fetchHoursForMonth(tomorrowDate);
|
||||
|
||||
// Handle calendar month change
|
||||
function handleMonthChange(newMonth: CalendarDate) {
|
||||
// Only fetch if within 6 months range
|
||||
if (newMonth.compare(maxCalendarDate) <= 0) {
|
||||
fetchWorkingHoursForMonth(newMonth);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if date is unavailable - updated to handle DateValue type
|
||||
// Check if date is unavailable
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
// Only CalendarDate has compare method
|
||||
if (!(date instanceof CalendarDate)) {
|
||||
return true;
|
||||
}
|
||||
@@ -302,70 +336,182 @@
|
||||
// Check if there are any available time slots for the selected services
|
||||
if (selectedServices.length > 0) {
|
||||
const duration = getTotalDuration();
|
||||
const slots = generateTimeSlots(duration, date);
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
// If no slots available for the required duration, mark as unavailable
|
||||
if (slots.length === 0) return true;
|
||||
if (availableSlots.length === 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function generateTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
|
||||
// Use the new grouped time slots generation for visual display
|
||||
const groupedTimeSlots = $derived(
|
||||
selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
|
||||
// NEW: Generate grouped time slots (available as individual buttons, unavailable as grouped blocks)
|
||||
function generateGroupedTimeSlots(
|
||||
duration: number,
|
||||
date: CalendarDate | undefined
|
||||
): Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isGrouped?: boolean;
|
||||
}> {
|
||||
if (!date || !workingHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayHours = workingHours[dateStr];
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
|
||||
if (!dayHours || !dayHours.isOpen) {
|
||||
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
|
||||
const [startHour, startMinute] = dayHours.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayHours.endTime.split(':').map(Number);
|
||||
const groupedSlots: Array<{
|
||||
type: 'available' | 'unavailable';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isGrouped?: boolean;
|
||||
}> = [];
|
||||
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
|
||||
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
// Apply 2-hour buffer for today's appointments
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120; // Current time + 2 hours
|
||||
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
// Get available time slots that fit our duration
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
|
||||
let currentUnavailableStart: string | null = null;
|
||||
|
||||
// Generate all 15-minute increments within working hours
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const slotEndMinutes = minutes + duration;
|
||||
|
||||
if (slotEndMinutes > endTotalMinutes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
slots.push(timeStr);
|
||||
|
||||
// Check if this time slot is available (fits duration and within available hours)
|
||||
const isAvailable = availableSlots.includes(timeStr);
|
||||
|
||||
if (isAvailable) {
|
||||
// If we were building an unavailable group, push it first
|
||||
if (currentUnavailableStart !== null) {
|
||||
const groupEndTime = calculatePreviousTime(timeStr); // End before this available slot
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: currentUnavailableStart,
|
||||
endTime: groupEndTime,
|
||||
isGrouped: true
|
||||
});
|
||||
currentUnavailableStart = null;
|
||||
}
|
||||
|
||||
// Add available slot
|
||||
const slotEndTime = calculateEndTime(timeStr, duration);
|
||||
groupedSlots.push({
|
||||
type: 'available',
|
||||
startTime: timeStr,
|
||||
endTime: slotEndTime
|
||||
});
|
||||
} else {
|
||||
// Start or continue unavailable group
|
||||
if (currentUnavailableStart === null) {
|
||||
currentUnavailableStart = timeStr;
|
||||
}
|
||||
// Continue the group - we'll close it when we hit an available slot or end
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining unavailable group at the end of the day
|
||||
if (currentUnavailableStart !== null) {
|
||||
groupedSlots.push({
|
||||
type: 'unavailable',
|
||||
startTime: currentUnavailableStart,
|
||||
endTime: dayWorkingHours.endTime,
|
||||
isGrouped: true
|
||||
});
|
||||
}
|
||||
|
||||
return groupedSlots;
|
||||
}
|
||||
|
||||
// Helper: Calculate the previous 15-minute time slot
|
||||
function calculatePreviousTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
let totalMinutes = hours * 60 + minutes;
|
||||
totalMinutes -= 15; // Go back 15 minutes
|
||||
|
||||
const prevHours = Math.floor(totalMinutes / 60);
|
||||
const prevMinutes = totalMinutes % 60;
|
||||
return `${String(prevHours).padStart(2, '0')}:${String(prevMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// NEW: Generate available time slots within available segments
|
||||
function generateAvailableTimeSlots(duration: number, date: CalendarDate | undefined): string[] {
|
||||
if (!date || !workingHours || !availableHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateStr = date.toString();
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
const dayAvailableHours = availableHours[dateStr];
|
||||
|
||||
if (!dayWorkingHours || !dayWorkingHours.isOpen || !dayAvailableHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slots: string[] = [];
|
||||
const now = new Date();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
for (const slot of dayAvailableHours.slots) {
|
||||
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
|
||||
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
// Apply 2-hour buffer for today's appointments
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
// Generate 15-minute increments within this available slot
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const slotEndMinutes = minutes + duration;
|
||||
|
||||
// Check if the full duration fits within the available slot
|
||||
if (slotEndMinutes <= endTotalMinutes) {
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
slots.push(timeStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
// Generate time slots based on selected services and date
|
||||
const timeSlots = $derived(
|
||||
selectedServices.length > 0 && selectedDate
|
||||
? generateTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
|
||||
function getTotalDuration() {
|
||||
return selectedServices.reduce((total, service) => total + service.duration, 0);
|
||||
}
|
||||
@@ -376,40 +522,90 @@
|
||||
|
||||
function toggleService(service: any) {
|
||||
const index = selectedServices.findIndex((s) => s.id === service.id);
|
||||
if (index >= 0) {
|
||||
const wasSelected = index >= 0;
|
||||
|
||||
if (wasSelected) {
|
||||
selectedServices = selectedServices.filter((s) => s.id !== service.id);
|
||||
} else {
|
||||
selectedServices = [...selectedServices, service];
|
||||
}
|
||||
// Reset time selection when services change
|
||||
|
||||
// Clear selected date and time when services change, as they may no longer be valid
|
||||
selectedTime = null;
|
||||
selectedDate = undefined;
|
||||
|
||||
// Re-fetch available hours when services change and we're on step 2
|
||||
if (currentStep === 2) {
|
||||
// Clear the cache to force refetch with new duration
|
||||
availableHoursCache.clear();
|
||||
if (selectedDate) {
|
||||
fetchHoursForMonth(selectedDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isServiceSelected(service: any) {
|
||||
return selectedServices.some((s) => s.id === service.id);
|
||||
}
|
||||
|
||||
function isTimeInDuration(
|
||||
time: string,
|
||||
selectedTime: string | null,
|
||||
totalDuration: number
|
||||
): 'start' | 'duration' | 'none' {
|
||||
if (!selectedTime) return 'none';
|
||||
if (time === selectedTime) return 'start';
|
||||
const allTimeSlots = $derived(
|
||||
selectedServices.length > 0 && selectedDate
|
||||
? generateAllTimeSlots(getTotalDuration(), selectedDate)
|
||||
: []
|
||||
);
|
||||
|
||||
const [selectedHour, selectedMin] = selectedTime.split(':').map(Number);
|
||||
const [currentHour, currentMin] = time.split(':').map(Number);
|
||||
|
||||
const selectedMinutes = selectedHour * 60 + selectedMin;
|
||||
const currentMinutes = currentHour * 60 + currentMin;
|
||||
|
||||
const endMinutes = selectedMinutes + totalDuration;
|
||||
|
||||
if (currentMinutes > selectedMinutes && currentMinutes < endMinutes) {
|
||||
return 'duration';
|
||||
function generateAllTimeSlots(
|
||||
duration: number,
|
||||
date: CalendarDate | undefined
|
||||
): Array<{ time: string; type: 'available' | 'unavailable' }> {
|
||||
if (!date || !workingHours) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return 'none';
|
||||
const dateStr = date.toString();
|
||||
const dayWorkingHours = workingHours[dateStr];
|
||||
|
||||
if (!dayWorkingHours || !dayWorkingHours.isOpen) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allSlots: Array<{ time: string; type: 'available' | 'unavailable' }> = [];
|
||||
const [startHour, startMinute] = dayWorkingHours.startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = dayWorkingHours.endTime.split(':').map(Number);
|
||||
|
||||
let startTotalMinutes = startHour * 60 + startMinute;
|
||||
const endTotalMinutes = endHour * 60 + endMinute;
|
||||
|
||||
const now = new Date();
|
||||
const today = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||||
const isToday = date.compare(today) === 0;
|
||||
|
||||
// Apply 2-hour buffer for today's appointments
|
||||
if (isToday) {
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const minimumStartMinutes = currentMinutes + 120;
|
||||
startTotalMinutes = Math.max(startTotalMinutes, minimumStartMinutes);
|
||||
}
|
||||
|
||||
// Get available time slots that fit our duration
|
||||
const availableSlots = generateAvailableTimeSlots(duration, date);
|
||||
|
||||
// Generate all 15-minute increments within working hours
|
||||
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
||||
const hour = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
const timeStr = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
|
||||
// Check if this time slot is available (fits duration and within available hours)
|
||||
const isAvailable = availableSlots.includes(timeStr);
|
||||
|
||||
allSlots.push({
|
||||
time: timeStr,
|
||||
type: isAvailable ? 'available' : 'unavailable'
|
||||
});
|
||||
}
|
||||
|
||||
return allSlots;
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
@@ -427,6 +623,13 @@
|
||||
setTimeout(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, 50);
|
||||
|
||||
// Re-fetch available hours when returning to step 2
|
||||
if (currentStep === 2 && selectedDate) {
|
||||
const monthKey = `${selectedDate.year}-${String(selectedDate.month).padStart(2, '0')}`;
|
||||
availableHoursCache.delete(monthKey); // Force refetch
|
||||
fetchHoursForMonth(selectedDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,22 +789,37 @@
|
||||
<div
|
||||
class="no-scrollbar inset-y-0 right-0 flex max-h-48 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-56 md:border-l md:border-t-0"
|
||||
>
|
||||
{#if loadingWorkingHours && selectedDate}
|
||||
{#if (loadingWorkingHours || loadingAvailableHours) && selectedDate}
|
||||
<div class="text-center text-sm text-gray-500">Loading available times...</div>
|
||||
{:else if timeSlots.length > 0}
|
||||
{@const duration = getTotalDuration()}
|
||||
{:else if groupedTimeSlots.length > 0}
|
||||
<!-- Grouped Time Slots Grid - ORIGINAL STYLING BUT WITH GROUPED UNAVAILABLE SLOTS -->
|
||||
<div class="grid gap-2">
|
||||
{#each timeSlots as time (time)}
|
||||
{@const endTime = calculateEndTime(time, duration)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => (selectedTime = time)}
|
||||
class={`w-full hover:bg-fuchsia-50 ${
|
||||
time === selectedTime ? ' bg-fuchsia-200' : ''
|
||||
}`}
|
||||
>
|
||||
{formatTime(time)} <span class="text-gray-500">- {formatTime(endTime)}</span>
|
||||
</Button>
|
||||
{#each groupedTimeSlots as slot (slot.startTime)}
|
||||
{#if slot.type === 'available'}
|
||||
<!-- Available slot - individual button -->
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
selectedTime = slot.startTime;
|
||||
}}
|
||||
class={`w-full hover:bg-fuchsia-50 ${
|
||||
slot.startTime === selectedTime ? 'bg-fuchsia-200' : ''
|
||||
}`}
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{:else}
|
||||
<!-- Unavailable slot - grouped block -->
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
|
||||
disabled
|
||||
>
|
||||
{formatTime(slot.startTime)}
|
||||
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if selectedServices.length === 0}
|
||||
|
||||
Reference in New Issue
Block a user