You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { X, Loader2, AlertCircle, Calendar, Clock, Users, FileText, MapPin } from 'lucide-react';
|
|
import { createSlotInstance } from '@/src/lib/api/slot-instances';
|
|
import type { CreateSlotInstanceRequest, SlotInstanceError } from '@/src/types/slot-instances';
|
|
import type { Court } from '@/src/types/courts';
|
|
|
|
interface ManualSlotModalProps {
|
|
clubId: number;
|
|
courts: Court[];
|
|
initialDate: string;
|
|
onClose: () => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export default function ManualSlotModal({
|
|
clubId,
|
|
courts,
|
|
initialDate,
|
|
onClose,
|
|
onSuccess,
|
|
}: ManualSlotModalProps) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<SlotInstanceError | null>(null);
|
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
|
|
|
// Form state
|
|
const [courtId, setCourtId] = useState<number | null>(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<string, string> = {};
|
|
|
|
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<string, string> = {};
|
|
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 (
|
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
<div className="bg-white rounded-2xl p-8 max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-900">Create Manual Slot</h2>
|
|
<p className="text-sm text-slate-600 mt-1">
|
|
Create a one-off booking slot
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Info box */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
<p className="text-sm text-blue-900">
|
|
Manual slots are independent of slot definitions and won't be affected
|
|
by the materializer's automatic slot generation or cleanup.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error display */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
|
<div className="flex items-start space-x-3">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-sm text-red-900 font-medium">{error.title}</p>
|
|
<p className="text-sm text-red-700 mt-1">{error.detail}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Court selection */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<MapPin className="w-4 h-4 inline mr-1" />
|
|
Court *
|
|
</label>
|
|
<select
|
|
value={courtId ?? ''}
|
|
onChange={(e) => setCourtId(parseInt(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.court_id ? 'border-red-300' : 'border-slate-200'
|
|
}`}
|
|
>
|
|
<option value="">Select a court...</option>
|
|
{courts.map((court) => (
|
|
<option key={court.court_id} value={court.court_id}>
|
|
{court.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{validationErrors.court_id && (
|
|
<p className="mt-1 text-sm text-red-600">{validationErrors.court_id}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Date */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<Calendar className="w-4 h-4 inline mr-1" />
|
|
Date *
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => 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 && (
|
|
<p className="mt-1 text-sm text-red-600">{validationErrors.date}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Time range */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<Clock className="w-4 h-4 inline mr-1" />
|
|
Start Time *
|
|
</label>
|
|
<input
|
|
type="time"
|
|
value={startTime}
|
|
onChange={(e) => 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 && (
|
|
<p className="mt-1 text-sm text-red-600">{validationErrors.start_time}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<Clock className="w-4 h-4 inline mr-1" />
|
|
End Time *
|
|
</label>
|
|
<input
|
|
type="time"
|
|
value={endTime}
|
|
onChange={(e) => 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 && (
|
|
<p className="mt-1 text-sm text-red-600">{validationErrors.end_time}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Duration display */}
|
|
<div className="bg-slate-50 rounded-lg px-4 py-3 text-center">
|
|
<span className="text-sm text-slate-600">Duration: </span>
|
|
<span className="text-sm font-semibold text-slate-900">{getDuration()}</span>
|
|
</div>
|
|
|
|
{/* Capacity */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<Users className="w-4 h-4 inline mr-1" />
|
|
Capacity *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="20"
|
|
value={capacity}
|
|
onChange={(e) => 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 && (
|
|
<p className="mt-1 text-sm text-red-600">{validationErrors.capacity}</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
Number of players this slot can accommodate
|
|
</p>
|
|
</div>
|
|
|
|
{/* Reason/Notes */}
|
|
<div>
|
|
<label className="block text-sm font-semibold text-slate-700 mb-2">
|
|
<FileText className="w-4 h-4 inline mr-1" />
|
|
Notes (optional)
|
|
</label>
|
|
<textarea
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
disabled={loading}
|
|
rows={2}
|
|
placeholder="e.g., Tournament slot, Private event, etc."
|
|
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-200">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
disabled={loading}
|
|
className="px-6 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="inline-flex items-center px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
|
|
>
|
|
{loading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
Create Slot
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|