diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 4e5daf6..0761fbf 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -6,6 +6,7 @@ import ( "crussell/internal/dav" "crussell/internal/validators" "crussell/mw" + "crussell/handlers/scheduling" "database/sql" "encoding/json" "errors" @@ -1205,6 +1206,15 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Check for time blocker overlap + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } else if blockerOverlap { + http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + return + } + var createdBy *string if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok { createdBy = &creatorID @@ -1368,6 +1378,15 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Check for time blocker overlap + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } else if blockerOverlap { + http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict) + return + } + weekday := int(req.StartTime.Weekday()) bookingTime := req.StartTime.Format("15:04:05") daysToMonday := weekday @@ -2183,3 +2202,127 @@ STATUS:%s END:VEVENT END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status) } + + + +// OverlappingBooking represents a booking that overlaps with another +type OverlappingBooking struct { + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + Duration int `json:"duration_minutes"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + User *UserSummary `json:"user,omitempty"` + Services []string `json:"services,omitempty"` +} + +// OverlappingBookingsResponse is the response for the overlapping bookings endpoint +type OverlappingBookingsResponse struct { + Bookings []OverlappingBooking `json:"bookings"` +} + +// GET /api/admin/bookings/{id}/overlapping +// Returns all bookings that overlap with the specified booking +func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + // Get the booking's start time and duration + var startTime time.Time + var durationMinutes int + if err := db.DB.QueryRow(r.Context(), ` + SELECT b.start_time, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) + 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.id = $1 + GROUP BY b.start_time + `, bookingID).Scan(&startTime, &durationMinutes); err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + log.Printf("Failed to get booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute) + + // Find overlapping bookings (excluding the current booking and cancelled/completed ones) + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.created_at, + COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration, + u.fn, + u.email + FROM bookings b + LEFT JOIN booking_services bs ON b.id = bs.booking_id + LEFT JOIN services s ON bs.service_id = s.id + LEFT JOIN users u ON b.user_id = u.id + WHERE b.id != $1 + AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'no_deposit') + AND b.start_time < $3 + AND b.start_time + (INTERVAL '1 minute' * ( + SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60) + FROM booking_services bs2 + JOIN services s2 ON bs2.service_id = s2.id + WHERE bs2.booking_id = b.id + )) > $2 + GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email + ORDER BY b.created_at ASC + `, bookingID, startTime, endTime) + if err != nil { + log.Printf("Failed to query overlapping bookings: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var bookings []OverlappingBooking + for rows.Next() { + var ob OverlappingBooking + ob.User = &UserSummary{} + if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.FullName, &ob.User.Email); err != nil { + log.Printf("Failed to scan overlapping booking: %v", err) + continue + } + + // Get services for this booking + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT s.name + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, ob.ID) + if err == nil { + for serviceRows.Next() { + var name string + if err := serviceRows.Scan(&name); err == nil { + ob.Services = append(ob.Services, name) + } + } + serviceRows.Close() + } + + bookings = append(bookings, ob) + } + + if bookings == nil { + bookings = []OverlappingBooking{} + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil { + log.Printf("Failed to encode overlapping bookings response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} \ No newline at end of file diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 54effbc..5e4d607 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -4,6 +4,7 @@ import ( "crussell/db" "crussell/handlers/notifications" "crussell/internal/validators" + "crussell/handlers/scheduling" "crussell/mw" "database/sql" "encoding/json" @@ -558,6 +559,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { return } + // Check for time blocker overlap - admin can proceed with warning + blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd) + if err != nil { + log.Printf("Failed to check time blocker overlap: %v", err) + } + + tx, err := db.DB.Begin(r.Context()) if err != nil { log.Printf("Failed to start transaction: %v", err) @@ -687,13 +695,38 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + // Build response with warnings if any + + warnings := []string{} + + if blockerOverlap { + + warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc)) + + } + + + response := map[string]interface{}{ + + "booking": booking, + + "warnings": warnings, + + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(booking); err != nil { + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + } // ============================================================================= diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index c066128..235146a 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -7,6 +7,8 @@ import ( "time" "crussell/db" + "crussell/mw" + "log" ) // --- Types --- @@ -248,6 +250,7 @@ type DayAvailableHours struct { IsOpen bool `json:"isOpen"` Slots []TimeSlot `json:"slots"` Source string `json:"source"` +Blockers []TimeSlot `json:"blockers,omitempty"` } // --- GetAvailableHours (with bookings, UK-local) --- @@ -351,6 +354,37 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { } bookingRows.Close() + // Load time blockers + blockers, err := GetTimeBlockersInRange(r.Context(), start, end) + if err != nil { + log.Printf("Failed to load time blockers: %v", err) + } + // Convert blockers to map by date for easier lookup + + blockerMap := make(map[string][]TimeSlot) + + for _, blocker := range blockers { + + dateStr := blocker.StartTime.Format("2006-01-02") + + endTime := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute) + + blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{ + + StartTime: blocker.StartTime.Format("15:04"), + + EndTime: endTime.Format("15:04"), + + }) + + } + + // Check if user is admin + isAdmin := false + if userRole, ok := r.Context().Value(mw.UserRoleKey).(string); ok { + isAdmin = userRole == "admin" + } + // Generate available slots per day var results []DayAvailableHours for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { @@ -411,6 +445,18 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { slots = subtractTimeSlots(slots, booked) } day.Slots = slots + // Handle time blockers + if !isAdmin { + // Regular users: subtract blockers from available slots + if dayBlockers, ok := blockerMap[day.Date]; ok { + day.Slots = subtractTimeSlots(day.Slots, dayBlockers) + } + } else { + // Admins: keep blockers visible for warning display + if dayBlockers, ok := blockerMap[day.Date]; ok { + day.Blockers = dayBlockers + } + } } else { day.Slots = []TimeSlot{} } diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go new file mode 100644 index 0000000..95470ae --- /dev/null +++ b/backend/handlers/scheduling/time-blockers.go @@ -0,0 +1,234 @@ +package scheduling + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "crussell/db" + "crussell/mw" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" +) + +// --- Types --- + +type TimeBlocker struct { + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + DurationMinutes int `json:"duration_minutes"` + Description string `json:"description,omitempty"` + CronExpression *string `json:"cron_expression,omitempty"` + CreatedAt time.Time `json:"created_at"` + CreatedBy *string `json:"created_by,omitempty"` +} + +type CreateTimeBlockerRequest struct { + StartTime time.Time `json:"start_time"` + DurationMinutes int `json:"duration_minutes"` + Description string `json:"description,omitempty"` + CronExpression *string `json:"cron_expression,omitempty"` +} + +// --- List Time Blockers --- +// GET /api/admin/time-blockers +func ListTimeBlockers(w http.ResponseWriter, r *http.Request) { + // Optional date range filtering + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + + var rows pgx.Rows + var err error + + if startStr != "" && endStr != "" { + // Filter by date range + ukLocation, _ := time.LoadLocation("Europe/London") + start, err1 := time.ParseInLocation("2006-01-02", startStr, ukLocation) + end, err2 := time.ParseInLocation("2006-01-02", endStr, ukLocation) + if err1 != nil || err2 != nil { + http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest) + return + } + 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) + + rows, err = db.DB.Query(r.Context(), ` + SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by + FROM time_blockers + WHERE start_time >= $1 AND start_time <= $2 + ORDER BY start_time DESC + `, start, end) + } else { + // Get all blockers (most recent first, limited) + rows, err = db.DB.Query(r.Context(), ` + SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by + FROM time_blockers + ORDER BY start_time DESC + LIMIT 100 + `) + } + + if err != nil { + http.Error(w, "failed to fetch time blockers", http.StatusInternalServerError) + return + } + defer rows.Close() + + var blockers []TimeBlocker + for rows.Next() { + var b TimeBlocker + if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { + http.Error(w, "failed to scan time blocker", http.StatusInternalServerError) + return + } + blockers = append(blockers, b) + } + + if err := rows.Err(); err != nil { + http.Error(w, "error iterating time blockers", http.StatusInternalServerError) + return + } + + if blockers == nil { + blockers = []TimeBlocker{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(blockers) +} + +// --- Create Time Blocker --- +// POST /api/admin/time-blockers +func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) { + var req CreateTimeBlockerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // Validate required fields + if req.StartTime.IsZero() { + http.Error(w, "start_time is required", http.StatusBadRequest) + return + } + if req.DurationMinutes <= 0 { + http.Error(w, "duration_minutes must be greater than 0", http.StatusBadRequest) + return + } + + // Get admin user ID from context + var createdBy *string + if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok { + createdBy = &userID + } + + // Insert the time blocker + var blocker TimeBlocker + err := db.DB.QueryRow(r.Context(), ` + INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by + `, req.StartTime, req.DurationMinutes, req.Description, req.CronExpression, createdBy).Scan( + &blocker.ID, &blocker.StartTime, &blocker.DurationMinutes, &blocker.Description, + &blocker.CronExpression, &blocker.CreatedAt, &blocker.CreatedBy, + ) + if err != nil { + http.Error(w, "failed to create time blocker", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(blocker) +} + +// --- Delete Time Blocker --- +// DELETE /api/admin/time-blockers/{id} +func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "missing id parameter", http.StatusBadRequest) + return + } + + result, err := db.DB.Exec(r.Context(), ` + DELETE FROM time_blockers WHERE id = $1 + `, id) + if err != nil { + http.Error(w, "failed to delete time blocker", http.StatusInternalServerError) + return + } + + rowsAffected := result.RowsAffected() + if rowsAffected == 0 { + http.Error(w, "time blocker not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// --- Helper: Check Time Blocker Overlap --- +// Returns (hasOverlap, blockerDescription, error) +// Used by booking handlers to check for blocker conflicts +func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time) (bool, string, error) { + var description string + var hasOverlap bool + + err := db.DB.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM time_blockers + WHERE start_time < $2 + AND start_time + (INTERVAL '1 minute' * duration_minutes) > $1 + ) + `, startTime, endTime).Scan(&hasOverlap) + + if err != nil { + return false, "", err + } + + if hasOverlap { + // Get the description of the overlapping blocker + db.DB.QueryRow(ctx, ` + SELECT COALESCE(description, 'Time blocked') + FROM time_blockers + WHERE start_time < $2 + AND start_time + (INTERVAL '1 minute' * duration_minutes) > $1 + LIMIT 1 + `, startTime, endTime).Scan(&description) + } + + return hasOverlap, description, nil +} + +// --- Helper: Get Time Blockers in Range --- +// Returns blockers for the given date range, expanded for recurring blockers +// Used by GetAvailableHours to subtract blocked time from available slots +func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) { + // For now, only return one-off blockers (cron_expression IS NULL) + // TODO: Implement cron expansion for recurring blockers + rows, err := db.DB.Query(ctx, ` + SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by + FROM time_blockers + WHERE cron_expression IS NULL + AND start_time >= $1 AND start_time <= $2 + ORDER BY start_time + `, start, end) + if err != nil { + return nil, err + } + defer rows.Close() + + var blockers []TimeBlocker + for rows.Next() { + var b TimeBlocker + if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil { + return nil, err + } + blockers = append(blockers, b) + } + + return blockers, rows.Err() +} diff --git a/backend/main.go b/backend/main.go index d7c5750..2e0117f 100644 --- a/backend/main.go +++ b/backend/main.go @@ -182,6 +182,7 @@ func main() { r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler) r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/{id}", bookings.GetAdminBookingHandler) + r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/cancel", bookings.CancelBookingHandler) diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 7fb5e94..9bec776 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -70,27 +70,50 @@ {/if} - {#if selectedBooking} - - {@const isPastBooking = new Date(selectedBooking.start_time) < new Date()} - {@const isUnpaid = selectedBooking.amount_due > 0} - {@const showChip = !isPastBooking || isUnpaid} + {#if selectedBooking} + + {@const isPastBooking = new Date(selectedBooking.start_time) < new Date()} + {@const isUnpaid = selectedBooking.amount_due > 0} + {@const showChip = !isPastBooking || isUnpaid} + {@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)} - {#if showChip} - - {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} - + {#if showChip} +
+ + {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} + + + + {#if selectedBooking.deposit_required} + {#if selectedBooking.status === 'pending'} + + Will Require Deposit + + {:else if isConfirmedOrLater} + + {selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} + + {/if} + {/if} +
+ {/if} {/if} - {/if} @@ -138,47 +161,7 @@ - - {#if selectedBooking.deposit_required} -
-

- Deposit Information -

-
-
-
Deposit Amount
-
- £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'} -
-
-
-
Deposit Status
-
- - {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} - -
-
- {#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid} -
-
Deadline
-
- {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric' - })} -
-
- {/if} -
-
- {/if} + {#if selectedBooking.services && selectedBooking.services.length > 0} @@ -208,7 +191,40 @@

Financial Summary

-
+
+ {#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required} + +
+ Deposit Required +
+
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
+
+ + {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} + + {#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} + + • Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric' + })} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', { + hour: 'numeric', + minute: '2-digit', + hour12: true + })} + + {/if} +
+
+
+ {/if} +
Total Amount £{selectedBooking.total_amount.toFixed(2)} diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index 3be055f..ea5428e 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -11,6 +11,9 @@ open: boolean; booking: { id: string; + start_time: string; + duration_minutes?: number; + created_at: string; notes?: string; user?: { full_name: string; @@ -56,7 +59,69 @@ >({}); let submitting = $state(false); let showDeclineConfirm = $state(false); + let overlappingBookings = $state([]); + let loadingOverlaps = $state(false); + interface OverlappingBooking { + id: string; + start_time: string; + duration_minutes: number; + status: string; + created_at: string; + user?: { + full_name?: string; + email?: string; + }; + services?: string[]; + } + + // Fetch overlapping bookings when modal opens + async function fetchOverlappingBookings() { + if (!booking?.id) return; + loadingOverlaps = true; + try { + const response = await fetch(`/api/admin/bookings/${booking.id}/overlapping`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + if (response.ok) { + const data = await response.json(); + overlappingBookings = data.bookings || []; + } + } catch (err) { + console.error('Error fetching overlapping bookings:', err); + } finally { + loadingOverlaps = false; + } + } + + // Find the oldest booking (including current) among overlaps + function findOldestBooking(): { id: string; isCurrent: boolean } | null { + if (overlappingBookings.length === 0) return null; + + const currentCreatedAt = new Date(booking.created_at).getTime(); + let oldestId = booking.id; + let oldestTime = currentCreatedAt; + + for (const ob of overlappingBookings) { + const obTime = new Date(ob.created_at).getTime(); + if (obTime < oldestTime) { + oldestTime = obTime; + oldestId = ob.id; + } + } + + return { id: oldestId, isCurrent: oldestId === booking.id }; + } + + $effect(() => { + if (open && booking?.id) { + fetchOverlappingBookings(); + } + }); // Initialize overrides with original values $effect(() => { if (!booking?.services?.length) return; @@ -240,6 +305,79 @@
+ + {#if loadingOverlaps} +
+
Checking for overlapping bookings...
+
+ {:else if overlappingBookings.length > 0} + {@const oldest = findOldestBooking()} +
+

+ ⚠️ Overlapping Bookings ({overlappingBookings.length}) +

+ {#if oldest?.isCurrent} +
+ ★ This booking was created first - it has priority +
+ {/if} +
+ {#each overlappingBookings as ob (ob.id)} +
+
+
+
+ {#if oldest?.id === ob.id} + + {/if} + {ob.user?.full_name || 'Unknown'} +
+
+ {new Date(ob.start_time).toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true + })} + ({ob.duration_minutes} min) +
+ {#if ob.services && ob.services.length > 0} +
+ {ob.services.join(', ')} +
+ {/if} +
+
+ + {ob.status} + +
+ {new Date(ob.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true + })} +
+
+
+
+ {/each} +
+
+ {/if} + +

diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index 9a7b1f1..36f9df5 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -138,6 +138,8 @@ {/if}

{#if selectedBooking} + {@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)} + {#if selectedBooking.status === 'pending'}
+ {:else if isConfirmedOrLater && selectedBooking.deposit_required} + +
+ + {selectedBooking.status.replace('_', ' ')} + + + {selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} +
{/if} {/if} @@ -229,47 +262,7 @@ {/if}
- - {#if selectedBooking.deposit_required} -
-

- Deposit Information -

-
-
-
Deposit Amount
-
- £{selectedBooking.deposit_amount?.toFixed(2) || '0.00'} -
-
-
-
Deposit Status
-
- - {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} - -
-
- {#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid} -
-
Deadline
-
- {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric' - })} -
-
- {/if} -
-
- {/if} + {#if selectedBooking.services && selectedBooking.services.length > 0} @@ -348,7 +341,40 @@

Financial Summary

-
+
+ {#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required} + +
+ Deposit Required +
+
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
+
+ + {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} + + {#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} + + • Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', { + weekday: 'short', + day: 'numeric', + month: 'short', + year: 'numeric' + })} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', { + hour: 'numeric', + minute: '2-digit', + hour12: true + })} + + {/if} +
+
+
+ {/if} +
Total Amount £{selectedBooking.total_amount.toFixed(2)} diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index de08762..2f78447 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -246,17 +246,35 @@
{formatBookingDateTime(b.start_time)}
-
- - - {b.status} - - • {b.user?.full_name || 'Unknown User'} - - - {formatServices(b.services)} - +
+ + + {b.status} + + {#if b.deposit_required} + {#if b.status === 'pending'} + + Will Require Deposit + + {:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)} + + {b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} + + {/if} + {/if} + • {b.user?.full_name || 'Unknown User'} + + - {formatServices(b.services)} + +
-
{/each} diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 28f21ba..3adde31 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -55,7 +55,10 @@ | 'client_cancelled' | 'we_cancelled' | 're-schedule' - | 'no_show'; + | 'no_show' + | 'no_deposit'; + deposit_required: boolean; + deposit_paid: boolean; services: Array<{ service_name?: string; }>; @@ -434,27 +437,51 @@ return `${dateStr} at ${timeStr}`; })()}
-
- - {booking.status} - - - {booking.services.map((s) => s.service_name).join(', ')} - -
+
+ + {booking.status.replace('_', ' ')} + + + + {#if booking.deposit_required} + {#if booking.status === 'pending'} + + Will Require Deposit + + {:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid} + + Deposit Due + + {:else if booking.deposit_paid} + + Deposit Paid + + {/if} + {/if} + + + {booking.services.map((s) => s.service_name).join(', ')} + +
£{booking.total_amount.toFixed(2)}
diff --git a/frontend/src/lib/components/booking/BookingFlow.svelte b/frontend/src/lib/components/booking/BookingFlow.svelte index 5962813..49a3240 100644 --- a/frontend/src/lib/components/booking/BookingFlow.svelte +++ b/frontend/src/lib/components/booking/BookingFlow.svelte @@ -728,17 +728,21 @@ // Handle specific error cases if (response.status === 409) { - toast.error('This time slot is no longer available. Please choose a different time.'); + // Check if it's an active booking conflict or time slot conflict + if (errorMessage.includes('active booking') || errorMessage.includes('already have')) { + toast.error('You already have an active booking. Please complete or cancel it before creating a new one.'); + } else { + toast.error('This time slot is no longer available. Please choose a different time.'); + } } else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) { toast.error(errorMessage + ' Please complete a patch test first.'); - } else if (errorMessage.includes('48 hours') || errorMessage.includes('48h')) { + } else if (errorMessage.includes('48 hours') || errorMessage.includes('48h') || errorMessage.includes('advance')) { toast.error(errorMessage); } else if (response.status === 400) { toast.error(errorMessage); } else { toast.error('Failed to submit booking: ' + errorMessage); } - console.error('Booking submission failed:', response.status, errorText); } } catch (error) { diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 38c0517..faf9c65 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -313,6 +313,26 @@ CREATE TABLE exceptional_group_applications ( CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications(week_start); +-- ======================================= +-- TIME BLOCKERS TABLE +-- ======================================= +-- Admin-defined time periods that are unavailable for booking +-- Used for doctor appointments, extended lunch, training, etc. + +CREATE TABLE time_blockers ( + id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('time_blockers'), + start_time TIMESTAMPTZ NOT NULL, + duration_minutes INT NOT NULL CHECK (duration_minutes > 0), + description TEXT, + cron_expression TEXT, -- NULL = one-off, otherwise cron pattern for recurring + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL +); + +-- Indexes for efficient overlap queries +CREATE INDEX idx_time_blockers_start_time ON time_blockers(start_time); +CREATE INDEX idx_time_blockers_cron ON time_blockers(cron_expression) WHERE cron_expression IS NOT NULL; + -- ======================================= -- PAYMENTS TABLE