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">
|
<script lang="ts">
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
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 { 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
|
// shadcn-svelte components
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -33,6 +35,30 @@
|
|||||||
hours: WorkingHourRow[];
|
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 ===============
|
// =============== State ===============
|
||||||
let exceptionGroups = $state<ExceptionGroup[]>([]);
|
let exceptionGroups = $state<ExceptionGroup[]>([]);
|
||||||
let exceptionGroupsLoading = $state(true);
|
let exceptionGroupsLoading = $state(true);
|
||||||
@@ -61,7 +87,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFormValid = $derived(
|
const isFormValid = $derived(
|
||||||
exceptionDraft.name.trim() !== '' && exceptionDraft.weekStarts.length > 0
|
exceptionDraft.name.trim() !== '' &&
|
||||||
|
exceptionDraft.weekStarts.length > 0 &&
|
||||||
|
!hasOverlap &&
|
||||||
|
!checkingOverlap
|
||||||
);
|
);
|
||||||
|
|
||||||
function validateNameField() {
|
function validateNameField() {
|
||||||
@@ -91,6 +120,51 @@
|
|||||||
|
|
||||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
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 ===============
|
// =============== Helper Functions ===============
|
||||||
function weekdayLabel(i: number) {
|
function weekdayLabel(i: number) {
|
||||||
return dayNames[i];
|
return dayNames[i];
|
||||||
@@ -167,8 +241,8 @@
|
|||||||
}) => ({
|
}) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
weekday: h.weekday,
|
weekday: h.weekday,
|
||||||
start_time: formatTime(h.startTime),
|
start_time: h.startTime,
|
||||||
end_time: formatTime(h.endTime),
|
end_time: h.endTime,
|
||||||
is_open: h.isOpen
|
is_open: h.isOpen
|
||||||
})
|
})
|
||||||
) || []
|
) || []
|
||||||
@@ -224,7 +298,7 @@
|
|||||||
await fetchExceptionGroups();
|
await fetchExceptionGroups();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error creating exception group:', err);
|
console.error('Error creating exception group:', err);
|
||||||
@@ -255,7 +329,7 @@
|
|||||||
await fetchExceptionGroups();
|
await fetchExceptionGroups();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error deleting exception group:', err);
|
console.error('Error deleting exception group:', err);
|
||||||
@@ -281,6 +355,7 @@
|
|||||||
};
|
};
|
||||||
weekRangeFrom = '';
|
weekRangeFrom = '';
|
||||||
weekRangeTo = '';
|
weekRangeTo = '';
|
||||||
|
overlappingBookings = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNewException() {
|
function createNewException() {
|
||||||
@@ -288,6 +363,18 @@
|
|||||||
showExceptionModal = true;
|
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() {
|
function addWeekRange() {
|
||||||
if (!weekRangeFrom || !weekRangeTo) {
|
if (!weekRangeFrom || !weekRangeTo) {
|
||||||
toast.error('Please select both start and end dates');
|
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>
|
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
|
||||||
{/if}
|
{/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="group relative h-full rounded-lg border p-4 transition-all">
|
||||||
<div class="flex h-full flex-col gap-3">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -601,6 +688,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -705,10 +886,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 text-sm">
|
<td class="py-2 text-sm">
|
||||||
{row.is_open ? row.start_time : '—'}
|
{row.is_open ? formatTime(row.start_time) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 text-sm">
|
<td class="py-2 text-sm">
|
||||||
{row.is_open ? row.end_time : '—'}
|
{row.is_open ? formatTime(row.end_time) : '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { apiFetch } from '$lib/utils/api';
|
import { apiFetch } from '$lib/utils/api';
|
||||||
import { CalendarDate } from '@internationalized/date';
|
import { CalendarDate } from '@internationalized/date';
|
||||||
import { toast } from 'svelte-sonner';
|
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 { formatDuration, range } from '$lib/utils/format';
|
||||||
import { formatUserName } from '$lib/utils/nameDisplay';
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
|
import { formatLocalDateTime, parseWallClockDate } from '$lib/utils/timeSlots';
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
start_time: startIso,
|
start_time: startIso,
|
||||||
duration_minutes: 60,
|
duration_minutes: booking.duration_minutes,
|
||||||
description: `RESERVATION:placeholder:${booking.id}`
|
description: `RESERVATION:placeholder:${booking.id}`
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
await fetchBlockers();
|
await fetchBlockers();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error creating time blocker:', err);
|
console.error('Error creating time blocker:', err);
|
||||||
@@ -467,7 +467,7 @@
|
|||||||
await fetchBlockers();
|
await fetchBlockers();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
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) {
|
} catch (err) {
|
||||||
console.error('Error deleting time blocker:', err);
|
console.error('Error deleting time blocker:', err);
|
||||||
|
|||||||
@@ -378,7 +378,7 @@
|
|||||||
{#if sectionState.scheduling === 'expanded'}
|
{#if sectionState.scheduling === 'expanded'}
|
||||||
<div class="space-y-4 p-4">
|
<div class="space-y-4 p-4">
|
||||||
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
|
<TimeBlockers {openUserModal} {openBookingModal} {rescheduleVersion} {defaultHours} />
|
||||||
<HolidayHours />
|
<HolidayHours {openUserModal} {openBookingModal} {rescheduleVersion} />
|
||||||
<WeeklySchedule {defaultHours} />
|
<WeeklySchedule {defaultHours} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user