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.

407 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { X, Loader2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { generateSlotDefinitions, getSlotDefinitionPresets } from '@/src/lib/api/slot-definitions';
import type { Court } from '@/src/types/courts';
import type {
SlotDefinitionPreset,
GenerateSlotDefinitionsRequest,
PatternOverrides,
DayOfWeek,
PresetInfo,
} from '@/src/types/slot-definitions';
import { DAY_NAMES } from '@/src/types/slot-definitions';
interface GenerateSlotDefinitionsModalProps {
facilityId: number;
courts: Court[];
onClose: () => void;
onSuccess: () => void;
}
export default function GenerateSlotDefinitionsModal({
facilityId,
courts,
onClose,
onSuccess,
}: GenerateSlotDefinitionsModalProps) {
// Preset data
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [loadingPresets, setLoadingPresets] = useState(true);
// Form state
const [preset, setPreset] = useState<SlotDefinitionPreset>('workday_standard');
const [selectedCourtIds, setSelectedCourtIds] = useState<number[]>([]);
const [validFrom, setValidFrom] = useState(() => {
// Default to today's date in YYYY-MM-DD format
const today = new Date();
return today.toISOString().split('T')[0];
});
const [validTo, setValidTo] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
// Pattern overrides
const [overrideDays, setOverrideDays] = useState<DayOfWeek[]>([]);
const [overrideStartTime, setOverrideStartTime] = useState('');
const [overrideEndTime, setOverrideEndTime] = useState('');
const [overrideDuration, setOverrideDuration] = useState('');
const [overrideInterval, setOverrideInterval] = useState('');
const [overrideCapacity, setOverrideCapacity] = useState('');
// UI state
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch presets on mount
useEffect(() => {
async function loadPresets() {
const result = await getSlotDefinitionPresets();
if (result.success) {
setPresets(result.data.data.presets);
setLoadingPresets(false);
} else {
setError('Failed to load presets');
setLoadingPresets(false);
}
}
loadPresets();
}, []);
function handleSelectAllCourts() {
if (selectedCourtIds.length === courts.length) {
setSelectedCourtIds([]);
} else {
setSelectedCourtIds(courts.map(c => c.court_id));
}
}
function handleCourtToggle(courtId: number) {
setSelectedCourtIds(prev =>
prev.includes(courtId)
? prev.filter(id => id !== courtId)
: [...prev, courtId]
);
}
function handleDayToggle(day: DayOfWeek) {
setOverrideDays(prev =>
prev.includes(day)
? prev.filter(d => d !== day)
: [...prev, day]
);
}
function buildPatternOverrides(): PatternOverrides | undefined {
if (!showAdvanced) return undefined;
const overrides: PatternOverrides = {};
if (overrideDays.length > 0) overrides.days = overrideDays;
if (overrideStartTime) overrides.start_time = `${overrideStartTime}:00`;
if (overrideEndTime) overrides.end_time = `${overrideEndTime}:00`;
if (overrideDuration) overrides.duration_minutes = parseInt(overrideDuration, 10);
if (overrideInterval) overrides.interval_minutes = parseInt(overrideInterval, 10);
if (overrideCapacity) overrides.capacity = parseInt(overrideCapacity, 10);
return Object.keys(overrides).length > 0 ? overrides : undefined;
}
async function handleSubmit() {
if (selectedCourtIds.length === 0) {
setError('Please select at least one court');
return;
}
if (!validFrom) {
setError('Please select a valid from date');
return;
}
setLoading(true);
setError(null);
const request: GenerateSlotDefinitionsRequest = {
preset,
court_ids: selectedCourtIds,
pattern_overrides: buildPatternOverrides(),
valid_from: validFrom,
};
if (validTo) request.valid_to = validTo;
const result = await generateSlotDefinitions(facilityId, request);
if (result.success) {
onSuccess();
onClose();
} else {
setError(result.error.detail);
}
setLoading(false);
}
const selectedPreset = presets.find((p) => p.key === preset);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white border-b-2 border-slate-200 px-6 py-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-slate-900">Generate Slot Definitions</h2>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-600" />
</button>
</div>
{/* Body */}
<div className="p-6 space-y-6">
{/* Loading State */}
{loadingPresets && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-slate-900 animate-spin" />
<span className="ml-3 text-slate-600">Loading presets...</span>
</div>
)}
{/* Preset Selection */}
{!loadingPresets && presets.length > 0 && (
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Preset Schedule
</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as SlotDefinitionPreset)}
className="w-full px-4 py-3 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900 font-medium"
>
{presets.map((p) => (
<option key={p.key} value={p.key}>
{p.title}
</option>
))}
</select>
{selectedPreset && (
<p className="mt-2 text-sm text-slate-600">{selectedPreset.description}</p>
)}
</div>
)}
{/* Form Elements */}
{!loadingPresets && (
<>
{/* Court Selection */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-semibold text-slate-700">
Select Courts
</label>
<button
onClick={handleSelectAllCourts}
className="text-sm text-slate-600 hover:text-slate-900 font-medium"
>
{selectedCourtIds.length === courts.length ? 'Deselect All' : 'Select All'}
</button>
</div>
<div className="border-2 border-slate-200 rounded-lg p-4 space-y-2 max-h-48 overflow-y-auto">
{courts.map(court => (
<label
key={court.court_id}
className="flex items-center space-x-3 p-2 hover:bg-slate-50 rounded-lg cursor-pointer"
>
<input
type="checkbox"
checked={selectedCourtIds.includes(court.court_id)}
onChange={() => handleCourtToggle(court.court_id)}
className="w-4 h-4"
/>
<span className="text-slate-900 font-medium">{court.name}</span>
</label>
))}
</div>
<p className="mt-2 text-sm text-slate-600">
{selectedCourtIds.length} court{selectedCourtIds.length !== 1 ? 's' : ''} selected
</p>
</div>
{/* Validity Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Valid From <span className="text-red-600">*</span>
</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Valid To (Optional)
</label>
<input
type="date"
value={validTo}
onChange={(e) => setValidTo(e.target.value)}
className="w-full px-4 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
</div>
{/* Advanced Options */}
<div className="border-2 border-slate-200 rounded-lg">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<span className="text-sm font-semibold text-slate-700">Advanced Pattern Overrides</span>
{showAdvanced ? (
<ChevronUp className="w-5 h-5 text-slate-600" />
) : (
<ChevronDown className="w-5 h-5 text-slate-600" />
)}
</button>
{showAdvanced && (
<div className="px-4 pb-4 space-y-4 border-t-2 border-slate-200 pt-4">
{/* Custom Days */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Custom Days (Override Preset)
</label>
<div className="flex flex-wrap gap-2">
{[0, 1, 2, 3, 4, 5, 6].map(day => (
<button
key={day}
onClick={() => handleDayToggle(day as DayOfWeek)}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
overrideDays.includes(day as DayOfWeek)
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{DAY_NAMES[day as DayOfWeek].slice(0, 3)}
</button>
))}
</div>
</div>
{/* Time Range */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Start Time
</label>
<input
type="time"
value={overrideStartTime}
onChange={(e) => setOverrideStartTime(e.target.value)}
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
End Time
</label>
<input
type="time"
value={overrideEndTime}
onChange={(e) => setOverrideEndTime(e.target.value)}
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
</div>
{/* Duration and Interval */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Duration (minutes)
</label>
<input
type="number"
value={overrideDuration}
onChange={(e) => setOverrideDuration(e.target.value)}
min="1"
placeholder="90"
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Interval (minutes)
</label>
<input
type="number"
value={overrideInterval}
onChange={(e) => setOverrideInterval(e.target.value)}
min="1"
placeholder="90"
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
</div>
{/* Capacity */}
<div>
<label className="block text-sm font-semibold text-slate-700 mb-2">
Capacity (players)
</label>
<input
type="number"
value={overrideCapacity}
onChange={(e) => setOverrideCapacity(e.target.value)}
min="1"
placeholder="4"
className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900"
/>
</div>
</div>
)}
</div>
</>
)}
{/* Error Message */}
{error && (
<div className="p-4 bg-red-50 border-2 border-red-200 rounded-lg flex items-start space-x-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-red-700">{error}</div>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-slate-50 border-t-2 border-slate-200 px-6 py-4 flex items-center justify-between">
<button
onClick={onClose}
className="px-6 py-2 text-slate-700 font-semibold hover:bg-slate-200 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || loadingPresets || selectedCourtIds.length === 0}
className="px-6 py-2 bg-slate-900 text-white font-semibold rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span>Generating...</span>
</>
) : (
<span>Generate Slot Definitions</span>
)}
</button>
</div>
</div>
</div>
);
}