feat: add conflict resolution UI to holiday hours modal
Add conflict detection to the Create Exception Schedule modal with auto-checking, amber warning display, and View Booking/View Client buttons. Wire openUserModal and openBookingModal props from admin page. Fix TimeBlockers placeholder duration from hardcoded 60 to booking.duration_minutes. Remove dead placeholder creation code (isFormValid prevents save while conflicts exist). Fix formatTime overwriting raw hour data with display strings.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { range } from '$lib/utils/format';
|
||||
import { range, formatDuration } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -33,6 +35,30 @@
|
||||
hours: WorkingHourRow[];
|
||||
};
|
||||
|
||||
type OverlappingBooking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
duration_minutes: number;
|
||||
status: string;
|
||||
user: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
previous_first_name?: string | null;
|
||||
previous_last_name?: string | null;
|
||||
} | null;
|
||||
services: string[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
openUserModal?: (userId: string) => void;
|
||||
openBookingModal?: (bookingId: string) => void;
|
||||
rescheduleVersion?: number;
|
||||
}
|
||||
|
||||
let { openUserModal, openBookingModal, rescheduleVersion = 0 }: Props = $props();
|
||||
|
||||
// =============== State ===============
|
||||
let exceptionGroups = $state<ExceptionGroup[]>([]);
|
||||
let exceptionGroupsLoading = $state(true);
|
||||
@@ -61,7 +87,10 @@
|
||||
});
|
||||
|
||||
const isFormValid = $derived(
|
||||
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
|
||||
exceptionDraft.name.trim() !== '' &&
|
||||
exceptionDraft.weekStarts.length > 0 &&
|
||||
!hasOverlap &&
|
||||
!checkingOverlap
|
||||
);
|
||||
|
||||
function validateNameField() {
|
||||
@@ -91,6 +120,51 @@
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// =============== Conflict Resolution State ===============
|
||||
let overlappingBookings = $state<OverlappingBooking[]>([]);
|
||||
let checkingOverlap = $state(false);
|
||||
let hasOverlap = $derived(overlappingBookings.length > 0);
|
||||
|
||||
async function checkConflictingBookings() {
|
||||
if (exceptionDraft.weekStarts.length === 0) {
|
||||
overlappingBookings = [];
|
||||
return;
|
||||
}
|
||||
|
||||
checkingOverlap = true;
|
||||
try {
|
||||
const payload = {
|
||||
weekStarts: exceptionDraft.weekStarts,
|
||||
proposedHours: exceptionDraft.hours.map((h) => ({
|
||||
weekday: h.weekday,
|
||||
startTime: h.start_time,
|
||||
endTime: h.end_time,
|
||||
isOpen: h.is_open
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await apiFetch('/api/admin/bookings/conflicting-for-exception', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
overlappingBookings = data.bookings || [];
|
||||
} else {
|
||||
overlappingBookings = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking conflicting bookings:', err);
|
||||
overlappingBookings = [];
|
||||
} finally {
|
||||
checkingOverlap = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
function weekdayLabel(i: number) {
|
||||
return dayNames[i];
|
||||
@@ -167,8 +241,8 @@
|
||||
}) => ({
|
||||
id: h.id,
|
||||
weekday: h.weekday,
|
||||
start_time: formatTime(h.startTime),
|
||||
end_time: formatTime(h.endTime),
|
||||
start_time: h.startTime,
|
||||
end_time: h.endTime,
|
||||
is_open: h.isOpen
|
||||
})
|
||||
) || []
|
||||
@@ -224,7 +298,7 @@
|
||||
await fetchExceptionGroups();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
|
||||
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating exception group:', err);
|
||||
@@ -255,7 +329,7 @@
|
||||
await fetchExceptionGroups();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
|
||||
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting exception group:', err);
|
||||
@@ -281,6 +355,7 @@
|
||||
};
|
||||
weekRangeFrom = '';
|
||||
weekRangeTo = '';
|
||||
overlappingBookings = [];
|
||||
}
|
||||
|
||||
function createNewException() {
|
||||
@@ -288,6 +363,18 @@
|
||||
showExceptionModal = true;
|
||||
}
|
||||
|
||||
// Check for conflicts whenever weekStarts, hours, or rescheduleVersion change
|
||||
$effect(() => {
|
||||
// Track these reactive values so the effect re-runs when they change
|
||||
const weekStarts = exceptionDraft.weekStarts;
|
||||
const hours = exceptionDraft.hours;
|
||||
const rv = rescheduleVersion;
|
||||
// Avoid triggering check on initial empty state
|
||||
if (weekStarts.length > 0 && showExceptionModal) {
|
||||
checkConflictingBookings();
|
||||
}
|
||||
});
|
||||
|
||||
function addWeekRange() {
|
||||
if (!weekRangeFrom || !weekRangeTo) {
|
||||
toast.error('Please select both start and end dates');
|
||||
@@ -353,7 +440,7 @@
|
||||
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
|
||||
{/if}
|
||||
|
||||
{#each exceptionGroups as g (g.weekStarts)}
|
||||
{#each exceptionGroups as g (g.id)}
|
||||
<div class="group relative h-full rounded-lg border p-4 transition-all">
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex-1">
|
||||
@@ -601,6 +688,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Resolution -->
|
||||
{#if checkingOverlap}
|
||||
<div class="flex items-center gap-2 px-4 pb-4">
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin text-gray-500"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-500">Checking for conflicting bookings…</span>
|
||||
</div>
|
||||
{:else if hasOverlap}
|
||||
<div class="mx-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-amber-600"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
/>
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800">
|
||||
{overlappingBookings.length} booking{overlappingBookings.length > 1 ? 's' : ''} conflict{overlappingBookings.length >
|
||||
1
|
||||
? ''
|
||||
: 's'} with the proposed hours
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each overlappingBookings as booking (booking.id)}
|
||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium">
|
||||
{formatUserName(
|
||||
booking.user?.full_name || 'Unknown',
|
||||
booking.user?.previous_first_name,
|
||||
booking.user?.previous_last_name
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{parseWallClockDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})}} ·
|
||||
{formatDuration(booking.duration_minutes)}
|
||||
{#if booking.services?.length}
|
||||
·
|
||||
{booking.services.join(', ')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
{#if openBookingModal}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
onclick={() => openBookingModal(booking.id)}
|
||||
>
|
||||
View Booking
|
||||
</Button>
|
||||
{/if}
|
||||
{#if openUserModal && booking.user?.id}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
onclick={() => openUserModal(booking.user!.id)}
|
||||
>
|
||||
View Client
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -705,10 +886,10 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 text-sm">
|
||||
{row.is_open ? row.start_time : '—'}
|
||||
{row.is_open ? formatTime(row.start_time) : '—'}
|
||||
</td>
|
||||
<td class="py-2 text-sm">
|
||||
{row.is_open ? row.end_time : '—'}
|
||||
{row.is_open ? formatTime(row.end_time) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { CalendarDate } from '@internationalized/date';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
||||
import { formatDuration, range } from '$lib/utils/format';
|
||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_time: startIso,
|
||||
duration_minutes: 60,
|
||||
duration_minutes: booking.duration_minutes,
|
||||
description: `RESERVATION:placeholder:${booking.id}`
|
||||
})
|
||||
});
|
||||
@@ -440,7 +440,7 @@
|
||||
await fetchBlockers();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to create: ' + sanitizeText(text), { id: loadingToast });
|
||||
toast.error('Failed to create: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating time blocker:', err);
|
||||
@@ -467,7 +467,7 @@
|
||||
await fetchBlockers();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to delete: ' + sanitizeText(text), { id: loadingToast });
|
||||
toast.error('Failed to delete: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting time blocker:', err);
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
{#if sectionState.scheduling === 'expanded'}
|
||||
<div class="space-y-4 p-4">
|
||||
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
|
||||
<HolidayHours />
|
||||
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
|
||||
<WeeklySchedule {defaultHours} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user