feat(frontend): add out-of-hours booking UI components
Add out_of_hours toggle, slot detection and badge to BookingCreateModal. Show out-of-hours badge on BookingModal detail view. Display warning banner on SelectedTimeSummary for out-of-hours slots. Highlight out-of-hours slots with red styling in TimeSlotList. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
// Note: We are using native inputs for Steps 1 and 3 to fix reactivity bugs
|
||||
// but keeping the Label and other components.
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
@@ -27,7 +28,12 @@
|
||||
import SelectedTimeSummary from '$lib/components/booking/SelectedTimeSummary.svelte';
|
||||
|
||||
// Types
|
||||
import type { Service, CustomService, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
|
||||
import type {
|
||||
Service,
|
||||
CustomService,
|
||||
WorkingHoursDay,
|
||||
AvailableHoursDay
|
||||
} from '$lib/types/booking';
|
||||
|
||||
import {
|
||||
buildLunchProtection,
|
||||
@@ -35,6 +41,7 @@
|
||||
generateGroupedTimeSlots,
|
||||
formatTime,
|
||||
calculateEndTime,
|
||||
timeToMinutes,
|
||||
getDayWithOrdinal,
|
||||
type DayHours,
|
||||
type DayAvailability
|
||||
@@ -56,7 +63,15 @@
|
||||
let userQuery = $state('');
|
||||
// Updated type to include account_role for filtering
|
||||
let users = $state<
|
||||
Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string; previousFirstName?: string | null; previousLastName?: string | null }>
|
||||
Array<{
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
account_role: string;
|
||||
previousFirstName?: string | null;
|
||||
previousLastName?: string | null;
|
||||
}>
|
||||
>([]);
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let guestName = $state('');
|
||||
@@ -74,7 +89,13 @@
|
||||
let customSearchQuery = $state('');
|
||||
let loadingCustomServices = $state(false);
|
||||
let showCustomCreateForm = $state(false);
|
||||
let newCustomService = $state({ name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' });
|
||||
let newCustomService = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
});
|
||||
let creatingCustomService = $state(false);
|
||||
let customServiceErrors = $state<Record<string, string>>({});
|
||||
|
||||
@@ -116,16 +137,22 @@
|
||||
}
|
||||
let isCustomFormValid = $derived(
|
||||
(newCustomService.name ?? '').trim() !== '' &&
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
!customServiceErrors.name &&
|
||||
!customServiceErrors.price &&
|
||||
!customServiceErrors.duration_minutes &&
|
||||
!customServiceErrors.minimum_age_required
|
||||
);
|
||||
|
||||
function toggleCustomForm(show: boolean) {
|
||||
showCustomCreateForm = show;
|
||||
if (show) {
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
requestAnimationFrame(() => {
|
||||
const modalContent = document.querySelector('[data-custom-form-container]');
|
||||
@@ -161,6 +188,34 @@
|
||||
let loadingAvailableHours = $state(false);
|
||||
let hoursRangeGeneration = $state(0);
|
||||
let hoursMonthGeneration = $state(0);
|
||||
let outOfHours = $state(false);
|
||||
// Keep the last-known normal (non-out-of-hours) working hours for per-slot styling
|
||||
let normalWorkingHours = $state<Record<string, DayHours> | null>(null);
|
||||
// Clear caches and force re-fetch when out-of-hours toggled
|
||||
let prevOutOfHours = $state(false);
|
||||
$effect(() => {
|
||||
if (outOfHours !== prevOutOfHours) {
|
||||
prevOutOfHours = outOfHours;
|
||||
console.log('[DEBUG] Out-of-hours mode TOGGLED', { now: outOfHours });
|
||||
// Save normal hours BEFORE clearing, so we can show which slots are genuinely out-of-hours
|
||||
if (outOfHours && workingHours) {
|
||||
normalWorkingHours = { ...workingHours };
|
||||
} else if (!outOfHours) {
|
||||
normalWorkingHours = null;
|
||||
}
|
||||
// Clear data and caches
|
||||
workingHoursCache = {};
|
||||
availableHoursCache = {};
|
||||
workingHours = null;
|
||||
availableHours = null;
|
||||
selectedTime = null;
|
||||
// Directly re-fetch since caches aren't reactive (plain `let` not `$state`)
|
||||
// so the safety net effect won't detect the cache deletion
|
||||
if (currentStep === 4 && placeholder) {
|
||||
fetchHoursRange(placeholder, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Date Boundaries
|
||||
const today = new SvelteDate();
|
||||
@@ -206,11 +261,56 @@
|
||||
|
||||
// =============== Lunch Protection ===============
|
||||
const lunchProtection = $derived(
|
||||
selectedDate && selectedServices.length > 0
|
||||
selectedDate && selectedServices.length > 0 && !outOfHours
|
||||
? buildLunchProtection(selectedDate, workingHours, availableHours, getTotalDuration(), true)
|
||||
: new Map()
|
||||
);
|
||||
|
||||
// =============== Debug: Slot generation ===============
|
||||
/** Check if a slot time falls outside the normal (non-out-of-hours) business hours */
|
||||
function isSlotOutOfHours(
|
||||
dateStr: string,
|
||||
timeStr: string,
|
||||
duration: number,
|
||||
normalWH: Record<string, DayHours> | null
|
||||
): boolean {
|
||||
if (!normalWH) return false;
|
||||
const normalDay = normalWH[dateStr];
|
||||
if (!normalDay) return false;
|
||||
// Day is normally closed → ALL slots are out-of-hours
|
||||
if (!normalDay.isOpen) return true;
|
||||
// Slot starts before normal opening
|
||||
if (timeToMinutes(timeStr) < timeToMinutes(normalDay.startTime)) return true;
|
||||
// Slot ends after normal closing
|
||||
if (timeToMinutes(timeStr) + duration > timeToMinutes(normalDay.endTime)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupedTimeSlots = $derived.by(() => {
|
||||
const base =
|
||||
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(
|
||||
selectedDate,
|
||||
workingHours,
|
||||
availableHours,
|
||||
getTotalDuration(),
|
||||
lunchProtection
|
||||
)
|
||||
: [];
|
||||
if (!outOfHours || !normalWorkingHours || !selectedDate) return base;
|
||||
const dateStr = selectedDate.toString();
|
||||
const duration = getTotalDuration();
|
||||
return base.map((slot) => {
|
||||
if (slot.type === 'available') {
|
||||
return {
|
||||
...slot,
|
||||
outOfHours: isSlotOutOfHours(dateStr, slot.startTime, duration, normalWorkingHours)
|
||||
};
|
||||
}
|
||||
return slot;
|
||||
});
|
||||
});
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
@@ -233,6 +333,18 @@
|
||||
const canProceedStep3 = $derived(true); // Overrides are optional
|
||||
const canProceedStep4 = $derived(!!(selectedDate && selectedTime));
|
||||
|
||||
/** Whether the currently selected time slot is out-of-hours */
|
||||
const selectedTimeOutOfHours = $derived(
|
||||
outOfHours && selectedDate && selectedTime && normalWorkingHours
|
||||
? isSlotOutOfHours(
|
||||
selectedDate.toString(),
|
||||
selectedTime,
|
||||
getTotalDuration(),
|
||||
normalWorkingHours
|
||||
)
|
||||
: false
|
||||
);
|
||||
|
||||
// =============== Effects ===============
|
||||
let wasOpen = false;
|
||||
let userNavigatedCalendar = $state(false);
|
||||
@@ -306,7 +418,17 @@
|
||||
checkDate.getMonth() + 1,
|
||||
checkDate.getDate()
|
||||
);
|
||||
if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
||||
if (outOfHours) {
|
||||
// Out-of-hours: just check available hours exist with slots
|
||||
const dayAH = availableHours?.[dateStr];
|
||||
if (dayAH?.slots?.length > 0 && !isDateUnavailable(calDate)) {
|
||||
selectedDate = calDate;
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (workingHours[dateStr]?.isOpen && !isDateUnavailable(calDate)) {
|
||||
selectedDate = calDate;
|
||||
if (!userNavigatedCalendar) {
|
||||
placeholder = new CalendarDate(checkDate.getFullYear(), checkDate.getMonth() + 1, 1);
|
||||
@@ -339,6 +461,8 @@
|
||||
userNavigatedCalendar = false;
|
||||
bookingCreateAutoSelectDone = false;
|
||||
loadingMonthKeys = new Set();
|
||||
outOfHours = false;
|
||||
normalWorkingHours = null;
|
||||
// Clear reservation state
|
||||
reservationId = null;
|
||||
reservationExpiresAt = null;
|
||||
@@ -419,18 +543,40 @@
|
||||
|
||||
try {
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}),
|
||||
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
})
|
||||
fetch(
|
||||
`/api/scheduling/working-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`/api/scheduling/available-hours?start=${startStr}&end=${endStr}${outOfHours ? '&out_of_hours=true' : ''}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
const whData: WorkingHoursDay[] = await whRes.json();
|
||||
const ahData: AvailableHoursDay[] = await ahRes.json();
|
||||
|
||||
console.log('[DEBUG] API response for hours', {
|
||||
outOfHours,
|
||||
start: startStr,
|
||||
end: endStr,
|
||||
mode: outOfHours ? 'out_of_hours' : 'normal',
|
||||
whSample: whData.slice(0, 3).map((d) => ({
|
||||
date: d.date,
|
||||
isOpen: d.isOpen,
|
||||
startTime: d.startTime,
|
||||
endTime: d.endTime
|
||||
})),
|
||||
ahSample: ahData
|
||||
.slice(0, 3)
|
||||
.map((d) => ({ date: d.date, isOpen: d.isOpen, slotsCount: d.slots?.length }))
|
||||
});
|
||||
|
||||
const whMap: Record<string, any> = {};
|
||||
const ahMap: Record<string, any> = {};
|
||||
|
||||
@@ -496,12 +642,18 @@
|
||||
);
|
||||
|
||||
const [whRes, ahRes] = await Promise.all([
|
||||
fetch(`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}),
|
||||
fetch(`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
})
|
||||
fetch(
|
||||
`/api/scheduling/working-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`/api/scheduling/available-hours?start=${startOfMonth}&end=${endOfMonth}${outOfHours ? '&out_of_hours=true' : ''}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
if (whRes.ok && ahRes.ok) {
|
||||
@@ -547,8 +699,10 @@
|
||||
localDate.setHours(hours, minutes, 0, 0);
|
||||
const startTimeISO = localDate.toISOString();
|
||||
|
||||
const serviceIds = selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices.filter(s => (s as any).is_custom).map((s) => s.id);
|
||||
const serviceIds = selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id);
|
||||
const customServiceIds = selectedServices
|
||||
.filter((s) => (s as any).is_custom)
|
||||
.map((s) => s.id);
|
||||
|
||||
// Build service overrides payload
|
||||
const overrides = [];
|
||||
@@ -568,7 +722,8 @@
|
||||
service_ids: serviceIds,
|
||||
service_overrides: overrides.length > 0 ? overrides : [],
|
||||
ttl_minutes: 15,
|
||||
reservation_type: 'callin'
|
||||
reservation_type: 'callin',
|
||||
out_of_hours: outOfHours
|
||||
};
|
||||
if (customServiceIds.length > 0) {
|
||||
payload.custom_service_ids = customServiceIds;
|
||||
@@ -731,7 +886,13 @@
|
||||
}
|
||||
};
|
||||
showCustomCreateForm = false;
|
||||
newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' };
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = { name: '', price: '', duration_minutes: '' };
|
||||
toast.success('Custom service created and added');
|
||||
} else {
|
||||
@@ -745,24 +906,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
const groupedTimeSlots = $derived(
|
||||
currentStep === 4 && selectedServices.length > 0 && selectedDate
|
||||
? generateGroupedTimeSlots(
|
||||
selectedDate,
|
||||
workingHours,
|
||||
availableHours,
|
||||
getTotalDuration(),
|
||||
lunchProtection
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
function isDateUnavailable(date: DateValue): boolean {
|
||||
if (!(date instanceof CalendarDate)) return true;
|
||||
if (date.compare(minDate) < 0 || date.compare(maxCalendarDate) > 0) return true;
|
||||
if (!workingHours) return true;
|
||||
|
||||
const dateStr = date.toString();
|
||||
|
||||
// Out-of-hours: only check if available hours exist with slots
|
||||
if (outOfHours) {
|
||||
const ahDay = availableHours?.[dateStr];
|
||||
const whDay = workingHours[dateStr];
|
||||
const result = !ahDay?.slots || ahDay.slots.length === 0;
|
||||
console.log('[DEBUG] isDateUnavailable (outOfHours)', {
|
||||
dateStr,
|
||||
result,
|
||||
hasSlots: ahDay?.slots?.length,
|
||||
whIsOpen: whDay?.isOpen,
|
||||
whStart: whDay?.startTime,
|
||||
whEnd: whDay?.endTime
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const dayHours = workingHours[dateStr];
|
||||
if (!dayHours?.isOpen) return true;
|
||||
|
||||
@@ -865,10 +1031,11 @@
|
||||
const payload: Record<string, any> = {
|
||||
user_id: finalUserId,
|
||||
start_time: dateTimeStr,
|
||||
service_ids: selectedServices.filter(s => !(s as any).is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter(s => (s as any).is_custom).map((s) => s.id),
|
||||
service_ids: selectedServices.filter((s) => !(s as any).is_custom).map((s) => s.id),
|
||||
custom_service_ids: selectedServices.filter((s) => (s as any).is_custom).map((s) => s.id),
|
||||
service_overrides: overrides.length > 0 ? overrides : undefined,
|
||||
notes: notes.trim() || null
|
||||
notes: notes.trim() || null,
|
||||
out_of_hours: outOfHours
|
||||
};
|
||||
|
||||
const res = await fetch('/api/admin/bookings', {
|
||||
@@ -1042,7 +1209,13 @@
|
||||
onclick={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<div>
|
||||
<div class="text-base font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
|
||||
<div class="text-base font-medium">
|
||||
{formatUserName(
|
||||
user.fullName,
|
||||
user.previousFirstName,
|
||||
user.previousLastName
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{#if user.email && user.phone}
|
||||
{user.email} • {user.phone}
|
||||
@@ -1149,7 +1322,9 @@
|
||||
<Input
|
||||
placeholder="Search existing custom services..."
|
||||
bind:value={customSearchQuery}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') fetchCustomServices(); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') fetchCustomServices();
|
||||
}}
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={fetchCustomServices}>Search</Button>
|
||||
@@ -1182,12 +1357,23 @@
|
||||
}}
|
||||
>
|
||||
<span class="font-medium">{cs.name}</span>
|
||||
<span class="text-gray-500">{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0 ? ` (${cs.usage_count}×)` : ''}</span>
|
||||
<span class="text-gray-500"
|
||||
>{cs.duration_minutes} min • £{cs.price.toFixed(2)}{cs.usage_count > 0
|
||||
? ` (${cs.usage_count}×)`
|
||||
: ''}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button variant="ghost" size="sm" onclick={() => { showCustomCreateForm = true; }} class="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
showCustomCreateForm = true;
|
||||
}}
|
||||
class="w-full"
|
||||
>
|
||||
+ Create new custom service
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1198,8 +1384,10 @@
|
||||
<Input
|
||||
id="booking-cs-name"
|
||||
bind:value={newCustomService.name}
|
||||
oninput={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
onblur={() => customServiceErrors.name = validateCsName(newCustomService.name)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.name = validateCsName(newCustomService.name))}
|
||||
placeholder="e.g., Bridal Party French Tips"
|
||||
class={customServiceErrors.name ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -1225,8 +1413,10 @@
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={newCustomService.price}
|
||||
oninput={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
onblur={() => customServiceErrors.price = validateCsPrice(newCustomService.price)}
|
||||
oninput={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
onblur={() =>
|
||||
(customServiceErrors.price = validateCsPrice(newCustomService.price))}
|
||||
placeholder="0.00"
|
||||
class={customServiceErrors.price ? 'border-red-500' : ''}
|
||||
/>
|
||||
@@ -1239,12 +1429,21 @@
|
||||
<select
|
||||
id="booking-cs-dur"
|
||||
bind:value={newCustomService.duration_minutes}
|
||||
onchange={() => customServiceErrors.duration_minutes = validateCsDuration(newCustomService.duration_minutes)}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {customServiceErrors.duration_minutes ? 'border-red-500' : ''}"
|
||||
onchange={() =>
|
||||
(customServiceErrors.duration_minutes = validateCsDuration(
|
||||
newCustomService.duration_minutes
|
||||
))}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {customServiceErrors.duration_minutes
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{#each durationOptions as mins (mins)}
|
||||
<option value={mins}>{mins} min{mins >= 60 ? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})` : ''}</option>
|
||||
<option value={mins}
|
||||
>{mins} min{mins >= 60
|
||||
? ` (${Math.floor(mins / 60)}h${mins % 60 > 0 ? ` ${mins % 60}m` : ''})`
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
{#if customServiceErrors.duration_minutes}
|
||||
@@ -1262,8 +1461,13 @@
|
||||
max="100"
|
||||
placeholder="0"
|
||||
bind:value={newCustomService.minimum_age_required}
|
||||
oninput={() => customServiceErrors.minimum_age_required = validateCsMinimumAge(newCustomService.minimum_age_required)}
|
||||
class="w-full {customServiceErrors.minimum_age_required ? 'border-red-500' : ''}"
|
||||
oninput={() =>
|
||||
(customServiceErrors.minimum_age_required = validateCsMinimumAge(
|
||||
newCustomService.minimum_age_required
|
||||
))}
|
||||
class="w-full {customServiceErrors.minimum_age_required
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if customServiceErrors.minimum_age_required}
|
||||
<p class="text-xs text-red-600">{customServiceErrors.minimum_age_required}</p>
|
||||
@@ -1271,10 +1475,33 @@
|
||||
<p class="text-xs text-gray-500">0 for no age restriction</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" onclick={createCustomService} disabled={creatingCustomService || !isCustomFormValid}>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={createCustomService}
|
||||
disabled={creatingCustomService || !isCustomFormValid}
|
||||
>
|
||||
{creatingCustomService ? 'Creating...' : 'Save & Add'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={() => { showCustomCreateForm = false; newCustomService = { name: '', description: '', price: '', duration_minutes: '', minimum_age_required: '' }; customServiceErrors = { name: '', price: '', duration_minutes: '', minimum_age_required: '' }; }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
showCustomCreateForm = false;
|
||||
newCustomService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
customServiceErrors = {
|
||||
name: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1481,6 +1708,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Out-of-hours toggle -->
|
||||
<div class="mx-6 flex items-center gap-2">
|
||||
<Checkbox id="out-of-hours" bind:checked={outOfHours} />
|
||||
<Label for="out-of-hours" class="cursor-pointer text-sm font-medium text-amber-600">
|
||||
Out-of-hours booking
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{#if loadingAvailableHours && selectedDate}
|
||||
<div class="flex items-center justify-center border-t p-6">
|
||||
<p class="text-sm text-gray-500">Loading times...</p>
|
||||
@@ -1512,6 +1747,7 @@
|
||||
endTime={formatTime(calculateEndTime(selectedTime, getTotalDuration()))}
|
||||
duration={getTotalDuration()}
|
||||
protection={lunchProtection.get(selectedTime)}
|
||||
outOfHours={selectedTimeOutOfHours}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
updated_at: data.updated_at,
|
||||
created_by: data.created_by,
|
||||
created_by_name: data.created_by_name,
|
||||
out_of_hours: data.out_of_hours ?? false,
|
||||
|
||||
// Deposit fields
|
||||
deposit_required: data.deposit_required ?? false,
|
||||
@@ -316,6 +317,19 @@
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedBooking.out_of_hours}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mr-1 h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Out-of-hours
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
Reference in New Issue
Block a user