From 308d9d70bf8e852831dd021a0fd099886a7e6a47 Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Thu, 27 Nov 2025 16:38:39 +0100 Subject: [PATCH] feat: add slot instances management UI Add complete admin UI for managing individual slot instances: Components: - SlotInstancesComponent: day view with date navigation and filters - SlotInstanceEditModal: edit times, capacity, convert to manual - ManualSlotModal: create one-off manual slots Features: - Date picker with prev/next day navigation - Filter by court and show/hide cancelled slots - Group slots by court in table view - Status badges (available, pending, booked, cancelled) - Origin badges (template, manual, maintenance) - Convert definition-based slots to manual (detach from materializer) - Create manual slots for special events - Edit slot times/capacity (protected when has bookings) - Cancel and delete slots with confirmation API Client: - getSlotInstances, createSlotInstance, updateSlotInstance, deleteSlotInstance - cancelSlotInstance, convertToManualSlot helpers Types: - SlotInstance, SlotInstancesResponse - CreateSlotInstanceRequest, UpdateSlotInstanceRequest - Helper functions for formatting and status colors Also adds "Slot Instances" tab to ClubDetailTabs navigation. --- .../admin/clubs/[club_id]/ClubDetailTabs.tsx | 6 + .../slot-instances/ManualSlotModal.tsx | 329 ++++++++++++ .../slot-instances/SlotInstanceEditModal.tsx | 324 ++++++++++++ .../slot-instances/SlotInstancesComponent.tsx | 480 ++++++++++++++++++ .../clubs/[club_id]/slot-instances/page.tsx | 17 + src/lib/api/slot-instances.ts | 216 ++++++++ src/types/slot-instances.ts | 201 ++++++++ 7 files changed, 1573 insertions(+) create mode 100644 src/app/[locale]/admin/clubs/[club_id]/slot-instances/ManualSlotModal.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/slot-instances/SlotInstanceEditModal.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/slot-instances/SlotInstancesComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/slot-instances/page.tsx create mode 100644 src/lib/api/slot-instances.ts create mode 100644 src/types/slot-instances.ts diff --git a/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx index f4b55ac..0f808aa 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/ClubDetailTabs.tsx @@ -196,6 +196,12 @@ export default function ClubDetailTabs({ clubId }: ClubDetailTabsProps) { > Slot Definitions + + Slot Instances + void; + onSuccess: () => void; +} + +export default function ManualSlotModal({ + clubId, + courts, + initialDate, + onClose, + onSuccess, +}: ManualSlotModalProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [validationErrors, setValidationErrors] = useState>({}); + + // Form state + const [courtId, setCourtId] = useState(courts[0]?.court_id ?? null); + const [date, setDate] = useState(initialDate); + const [startTime, setStartTime] = useState('09:00'); + const [endTime, setEndTime] = useState('10:30'); + const [capacity, setCapacity] = useState(4); + const [reason, setReason] = useState(''); + + function validateForm(): boolean { + const errors: Record = {}; + + if (!courtId) { + errors.court_id = 'Please select a court'; + } + + if (!date) { + errors.date = 'Please select a date'; + } + + if (!startTime) { + errors.start_time = 'Please enter a start time'; + } + + if (!endTime) { + errors.end_time = 'Please enter an end time'; + } + + if (startTime && endTime && startTime >= endTime) { + errors.end_time = 'End time must be after start time'; + } + + if (capacity < 1) { + errors.capacity = 'Capacity must be at least 1'; + } + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setLoading(true); + setError(null); + + // Build the datetime strings + // Note: We're creating local datetime strings - the backend will handle timezone conversion + const startsAt = `${date}T${startTime}:00`; + const endsAt = `${date}T${endTime}:00`; + + const request: CreateSlotInstanceRequest = { + court_id: courtId!, + starts_at: startsAt, + ends_at: endsAt, + capacity, + reason: reason.trim() || undefined, + }; + + const result = await createSlotInstance(clubId, request); + + if (result.success) { + onSuccess(); + } else { + setError(result.error); + + // Handle field-level errors + if (result.error.errors) { + const fieldErrors: Record = {}; + for (const err of result.error.errors) { + fieldErrors[err.field] = err.message; + } + setValidationErrors(fieldErrors); + } + } + + setLoading(false); + } + + // Calculate duration for display + function getDuration(): string { + if (!startTime || !endTime) return '-'; + + const [startH, startM] = startTime.split(':').map(Number); + const [endH, endM] = endTime.split(':').map(Number); + + const startMins = startH * 60 + startM; + const endMins = endH * 60 + endM; + const diffMins = endMins - startMins; + + if (diffMins <= 0) return '-'; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + + if (hours === 0) return `${mins}min`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; + } + + return ( +
+
+ {/* Header */} +
+
+

Create Manual Slot

+

+ Create a one-off booking slot +

+
+ +
+ + {/* Info box */} +
+

+ Manual slots are independent of slot definitions and won't be affected + by the materializer's automatic slot generation or cleanup. +

+
+ + {/* Error display */} + {error && ( +
+
+ +
+

{error.title}

+

{error.detail}

+
+
+
+ )} + + {/* Form */} +
+ {/* Court selection */} +
+ + + {validationErrors.court_id && ( +

{validationErrors.court_id}

+ )} +
+ + {/* Date */} +
+ + setDate(e.target.value)} + disabled={loading} + className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${ + validationErrors.date ? 'border-red-300' : 'border-slate-200' + }`} + /> + {validationErrors.date && ( +

{validationErrors.date}

+ )} +
+ + {/* Time range */} +
+
+ + setStartTime(e.target.value)} + disabled={loading} + className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${ + validationErrors.start_time ? 'border-red-300' : 'border-slate-200' + }`} + /> + {validationErrors.start_time && ( +

{validationErrors.start_time}

+ )} +
+
+ + setEndTime(e.target.value)} + disabled={loading} + className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${ + validationErrors.end_time ? 'border-red-300' : 'border-slate-200' + }`} + /> + {validationErrors.end_time && ( +

{validationErrors.end_time}

+ )} +
+
+ + {/* Duration display */} +
+ Duration: + {getDuration()} +
+ + {/* Capacity */} +
+ + setCapacity(parseInt(e.target.value) || 1)} + disabled={loading} + className={`w-full px-4 py-2 border-2 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500 ${ + validationErrors.capacity ? 'border-red-300' : 'border-slate-200' + }`} + /> + {validationErrors.capacity && ( +

{validationErrors.capacity}

+ )} +

+ Number of players this slot can accommodate +

+
+ + {/* Reason/Notes */} +
+ +