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 */} +
+ +