non-booking-time-blockers (nbtb)

This commit is contained in:
2026-03-04 11:38:34 +00:00
parent 861dd11c5b
commit 29b9776a93
12 changed files with 845 additions and 139 deletions
+143
View File
@@ -6,6 +6,7 @@ import (
"crussell/internal/dav" "crussell/internal/dav"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
"crussell/handlers/scheduling"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -1205,6 +1206,15 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return 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 var createdBy *string
if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok { if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
createdBy = &creatorID createdBy = &creatorID
@@ -1368,6 +1378,15 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
return 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()) weekday := int(req.StartTime.Weekday())
bookingTime := req.StartTime.Format("15:04:05") bookingTime := req.StartTime.Format("15:04:05")
daysToMonday := weekday daysToMonday := weekday
@@ -2183,3 +2202,127 @@ STATUS:%s
END:VEVENT END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status) 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)
}
}
+34 -1
View File
@@ -4,6 +4,7 @@ import (
"crussell/db" "crussell/db"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/handlers/scheduling"
"crussell/mw" "crussell/mw"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
@@ -558,6 +559,13 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
return 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()) tx, err := db.DB.Begin(r.Context())
if err != nil { if err != nil {
log.Printf("Failed to start transaction: %v", err) 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) http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) 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) log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
} }
// ============================================================================= // =============================================================================
@@ -7,6 +7,8 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/mw"
"log"
) )
// --- Types --- // --- Types ---
@@ -248,6 +250,7 @@ type DayAvailableHours struct {
IsOpen bool `json:"isOpen"` IsOpen bool `json:"isOpen"`
Slots []TimeSlot `json:"slots"` Slots []TimeSlot `json:"slots"`
Source string `json:"source"` Source string `json:"source"`
Blockers []TimeSlot `json:"blockers,omitempty"`
} }
// --- GetAvailableHours (with bookings, UK-local) --- // --- GetAvailableHours (with bookings, UK-local) ---
@@ -351,6 +354,37 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
} }
bookingRows.Close() 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 // Generate available slots per day
var results []DayAvailableHours var results []DayAvailableHours
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { 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) slots = subtractTimeSlots(slots, booked)
} }
day.Slots = slots 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 { } else {
day.Slots = []TimeSlot{} day.Slots = []TimeSlot{}
} }
@@ -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()
}
+1
View File
@@ -182,6 +182,7 @@ func main() {
r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler) r.With(mw.RateLimit(60, time.Minute)).Get("/search", bookings.SearchAdminBookingsHandler)
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler) r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler) r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Get("/{id}/overlapping", bookings.GetOverlappingBookingsHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler) r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.CancelBookingHandler) r.Post("/{id}/cancel", bookings.CancelBookingHandler)
@@ -70,27 +70,50 @@
{/if} {/if}
</div> </div>
{#if selectedBooking} {#if selectedBooking}
<!-- Logic: Only show chip if Booking is Future OR (Past AND Unpaid) --> <!-- Booking Status Badge -->
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()} {@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
{@const isUnpaid = selectedBooking.amount_due > 0} {@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid} {@const showChip = !isPastBooking || isUnpaid}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{#if showChip} {#if showChip}
<span <div class="flex items-center gap-2">
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium <span
{isPastBooking class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
? 'bg-red-100 text-red-800' // Red if past & unpaid {isPastBooking
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed' ? 'bg-red-100 text-red-800'
? 'bg-emerald-100 text-emerald-800' : selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
: selectedBooking.status === 'pending' ? 'bg-emerald-100 text-emerald-800'
? 'bg-amber-100 text-amber-800' : selectedBooking.status === 'pending'
: 'bg-gray-100 text-gray-800'}" ? 'bg-amber-100 text-amber-800'
> : 'bg-gray-100 text-gray-800'}"
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} >
</span> {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
</span>
<!-- Deposit Status Badge -->
{#if selectedBooking.deposit_required}
{#if selectedBooking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if isConfirmedOrLater}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
</div>
{/if}
{/if} {/if}
{/if}
</div> </div>
</Modal.Header> </Modal.Header>
@@ -138,47 +161,7 @@
</div> </div>
</div> </div>
<!-- Deposit Info --> <!-- Deposit Info (removed - now in Financial Summary for confirmed+ bookings) -->
{#if selectedBooking.deposit_required}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-blue-800 uppercase">
Deposit Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-blue-600">Deposit Amount</div>
<div class="font-semibold text-blue-900">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
</div>
<div>
<div class="text-xs text-blue-600">Deposit Status</div>
<div class="font-semibold">
<span
class="{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'} inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
</div>
</div>
{#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid}
<div class="md:col-span-2">
<div class="text-xs text-blue-600">Deadline</div>
<div class="font-semibold text-blue-900">
{new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Services --> <!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0} {#if selectedBooking.services && selectedBooking.services.length > 0}
@@ -208,7 +191,40 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary Financial Summary
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• 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
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span> <span class="text-sm text-gray-600">Total Amount</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span> <span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
@@ -11,6 +11,9 @@
open: boolean; open: boolean;
booking: { booking: {
id: string; id: string;
start_time: string;
duration_minutes?: number;
created_at: string;
notes?: string; notes?: string;
user?: { user?: {
full_name: string; full_name: string;
@@ -56,7 +59,69 @@
>({}); >({});
let submitting = $state(false); let submitting = $state(false);
let showDeclineConfirm = $state(false); let showDeclineConfirm = $state(false);
let overlappingBookings = $state<OverlappingBooking[]>([]);
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 // Initialize overrides with original values
$effect(() => { $effect(() => {
if (!booking?.services?.length) return; if (!booking?.services?.length) return;
@@ -240,6 +305,79 @@
</Modal.Header> </Modal.Header>
<div class="space-y-6 px-4 pb-4"> <div class="space-y-6 px-4 pb-4">
<!-- Overlapping Bookings Warning -->
{#if loadingOverlaps}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="text-sm text-gray-500">Checking for overlapping bookings...</div>
</div>
{:else if overlappingBookings.length > 0}
{@const oldest = findOldestBooking()}
<div class="rounded-lg border border-amber-300 bg-amber-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-amber-800 uppercase">
⚠️ Overlapping Bookings ({overlappingBookings.length})
</h3>
{#if oldest?.isCurrent}
<div class="mb-3 rounded bg-green-100 px-3 py-2 text-sm text-green-800">
★ This booking was created first - it has priority
</div>
{/if}
<div class="space-y-2">
{#each overlappingBookings as ob (ob.id)}
<div class="rounded border bg-white p-3 text-sm">
<div class="flex items-start justify-between">
<div>
<div class="font-medium">
{#if oldest?.id === ob.id}
<span class="text-amber-600" title="Booked first"></span>
{/if}
{ob.user?.full_name || 'Unknown'}
</div>
<div class="text-xs text-gray-500">
{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)
</div>
{#if ob.services && ob.services.length > 0}
<div class="mt-1 text-xs text-gray-600">
{ob.services.join(', ')}
</div>
{/if}
</div>
<div class="text-right">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{ob.status === 'pending'
? 'bg-amber-100 text-amber-800'
: ob.status === 'confirmed'
? 'bg-emerald-100 text-emerald-800'
: 'bg-gray-100 text-gray-800'}"
>
{ob.status}
</span>
<div class="mt-1 text-xs text-gray-400">
{new Date(ob.created_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Customer Contact Info -->
<!-- Customer Contact Info --> <!-- Customer Contact Info -->
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4"> <div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
@@ -138,6 +138,8 @@
{/if} {/if}
</div> </div>
{#if selectedBooking} {#if selectedBooking}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{#if selectedBooking.status === 'pending'} {#if selectedBooking.status === 'pending'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
@@ -165,6 +167,37 @@
> >
Pending Pending
</span> </span>
<!-- Deposit Badge for pending -->
{#if selectedBooking.deposit_required}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{/if}
</div>
{:else if isConfirmedOrLater && selectedBooking.deposit_required}
<!-- Deposit Badge for confirmed+ -->
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.status === 'completed'
? 'bg-green-100 text-green-800'
: selectedBooking.status === 'in_progress'
? 'bg-blue-100 text-blue-800'
: 'bg-emerald-100 text-emerald-800'}"
>
{selectedBooking.status.replace('_', ' ')}
</span>
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -229,47 +262,7 @@
{/if} {/if}
</div> </div>
<!-- Deposit Info --> <!-- Deposit Info (moved to Financial Summary) -->
{#if selectedBooking.deposit_required}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold tracking-wide text-blue-800 uppercase">
Deposit Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-blue-600">Deposit Amount</div>
<div class="font-semibold text-blue-900">
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
</div>
</div>
<div>
<div class="text-xs text-blue-600">Deposit Status</div>
<div class="font-semibold">
<span
class="{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'} inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
</div>
</div>
{#if selectedBooking.deposit_deadline && !selectedBooking.deposit_paid}
<div class="md:col-span-2">
<div class="text-xs text-blue-600">Deadline</div>
<div class="font-semibold text-blue-900">
{new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Services --> <!-- Services -->
{#if selectedBooking.services && selectedBooking.services.length > 0} {#if selectedBooking.services && selectedBooking.services.length > 0}
@@ -348,7 +341,40 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase"> <h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Financial Summary Financial Summary
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<!-- Deposit Info -->
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• 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
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Total Amount</span> <span class="text-sm text-gray-600">Total Amount</span>
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span> <span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
@@ -246,17 +246,35 @@
<div class="font-medium"> <div class="font-medium">
{formatBookingDateTime(b.start_time)} {formatBookingDateTime(b.start_time)}
</div> </div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500"> <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}> <span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span> <span class={getStatusDotClasses(b.status)}></span>
{b.status} {b.status}
</span> </span>
<span>{b.user?.full_name || 'Unknown User'}</span> {#if b.deposit_required}
<span> {#if b.status === 'pending'}
- {formatServices(b.services)} <span
</span> class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
</div> </div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button> <Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div> </div>
{/each} {/each}
@@ -55,7 +55,10 @@
| 'client_cancelled' | 'client_cancelled'
| 'we_cancelled' | 'we_cancelled'
| 're-schedule' | 're-schedule'
| 'no_show'; | 'no_show'
| 'no_deposit';
deposit_required: boolean;
deposit_paid: boolean;
services: Array<{ services: Array<{
service_name?: string; service_name?: string;
}>; }>;
@@ -434,27 +437,51 @@
return `${dateStr} at ${timeStr}`; return `${dateStr} at ${timeStr}`;
})()} })()}
</div> </div>
<div class="mt-1 flex items-center gap-2 text-xs"> <div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span <span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{booking.status === 'confirmed' {booking.status === 'confirmed' || booking.status === 'in_progress'
? 'bg-emerald-100 text-emerald-800' ? 'bg-emerald-100 text-emerald-800'
: booking.status === 'pending' : booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800' ? 'bg-yellow-100 text-yellow-800'
: booking.status === 'completed' : booking.status === 'completed'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: booking.status === 'cancelled' || : booking.status === 'client_cancelled' ||
booking.status === 'client_cancelled' || booking.status === 'we_cancelled' ||
booking.status === 'we_cancelled' booking.status === 'no_deposit'
? 'bg-red-100 text-red-800' ? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'}" : 'bg-gray-100 text-gray-800'}"
> >
{booking.status} {booking.status.replace('_', ' ')}
</span> </span>
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')} <!-- Deposit chip: pending = "will require deposit", confirmed+/!paid = "deposit due" -->
</span> {#if booking.deposit_required}
</div> {#if booking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
>
Deposit Due
</span>
{:else if booking.deposit_paid}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
>
Deposit Paid
</span>
{/if}
{/if}
<span class="text-gray-500">
{booking.services.map((s) => s.service_name).join(', ')}
</span>
</div>
<div class="mt-1 text-sm font-semibold text-gray-900"> <div class="mt-1 text-sm font-semibold text-gray-900">
£{booking.total_amount.toFixed(2)} £{booking.total_amount.toFixed(2)}
</div> </div>
@@ -728,17 +728,21 @@
// Handle specific error cases // Handle specific error cases
if (response.status === 409) { 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')) { } else if (errorMessage.includes('patch test') || errorMessage.includes('Patch test')) {
toast.error(errorMessage + ' Please complete a patch test first.'); 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); toast.error(errorMessage);
} else if (response.status === 400) { } else if (response.status === 400) {
toast.error(errorMessage); toast.error(errorMessage);
} else { } else {
toast.error('Failed to submit booking: ' + errorMessage); toast.error('Failed to submit booking: ' + errorMessage);
} }
console.error('Booking submission failed:', response.status, errorText); console.error('Booking submission failed:', response.status, errorText);
} }
} catch (error) { } catch (error) {
+20
View File
@@ -313,6 +313,26 @@ CREATE TABLE exceptional_group_applications (
CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications(week_start); 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 -- PAYMENTS TABLE