import React, { useEffect, useMemo, useState } from 'react' const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon... const QUICK_PRESETS = [ { label: 'Werkdag', weekdays: [1, 2, 3, 4, 5] }, { label: 'Weekend', weekdays: [0, 6] }, { label: 'Dagelijks', weekdays: [0, 1, 2, 3, 4, 5, 6] }, ] const SERVICE_PRESETS = [ { label: 'Ochtendspits', start: '06:00', end: '09:00', intervalMinutes: 15 }, { label: 'Middagslag', start: '12:00', end: '16:00', intervalMinutes: 30 }, { label: 'Avondspits', start: '16:00', end: '20:00', intervalMinutes: 20 }, ] const getPresetStorageKey = (user) => `transit.fixedSchedulePresets.${user?.userCode || 'guest'}` const toInputDate = (value) => { const date = new Date(value) if (Number.isNaN(date.getTime())) return new Date().toISOString().split('T')[0] return date.toISOString().split('T')[0] } const toInputTime = (value) => { const date = new Date(value) if (Number.isNaN(date.getTime())) return '08:00' return `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}` } const normalizeTime = (value) => { const trimmed = String(value || '').trim() if (!trimmed) return '' if (/^\d{2}:\d{2}$/.test(trimmed)) return trimmed if (/^\d{1,2}:\d{2}$/.test(trimmed)) { const [hours, minutes] = trimmed.split(':') return `${String(Number(hours)).padStart(2, '0')}:${minutes}` } return '' } const parseTimeToMinutes = (time) => { const normalized = normalizeTime(time) if (!normalized) return null const [hours, minutes] = normalized.split(':').map(Number) return hours * 60 + minutes } const addMinutesToTime = (time, minutesToAdd) => { const normalized = normalizeTime(time) if (!normalized || !Number.isFinite(Number(minutesToAdd))) return null const mins = parseTimeToMinutes(normalized) if (mins === null) return null const total = (mins + Number(minutesToAdd)) const hours = Math.floor((total % (24 * 60)) / 60) const minutes = (total % 60 + 60) % 60 return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` } const buildTimeRange = (start, end, intervalMinutes, allowWrap = false) => { const normalizedStart = normalizeTime(start) const normalizedEnd = normalizeTime(end) const step = Number(intervalMinutes) if (!normalizedStart || !normalizedEnd || !Number.isFinite(step) || step <= 0) { return [] } const startMinutes = parseTimeToMinutes(normalizedStart) let endMinutes = parseTimeToMinutes(normalizedEnd) if (startMinutes === null || endMinutes === null) return [] if (endMinutes < startMinutes) { if (!allowWrap) return [] // wrap end to next day endMinutes += 24 * 60 } const times = [] for (let minute = startMinutes; minute <= endMinutes; minute += step) { const m = minute % (24 * 60) const hours = Math.floor(m / 60) const minutes = m % 60 times.push(`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`) } return times } const addDaysToDate = (isoDate, days) => { try { const d = new Date(isoDate + 'T00:00:00') d.setUTCDate(d.getUTCDate() + Number(days)) return d.toISOString().split('T')[0] } catch { return isoDate } } const buildCountRange = (start, intervalMinutes, count) => { const normalizedStart = normalizeTime(start) const step = Number(intervalMinutes) const total = Number(count) if (!normalizedStart || !Number.isFinite(step) || step <= 0 || !Number.isFinite(total) || total <= 0) { return [] } const startMinutes = parseTimeToMinutes(normalizedStart) if (startMinutes === null) return [] const times = [] for (let index = 0; index < total; index += 1) { const minute = startMinutes + (index * step) const hours = Math.floor(minute / 60) % 24 const minutes = minute % 60 times.push(`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`) } return times } const dedupeTimes = (times) => { const seen = new Set() return times .map(normalizeTime) .filter(Boolean) .filter(time => { if (seen.has(time)) return false seen.add(time) return true }) .sort((a, b) => parseTimeToMinutes(a) - parseTimeToMinutes(b)) } const FixedScheduleEditor = ({ client, user, scheduleTemplates, busLines, buses, drivers, message, setMessage, onScheduleChange, }) => { const [editingScheduleId, setEditingScheduleId] = useState(null) const [scheduleMode, setScheduleMode] = useState('single') const [selectedLine, setSelectedLine] = useState('') const [selectedBus, setSelectedBus] = useState('') const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [driverId, setDriverId] = useState('') const [selectedWeekdays, setSelectedWeekdays] = useState([]) const [singleTime, setSingleTime] = useState('08:00') const [batchStartTime, setBatchStartTime] = useState('06:00') const [batchEndTime, setBatchEndTime] = useState('09:00') const [batchInterval, setBatchInterval] = useState(15) const [batchCount, setBatchCount] = useState(6) const [batchTimes, setBatchTimes] = useState(['08:00']) const [newBatchTime, setNewBatchTime] = useState('') const [batchWrap, setBatchWrap] = useState(false) const [appendMode, setAppendMode] = useState(false) const [presetsOpen, setPresetsOpen] = useState(true) const [batchOpen, setBatchOpen] = useState(true) const [templatesOpen, setTemplatesOpen] = useState(true) const [presetName, setPresetName] = useState('') const [savedPresets, setSavedPresets] = useState([]) const [deleteConfirm, setDeleteConfirm] = useState(null) const [showDriverAssignment, setShowDriverAssignment] = useState(false) const [driverAssignments, setDriverAssignments] = useState([]) const [availableDrivers, setAvailableDrivers] = useState([]) const [pendingBatchData, setPendingBatchData] = useState(null) const isAdmin = Boolean(user?.isAdmin) const resetForm = () => { setEditingScheduleId(null) setScheduleMode('single') setSelectedLine('') setSelectedBus('') setSelectedDate(new Date().toISOString().split('T')[0]) setDriverId('') setSelectedWeekdays([]) setSingleTime('08:00') setBatchStartTime('06:00') setBatchEndTime('09:00') setBatchInterval(15) setBatchCount(6) setBatchTimes(['08:00']) setNewBatchTime('') setPresetName('') } useEffect(() => { try { const raw = localStorage.getItem(getPresetStorageKey(user)) if (!raw) { setSavedPresets([]) return } const parsed = JSON.parse(raw) setSavedPresets(Array.isArray(parsed) ? parsed : []) } catch { setSavedPresets([]) } }, [user]) // Load available drivers when driver assignment dialog opens useEffect(() => { if (!showDriverAssignment) return const loadDrivers = async () => { try { const res = await client.get('/admin/drivers') setAvailableDrivers(res.data.drivers || []) } catch (err) { console.error('Error loading drivers:', err) setAvailableDrivers([]) } } loadDrivers() }, [showDriverAssignment]) const persistPresets = (nextPresets) => { setSavedPresets(nextPresets) try { localStorage.setItem(getPresetStorageKey(user), JSON.stringify(nextPresets)) } catch { // ignore storage failures } } const toggleWeekday = (dayValue) => { setSelectedWeekdays(prev => prev.includes(dayValue) ? prev.filter(d => d !== dayValue) : [...prev, dayValue] ) } const setWeekdaysPreset = (weekdays) => { setSelectedWeekdays([...weekdays].sort((a, b) => a - b)) } const formatTime = (timeString) => { const date = new Date(timeString) return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) } const getWeekdayLabel = (weekdayValues) => { if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig' if (weekdayValues.length === 7) return 'Dagelijks' const labels = weekdayValues .slice() .sort((a, b) => a - b) .map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)]) return labels.join(', ') } const startEditingSchedule = (schedule) => { setMessage(null) setEditingScheduleId(schedule.id) setScheduleMode('single') setSelectedLine(String(schedule.busLineId)) setSelectedBus(String(schedule.busId)) setDriverId(schedule.driverId ? String(schedule.driverId) : '') setSelectedDate(toInputDate(schedule.startTime)) setSingleTime(toInputTime(schedule.startTime)) setSelectedWeekdays(Array.isArray(schedule.weekdays) ? [...schedule.weekdays] : []) window.scrollTo({ top: 0, behavior: 'smooth' }) } const addBatchTime = () => { const normalized = normalizeTime(newBatchTime) if (!normalized) return setBatchTimes(prev => dedupeTimes([...prev, normalized])) setNewBatchTime('') } const removeBatchTime = (time) => { setBatchTimes(prev => prev.filter(item => item !== time)) } const generateBatchTimes = () => { const generated = buildTimeRange(batchStartTime, batchEndTime, batchInterval, batchWrap) if (appendMode) { setBatchTimes(prev => dedupeTimes([...prev, ...generated])) } else { setBatchTimes(dedupeTimes(generated)) } } const generateBatchTimesByCount = () => { const generated = buildCountRange(batchStartTime, batchInterval, batchCount) if (appendMode) { setBatchTimes(prev => dedupeTimes([...prev, ...generated])) } else { setBatchTimes(dedupeTimes(generated)) } } const applyPreset = (preset) => { setBatchStartTime(preset.start) setBatchEndTime(preset.end) setBatchInterval(preset.intervalMinutes) // infer wrap when end < start if preset does not explicitly set wrap const inferredWrap = preset.batchWrap ?? (parseTimeToMinutes(preset.end) < parseTimeToMinutes(preset.start)) setBatchWrap(Boolean(inferredWrap)) setBatchTimes(buildTimeRange(preset.start, preset.end, preset.intervalMinutes, Boolean(inferredWrap))) setScheduleMode('batch') } const saveCurrentPreset = () => { const name = presetName.trim() if (!name) { setMessage('Geef een naam op voor het preset') return } const nextPreset = { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name, scheduleMode, selectedLine, selectedBus, driverId, selectedWeekdays, singleTime, batchStartTime, batchEndTime, batchInterval, batchCount, batchTimes, } persistPresets([nextPreset, ...savedPresets].slice(0, 12)) setPresetName('') setMessage(`Preset opgeslagen: ${name}`) } const loadPreset = (preset) => { setScheduleMode(preset.scheduleMode || 'single') setSelectedLine(preset.selectedLine || '') setSelectedBus(preset.selectedBus || '') setDriverId(preset.driverId || '') setSelectedWeekdays(Array.isArray(preset.selectedWeekdays) ? [...preset.selectedWeekdays] : []) setSingleTime(preset.singleTime || '08:00') setBatchStartTime(preset.batchStartTime || '06:00') setBatchEndTime(preset.batchEndTime || '09:00') setBatchInterval(Number(preset.batchInterval) || 15) const inferredWrap = preset.batchWrap ?? (parseTimeToMinutes(preset.batchEndTime) < parseTimeToMinutes(preset.batchStartTime)) setBatchWrap(Boolean(inferredWrap)) setBatchCount(Number(preset.batchCount) || 6) setBatchTimes(dedupeTimes(Array.isArray(preset.batchTimes) && preset.batchTimes.length > 0 ? preset.batchTimes : [preset.singleTime || '08:00'])) setMessage(`Preset geladen: ${preset.name}`) } const removePreset = (presetId) => { persistPresets(savedPresets.filter(preset => preset.id !== presetId)) } const cloneTemplateIntoForm = (schedule) => { setMessage(`Dienst gekopieerd naar het formulier: lijn ${schedule.lineNumber}`) setEditingScheduleId(null) setScheduleMode(Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0 ? 'batch' : 'single') setSelectedLine(String(schedule.busLineId)) setSelectedBus(String(schedule.busId)) setDriverId(schedule.driverId ? String(schedule.driverId) : '') setSelectedDate(toInputDate(schedule.startTime)) setSingleTime(toInputTime(schedule.startTime)) setSelectedWeekdays(Array.isArray(schedule.weekdays) ? [...schedule.weekdays] : []) const startT = toInputTime(schedule.startTime) const endT = toInputTime(schedule.endTime) setBatchStartTime(startT) setBatchEndTime(addMinutesToTime(startT, 120) || endT || '09:00') // if template end is earlier than start, assume it wraps to next day const inferredWrap = parseTimeToMinutes(endT) < parseTimeToMinutes(startT) setBatchWrap(Boolean(inferredWrap)) setBatchTimes([startT]) window.scrollTo({ top: 0, behavior: 'smooth' }) } const selectedWeekdayLabel = useMemo(() => getWeekdayLabel(selectedWeekdays), [selectedWeekdays]) const submitSingleSchedule = async () => { const startDateTime = `${selectedDate}T${singleTime}:00` const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b) const payload = { busLineId: Number(selectedLine), busId: Number(selectedBus), driverId: driverId ? Number(driverId) : null, startTime: startDateTime, weekdays: normalizedWeekdays, } const res = editingScheduleId ? await client.put(`/admin/schedules/${editingScheduleId}`, payload) : await client.post('/admin/schedules', payload) const dayLabels = normalizedWeekdays.length > 0 ? selectedWeekdayLabel : 'eenmalig' setMessage(editingScheduleId ? `Dienst bijgewerkt: #${res.data.schedule.id} (${dayLabels})` : `Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`) } const submitBatchSchedules = async () => { const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b) const uniqueTimes = dedupeTimes(batchTimes) if (uniqueTimes.length === 0) { throw new Error('Voeg minstens één vertrektijd toe voor de reeks') } const startBaseMinutes = parseTimeToMinutes(batchStartTime) const schedules = uniqueTimes.map(time => { const tmins = parseTimeToMinutes(time) let dateForTime = selectedDate if (batchWrap && startBaseMinutes !== null && tmins !== null && tmins < startBaseMinutes) { dateForTime = addDaysToDate(selectedDate, 1) } return { busLineId: Number(selectedLine), busId: Number(selectedBus), driverId: driverId ? Number(driverId) : null, startTime: `${dateForTime}T${time}:00`, weekdays: normalizedWeekdays, } }) // If no driver assigned, show driver assignment dialog if (!driverId) { setPendingBatchData({ schedules, times: uniqueTimes, dayLabels: normalizedWeekdays.length > 0 ? getWeekdayLabel(normalizedWeekdays) : 'eenmalig', busLineId: Number(selectedLine), busId: Number(selectedBus), weekdays: normalizedWeekdays, }) // Check for conflicts try { const res = await client.post('/admin/check-driver-conflicts', { generatedTimes: uniqueTimes.map(t => `${selectedDate}T${t}:00`), drivers: drivers || [], busLineId: Number(selectedLine), busId: Number(selectedBus), weekdays: normalizedWeekdays, }) setDriverAssignments(res.data.assignments || []) setShowDriverAssignment(true) return { skipped: true } } catch (err) { console.error('Error checking conflicts:', err) // Still show dialog even if conflict check failed setShowDriverAssignment(true) return { skipped: true } } } const payload = { schedules } const res = await client.post('/admin/schedules/bulk', payload) return { count: res.data.createdCount ?? res.data.schedules?.length ?? uniqueTimes.length, times: uniqueTimes, dayLabels: normalizedWeekdays.length > 0 ? getWeekdayLabel(normalizedWeekdays) : 'eenmalig', } } const submitSchedule = async (e) => { e.preventDefault() setMessage(null) if (!selectedLine || !selectedBus) { setMessage('Lijn en voertuig zijn verplicht') return } if (editingScheduleId) { try { await submitSingleSchedule() resetForm() onScheduleChange?.() } catch (err) { const errMsg = err.response?.data?.error || err.message setMessage('Error: ' + errMsg) } return } if (scheduleMode === 'batch') { try { const result = await submitBatchSchedules() setMessage(`Reeks aangemaakt: ${result.count} diensten op ${result.times.join(', ')} (${result.dayLabels})`) resetForm() onScheduleChange?.() } catch (err) { const errMsg = err.response?.data?.error || err.message setMessage('Error: ' + errMsg) } return } if (!singleTime) { setMessage('Start-tijd is verplicht') return } try { await submitSingleSchedule() resetForm() onScheduleChange?.() } catch (err) { const errMsg = err.response?.data?.error || err.message setMessage('Error: ' + errMsg) } } const deleteSchedule = async (scheduleId, lineNumber, busName) => { setMessage(null) try { await client.delete(`/admin/schedules/${scheduleId}`) setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`) setDeleteConfirm(null) if (editingScheduleId === scheduleId) { resetForm() } onScheduleChange?.() } catch (err) { const errMsg = err.response?.data?.error || err.message setMessage('Error: ' + errMsg) } } const previewBatchTimes = useMemo(() => dedupeTimes(batchTimes), [batchTimes]) return (

Vaste Diensten Beheren

Maak, wijzig of verwijder vaste diensten en herhaalde routeschema's

{isAdmin && (

{editingScheduleId ? 'Vaste dienst bewerken' : 'Nieuwe vaste dienst'}

{editingScheduleId ? 'Wijzig één bestaande dienst.' : 'Maak één rit of meteen een complete dienstenreeks met meerdere vertrektijden.'}

{editingScheduleId ? ( ) : ( <> )}
setSelectedDate(e.target.value)} /> {editingScheduleId || scheduleMode === 'single' ? ( setSingleTime(e.target.value)} placeholder="Start time" /> ) : (
{SERVICE_PRESETS.map(preset => ( ))}
)}
{QUICK_PRESETS.map(preset => ( ))}
{WEEKDAYS.map((day, idx) => { const dayValue = WEEKDAY_VALUES[idx] const active = selectedWeekdays.includes(dayValue) return ( ) })}
{!editingScheduleId && (
Sla huidige configuratie op en hergebruik hem later
{presetsOpen && (
setPresetName(e.target.value)} placeholder="Preset naam, bijvoorbeeld schooldag-ochtend" />
{savedPresets.length === 0 ? ( Nog geen presets opgeslagen ) : savedPresets.map(preset => ( ))}
)}
)} {!editingScheduleId && scheduleMode === 'batch' && (
{batchOpen && (
setBatchStartTime(e.target.value)} />
setBatchEndTime(e.target.value)} />
setBatchInterval(Number(e.target.value) || 15)} />
setBatchCount(Number(e.target.value) || 1)} />
setNewBatchTime(e.target.value)} />
Preview {previewBatchTimes.length} ritten
{previewBatchTimes.length === 0 ? ( Nog geen tijden toegevoegd ) : previewBatchTimes.map(time => ( {time} ))}
)}
)}
{editingScheduleId ? 'Je past één bestaande dienst aan.' : scheduleMode === 'batch' ? `Reeks wordt aangemaakt voor ${previewBatchTimes.length} vertrektijden.` : 'Maak één dienst aan met optionele herhaling.'}
)}

Bestaande vaste diensten

Bewerk of verwijder bestaande diensten
{templatesOpen && (
{scheduleTemplates.length === 0 ? (
Nog geen vaste diensten gevonden.
) : ( scheduleTemplates.slice() .sort((a, b) => a.lineNumber - b.lineNumber || new Date(a.startTime) - new Date(b.startTime)) .map(schedule => { return (
Lijn {schedule.lineNumber} {formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
{schedule.busName}
{schedule.licensePlate}
{schedule.driverName && (
{schedule.driverName}
)}
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
{isAdmin && (
)}
) }) )}
)} {message &&
{message}
} {deleteConfirm && (

Delete Service?

Lijn {deleteConfirm.lineNumber} - {deleteConfirm.busName} ({deleteConfirm.licensePlate})

{new Date(deleteConfirm.startTime).toLocaleString()}

)} {/* Driver Assignment Modal */} {showDriverAssignment && (

Verdeel ritten aan chauffeurs

{!driverAssignments || driverAssignments.length === 0 ? (

Laden...

) : ( <>
{driverAssignments.length} vertrektijden gevonden.
Klik op een chauffeur om deze ritt toe te wijzen.
{driverAssignments.map((assignment, idx) => (
Ritt {idx + 1}: {assignment.time.split('T')[1]?.slice(0, 5)} uur
{assignment.driverAssignments?.map((driverOpt, dIdx) => { const isAssigned = assignment.assignedDriverId === driverOpt.driverId const bgColor = isAssigned ? '#4caf50' : driverOpt.hasConflict ? '#ffcdd2' : '#e3f2fd' const textColor = isAssigned ? 'white' : driverOpt.hasConflict ? '#c62828' : 'inherit' return ( ) })}
))}
)}
)}
) } const overlay = { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, } const modal = { backgroundColor: '#fff', padding: '2rem', borderRadius: '8px', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', } export default FixedScheduleEditor