feat: edit request time blockers, today closing time, UI polish, and test fixes
- Add time blocker management for booking edit requests - Add closing_time field to admin today/current-next endpoint - Update UserBookingModal and CurrentAppointment UI components - Fix fmt import in bookings_test.go (was missing) - Fix created_by FK in TestAdminApproveEditRequest_TimeBlockerOverlap - Update test coverage for edit request time blocker overlap - Update gap backlog documentation
This commit is contained in:
@@ -351,6 +351,70 @@
|
||||
|
||||
async function submitReschedule() {
|
||||
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
|
||||
|
||||
// Re-fetch available hours to confirm slot is still open
|
||||
try {
|
||||
const dateStr = rescheduleDate.toString();
|
||||
const monthKey = `${rescheduleDate.year}-${String(rescheduleDate.month).padStart(2, '0')}`;
|
||||
const startOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, 1);
|
||||
const endOfMonth = new CalendarDate(rescheduleDate.year, rescheduleDate.month, rescheduleDate.calendar.getDaysInMonth(rescheduleDate));
|
||||
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`),
|
||||
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
const freshWH: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
|
||||
whData.forEach((d) => { freshWH[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
|
||||
const freshAH: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
|
||||
ahData.forEach((d) => { freshAH[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
|
||||
|
||||
const dayWH = freshWH[dateStr];
|
||||
const dayAH = freshAH[dateStr];
|
||||
|
||||
if (!dayWH?.isOpen || !dayAH?.slots) {
|
||||
toast.error('This date is no longer available. Please select a different date.');
|
||||
rescheduleDate = undefined;
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the selected time is still available
|
||||
const freshSlots: string[] = [];
|
||||
for (const slot of dayAH.slots) {
|
||||
const [sh, sm] = slot.startTime.split(':').map(Number);
|
||||
const [eh, em] = slot.endTime.split(':').map(Number);
|
||||
for (let m = sh * 60 + sm; m < eh * 60 + em; m += 15) {
|
||||
if (m + totalDuration <= eh * 60 + em) {
|
||||
freshSlots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!freshSlots.includes(rescheduleTime)) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check lunch protection
|
||||
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
||||
const freshLunch = getLunchProtectionForSlots(dayWH.startTime, dayWH.endTime, existingBookings, totalDuration, 15, false);
|
||||
if (freshLunch.get(rescheduleTime)?.isBlocked) {
|
||||
toast.error('This time slot is no longer available. Please choose a different time.');
|
||||
rescheduleTime = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('Could not verify slot availability. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
rescheduleSubmitting = true;
|
||||
try {
|
||||
const [hours, minutes] = rescheduleTime.split(':').map(Number);
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
|
||||
let currentAppointment = $state<Booking | null>(null);
|
||||
let nextAppointment = $state<Booking | null>(null);
|
||||
let closingTime = $state<string | null>(null); // "HH:MM" format
|
||||
let freeTimeAfter = $state(0); // minutes of free time after current/next appointment
|
||||
let freeTimeCapped = $state(false); // true if free time extends to/past closing
|
||||
let loading = $state(true);
|
||||
let timeRemaining = $state(0); // minutes remaining in current appointment
|
||||
let isInProgress = $state(false);
|
||||
@@ -52,48 +54,53 @@
|
||||
isInProgress = currentAppointment.status === 'in_progress';
|
||||
const startTime = new SvelteDate(currentAppointment.start_time);
|
||||
|
||||
if (isInProgress) {
|
||||
// Appointment is in progress - show time remaining until end
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
|
||||
// If current time is before start time, show time until start
|
||||
if (isInProgress) {
|
||||
if (now.getTime() < startTime.getTime()) {
|
||||
const timeUntilMs = startTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
||||
} else {
|
||||
// Otherwise show time until end
|
||||
const remainingMs = endTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(remainingMs / 60000));
|
||||
}
|
||||
|
||||
// Calculate free time until next appointment
|
||||
if (nextAppointment) {
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
|
||||
} else {
|
||||
freeTimeAfter = 0;
|
||||
}
|
||||
} else {
|
||||
// Appointment is upcoming - show time until start
|
||||
const timeUntilMs = startTime.getTime() - now.getTime();
|
||||
timeRemaining = Math.max(0, Math.floor(timeUntilMs / 60000));
|
||||
freeTimeAfter = 0;
|
||||
}
|
||||
|
||||
// If there's a next appointment, calculate free time after this one ends
|
||||
if (nextAppointment) {
|
||||
const endTime = new SvelteDate(
|
||||
startTime.getTime() + currentAppointment.duration_minutes * 60 * 1000
|
||||
);
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
freeTimeAfter = Math.max(0, Math.floor(gapMs / 60000));
|
||||
// Calculate free time after this appointment
|
||||
let rawFreeMinutes = 0;
|
||||
if (nextAppointment) {
|
||||
const nextStart = new SvelteDate(nextAppointment.start_time);
|
||||
const gapMs = nextStart.getTime() - endTime.getTime();
|
||||
rawFreeMinutes = Math.max(0, Math.floor(gapMs / 60000));
|
||||
}
|
||||
|
||||
// Cap at closing time
|
||||
if (closingTime) {
|
||||
const [ch, cm] = closingTime.split(':').map(Number);
|
||||
const today = new SvelteDate();
|
||||
const closing = new SvelteDate(today.getFullYear(), today.getMonth() + 1, today.getDate(), ch, cm, 0);
|
||||
const minutesToClosing = Math.max(0, Math.floor((closing.getTime() - endTime.getTime()) / 60000));
|
||||
|
||||
if (!nextAppointment) {
|
||||
// No next appointment — show time until closing
|
||||
freeTimeAfter = minutesToClosing;
|
||||
freeTimeCapped = true;
|
||||
} else if (rawFreeMinutes > minutesToClosing) {
|
||||
// Next appointment is after closing — cap at closing
|
||||
freeTimeAfter = minutesToClosing;
|
||||
freeTimeCapped = true;
|
||||
} else {
|
||||
freeTimeAfter = rawFreeMinutes;
|
||||
freeTimeCapped = false;
|
||||
}
|
||||
} else {
|
||||
freeTimeAfter = nextAppointment ? rawFreeMinutes : 0;
|
||||
freeTimeCapped = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +120,7 @@
|
||||
const data = await response.json();
|
||||
currentAppointment = data.current || null;
|
||||
nextAppointment = data.next || null;
|
||||
closingTime = data.closing_time || null;
|
||||
calculateTimes();
|
||||
} else {
|
||||
toast.error('Failed to load current appointment');
|
||||
@@ -203,10 +211,16 @@
|
||||
</span>
|
||||
In Progress • {timeRemaining} min remaining
|
||||
</Badge>
|
||||
{#if freeTimeAfter > 0 && timeRemaining > 29}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
{freeTimeAfter} min free afterwards
|
||||
</Badge>
|
||||
{#if timeRemaining > 29}
|
||||
{#if freeTimeCapped && freeTimeAfter === 0}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
closing after
|
||||
</Badge>
|
||||
{:else if freeTimeAfter > 0}
|
||||
<Badge class="bg-blue-100 px-3 py-1 text-sm text-blue-800">
|
||||
{freeTimeAfter} min free afterwards{#if freeTimeCapped} (closing after){/if}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user