feat: add Generate and Clone UI for slot definitions
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
Built complete bulk operations UI for slot definitions based on Backend Brooke's spec (BUILD:345). New Components: - GenerateSlotDefinitionsModal (380 lines) - Preset selector with 4 options (workday_standard, weekend_extended, all_week_uniform, hourly_daytime) - Court multi-select with Select All - Validity date pickers (optional) - Advanced pattern overrides (collapsible): custom days, time range, duration, interval, capacity - Real-time validation and error handling - CloneSlotDefinitionModal (280 lines) - Source definition display with full details - Target courts multi-select - Target days multi-select (pre-selected source day) - Validity date overrides (optional) - Preview of estimated clone count Integration: - Added Generate button (blue, magic wand icon) to page header - Added Clone button (blue copy icon) to each table row - Wired up modals with success callbacks to reload data - Success callbacks trigger table refresh Features: - All modals use professional slate theme - Loading states with spinners - Error display with user-friendly messages - Form validation before submission - Modal backdrop dismissal Ready for testing against staging (BUILD:345).master
parent
13ec348fb2
commit
1ebe61fa40
@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Loader2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { generateSlotDefinitions } from '@/src/lib/api/slot-definitions';
|
||||
import type { Court } from '@/src/types/courts';
|
||||
import type {
|
||||
SlotDefinitionPreset,
|
||||
GenerateSlotDefinitionsRequest,
|
||||
PatternOverrides,
|
||||
DayOfWeek,
|
||||
} from '@/src/types/slot-definitions';
|
||||
import { PRESET_OPTIONS, DAY_NAMES } from '@/src/types/slot-definitions';
|
||||
|
||||
interface GenerateSlotDefinitionsModalProps {
|
||||
clubId: number;
|
||||
courts: Court[];
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function GenerateSlotDefinitionsModal({
|
||||
clubId,
|
||||
courts,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: GenerateSlotDefinitionsModalProps) {
|
||||
// Form state
|
||||
const [preset, setPreset] = useState<SlotDefinitionPreset>('workday_standard');
|
||||
const [selectedCourtIds, setSelectedCourtIds] = useState<number[]>([]);
|
||||
const [validFrom, setValidFrom] = useState('');
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const request: GenerateSlotDefinitionsRequest = {
|
||||
preset,
|
||||
court_ids: selectedCourtIds,
|
||||
pattern_overrides: buildPatternOverrides(),
|
||||
};
|
||||
|
||||
if (validFrom) request.valid_from = validFrom;
|
||||
if (validTo) request.valid_to = validTo;
|
||||
|
||||
const result = await generateSlotDefinitions(clubId, request);
|
||||
|
||||
if (result.success) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error.detail);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const selectedPreset = PRESET_OPTIONS.find(p => p.id === 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">
|
||||
{/* Preset Selection */}
|
||||
<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"
|
||||
>
|
||||
{PRESET_OPTIONS.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name} ({opt.days}, {opt.hours}, {opt.duration})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<p className="mt-2 text-sm text-slate-600">{selectedPreset.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 (Optional)
|
||||
</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 || 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue