diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/CloneSlotDefinitionModal.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/CloneSlotDefinitionModal.tsx new file mode 100644 index 0000000..3705551 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/CloneSlotDefinitionModal.tsx @@ -0,0 +1,294 @@ +'use client'; + +import { useState } from 'react'; +import { X, Loader2, AlertCircle } from 'lucide-react'; +import { cloneSlotDefinition } from '@/src/lib/api/slot-definitions'; +import type { Court } from '@/src/types/courts'; +import type { + SlotDefinition, + CloneSlotDefinitionRequest, + DayOfWeek, +} from '@/src/types/slot-definitions'; +import { DAY_NAMES, formatTime, calculateEndTime } from '@/src/types/slot-definitions'; + +interface CloneSlotDefinitionModalProps { + clubId: number; + sourceDefinition: SlotDefinition; + courts: Court[]; + onClose: () => void; + onSuccess: () => void; +} + +export default function CloneSlotDefinitionModal({ + clubId, + sourceDefinition, + courts, + onClose, + onSuccess, +}: CloneSlotDefinitionModalProps) { + // Form state + const [selectedCourtIds, setSelectedCourtIds] = useState([]); + const [selectedDays, setSelectedDays] = useState([sourceDefinition.dow]); + const [validFrom, setValidFrom] = useState(''); + const [validTo, setValidTo] = useState(''); + + // UI state + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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) { + setSelectedDays(prev => + prev.includes(day) + ? prev.filter(d => d !== day) + : [...prev, day] + ); + } + + async function handleSubmit() { + if (selectedCourtIds.length === 0) { + setError('Please select at least one target court'); + return; + } + + if (selectedDays.length === 0) { + setError('Please select at least one target day'); + return; + } + + setLoading(true); + setError(null); + + const request: CloneSlotDefinitionRequest = { + target_court_ids: selectedCourtIds, + target_days: selectedDays, + }; + + if (validFrom) request.valid_from = validFrom; + if (validTo) request.valid_to = validTo; + + const result = await cloneSlotDefinition(clubId, sourceDefinition.slot_definition_id, request); + + if (result.success) { + onSuccess(); + onClose(); + } else { + setError(result.error.detail); + } + + setLoading(false); + } + + const sourceCourt = courts.find(c => c.court_id === sourceDefinition.court_id); + const estimatedCount = selectedCourtIds.length * selectedDays.length; + + return ( +
+
+ {/* Header */} +
+

Clone Slot Definition

+ +
+ + {/* Body */} +
+ {/* Source Definition Info */} +
+

Source Definition

+
+
+
Court
+
{sourceCourt?.name || `Court ${sourceDefinition.court_id}`}
+
+
+
Day
+
{DAY_NAMES[sourceDefinition.dow]}
+
+
+
Time
+
+ {formatTime(sourceDefinition.starts_at)} - {calculateEndTime(sourceDefinition.starts_at, sourceDefinition.duration_minutes)} +
+
+
+
Duration
+
{sourceDefinition.duration_minutes} min
+
+
+
Capacity
+
{sourceDefinition.capacity} players
+
+ {sourceDefinition.rule?.description && ( +
+
Description
+
{sourceDefinition.rule.description}
+
+ )} +
+
+ + {/* Target Courts */} +
+
+ + +
+
+ {courts.map(court => ( + + ))} +
+

+ {selectedCourtIds.length} court{selectedCourtIds.length !== 1 ? 's' : ''} selected +

+
+ + {/* Target Days */} +
+ +
+ {[0, 1, 2, 3, 4, 5, 6].map(day => ( + + ))} +
+

+ {selectedDays.length} day{selectedDays.length !== 1 ? 's' : ''} selected +

+
+ + {/* Validity Dates Override */} +
+ +
+
+ + setValidFrom(e.target.value)} + placeholder={sourceDefinition.valid_from} + className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> +
+
+ + setValidTo(e.target.value)} + placeholder={sourceDefinition.valid_to || 'No end date'} + className="w-full px-3 py-2 border-2 border-slate-200 rounded-lg focus:outline-none focus:border-slate-900" + /> +
+
+

+ Leave empty to use source definition dates: {sourceDefinition.valid_from} + {sourceDefinition.valid_to ? ` to ${sourceDefinition.valid_to}` : ' (no end date)'} +

+
+ + {/* Preview */} +
+

+ This will create approximately {estimatedCount} slot definition{estimatedCount !== 1 ? 's' : ''} +

+

+ {selectedCourtIds.length} court{selectedCourtIds.length !== 1 ? 's' : ''} × {selectedDays.length} day{selectedDays.length !== 1 ? 's' : ''} = {estimatedCount} definition{estimatedCount !== 1 ? 's' : ''} +

+
+ + {/* Error Message */} + {error && ( +
+ +
{error}
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/GenerateSlotDefinitionsModal.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/GenerateSlotDefinitionsModal.tsx new file mode 100644 index 0000000..3a63142 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/GenerateSlotDefinitionsModal.tsx @@ -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('workday_standard'); + const [selectedCourtIds, setSelectedCourtIds] = useState([]); + const [validFrom, setValidFrom] = useState(''); + const [validTo, setValidTo] = useState(''); + const [showAdvanced, setShowAdvanced] = useState(false); + + // Pattern overrides + const [overrideDays, setOverrideDays] = useState([]); + 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(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 ( +
+
+ {/* Header */} +
+

Generate Slot Definitions

+ +
+ + {/* Body */} +
+ {/* Preset Selection */} +
+ + + {selectedPreset && ( +

{selectedPreset.description}

+ )} +
+ + {/* Court Selection */} +
+
+ + +
+
+ {courts.map(court => ( + + ))} +
+

+ {selectedCourtIds.length} court{selectedCourtIds.length !== 1 ? 's' : ''} selected +

+
+ + {/* Validity Dates */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Advanced Options */} +
+ + + {showAdvanced && ( +
+ {/* Custom Days */} +
+ +
+ {[0, 1, 2, 3, 4, 5, 6].map(day => ( + + ))} +
+
+ + {/* Time Range */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Duration and Interval */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Capacity */} +
+ + 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" + /> +
+
+ )} +
+ + {/* Error Message */} + {error && ( +
+ +
{error}
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx index a06cfa3..89623d7 100644 --- a/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx +++ b/src/app/[locale]/admin/clubs/[club_id]/slot-definitions/SlotDefinitionsComponent.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Calendar, Plus, Loader2, AlertCircle, Edit, Trash2, ArrowLeft } from 'lucide-react'; +import { Calendar, Plus, Loader2, AlertCircle, Edit, Trash2, ArrowLeft, Wand2, Copy } from 'lucide-react'; import Link from 'next/link'; import useTranslation from '@/src/hooks/useTranslation'; import { getSlotDefinitions, deleteSlotDefinition } from '@/src/lib/api/slot-definitions'; @@ -11,6 +11,8 @@ import { getAdminClubDetail } from '@/src/lib/api/admin-clubs'; import type { Court } from '@/src/types/courts'; import SlotDefinitionForm from './SlotDefinitionForm'; import MaterialisationStatusPanel from './MaterialisationStatusPanel'; +import GenerateSlotDefinitionsModal from './GenerateSlotDefinitionsModal'; +import CloneSlotDefinitionModal from './CloneSlotDefinitionModal'; interface SlotDefinitionsComponentProps { clubId: number; @@ -24,6 +26,8 @@ export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComp const [error, setError] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [editingDefinition, setEditingDefinition] = useState(undefined); + const [showGenerateModal, setShowGenerateModal] = useState(false); + const [cloningDefinition, setCloningDefinition] = useState(undefined); useEffect(() => { loadData(); @@ -156,13 +160,22 @@ export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComp

- +
+ + +
@@ -246,6 +259,13 @@ export default function SlotDefinitionsComponent({ clubId }: SlotDefinitionsComp
+
); }