diff --git a/admin_ui/src/components/FixedScheduleEditor.jsx b/admin_ui/src/components/FixedScheduleEditor.jsx new file mode 100644 index 0000000..9a149e9 --- /dev/null +++ b/admin_ui/src/components/FixedScheduleEditor.jsx @@ -0,0 +1,838 @@ +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 buildTimeRange = (start, end, intervalMinutes) => { + 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) + const endMinutes = parseTimeToMinutes(normalizedEnd) + if (startMinutes === null || endMinutes === null || endMinutes < startMinutes) { + return [] + } + + const times = [] + for (let minute = startMinutes; minute <= endMinutes; minute += step) { + const hours = Math.floor(minute / 60) + const minutes = minute % 60 + times.push(`${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`) + } + + return times +} + +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 [presetName, setPresetName] = useState('') + const [savedPresets, setSavedPresets] = useState([]) + const [deleteConfirm, setDeleteConfirm] = useState(null) + + const isAdmin = Boolean(user?.userCode) + + 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]) + + 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) + setBatchTimes(prev => dedupeTimes([...prev, ...generated])) + } + + const generateBatchTimesByCount = () => { + const generated = buildCountRange(batchStartTime, batchInterval, batchCount) + setBatchTimes(prev => dedupeTimes([...prev, ...generated])) + } + + const applyPreset = (preset) => { + setBatchStartTime(preset.start) + setBatchEndTime(preset.end) + setBatchInterval(preset.intervalMinutes) + setBatchTimes(buildTimeRange(preset.start, preset.end, preset.intervalMinutes)) + 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) + 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] : []) + setBatchStartTime(toInputTime(schedule.startTime)) + setBatchEndTime(addMinutesToTime(toInputTime(schedule.startTime), 120) || '09:00') + setBatchTimes([toInputTime(schedule.startTime)]) + 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 payload = { + schedules: uniqueTimes.map(time => ({ + busLineId: Number(selectedLine), + busId: Number(selectedBus), + driverId: driverId ? Number(driverId) : null, + startTime: `${selectedDate}T${time}:00`, + weekdays: normalizedWeekdays, + })), + } + + 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 && ( +
+
+ Reusable presets + Sla huidige configuratie op en hergebruik hem later +
+
+ setPresetName(e.target.value)} + placeholder="Preset naam, bijvoorbeeld schooldag-ochtend" + /> + +
+
+ {savedPresets.length === 0 ? ( + Nog geen presets opgeslagen + ) : savedPresets.map(preset => ( + + + + + ))} +
+
+ )} + + {!editingScheduleId && scheduleMode === 'batch' && ( +
+
+
+ + 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 hieronder bestaande diensten +

+
+ {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 => ( +
+
+ 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()} +

+
+ + +
+
+
+ )} +
+ ) +} + +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 diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index 88745a8..0dc283a 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -1,54 +1,18 @@ import React, { useEffect, useState } from 'react' import { transitClient } from '../api' - -const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] -const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon... - -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')}` -} +import TimetableDisplay from './TimetableDisplay' +import FixedScheduleEditor from './FixedScheduleEditor' export default function ScheduleMaker({ token, user }) { const client = transitClient(token) - const [schedules, setSchedules] = useState([]) const [scheduleTemplates, setScheduleTemplates] = useState([]) - const [overviewSchedules, setOverviewSchedules] = useState([]) const [busLines, setBusLines] = useState([]) const [buses, setBuses] = useState([]) const [drivers, setDrivers] = useState([]) const [selectedDriverFilter, setSelectedDriverFilter] = useState('') const [message, setMessage] = useState(null) const [isAdmin] = useState(Boolean(user?.userCode)) - const [deleteConfirm, setDeleteConfirm] = useState(null) - const [expandedSchedule, setExpandedSchedule] = useState(null) - const [editingScheduleId, setEditingScheduleId] = useState(null) - const [routeStopsByLine, setRouteStopsByLine] = useState({}) - - // Form state - const [selectedLine, setSelectedLine] = useState('') - const [selectedBus, setSelectedBus] = useState('') - const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0]) - const [startTime, setStartTime] = useState('08:00') - const [driverId, setDriverId] = useState('') - const [selectedWeekdays, setSelectedWeekdays] = useState([]) - - const loadSchedules = async () => { - try { - const res = await client.get('/admin/schedules') - setSchedules(res.data.schedules || []) - } catch (err) { - setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message)) - } - } const loadScheduleTemplates = async () => { try { @@ -59,20 +23,6 @@ export default function ScheduleMaker({ token, user }) { } } - const loadOverviewSchedules = async () => { - try { - const params = new URLSearchParams({ date: overviewDate }) - if (selectedDriverFilter) { - params.set('driverId', selectedDriverFilter) - } - - const res = await client.get(`/schedules/overview?${params.toString()}`) - setOverviewSchedules(res.data.schedules || []) - } catch (err) { - setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message)) - } - } - const loadBusLines = async () => { try { const res = await client.get('/admin/bus-lines') @@ -101,616 +51,43 @@ export default function ScheduleMaker({ token, user }) { } useEffect(() => { - loadSchedules() loadScheduleTemplates() loadBusLines() loadBuses() loadDrivers() }, []) - useEffect(() => { - loadOverviewSchedules() - }, [overviewDate, selectedDriverFilter]) - - const resetScheduleForm = () => { - setEditingScheduleId(null) - setSelectedLine('') - setSelectedBus('') - setStartTime('08:00') - setDriverId('') - setSelectedWeekdays([]) + const handleScheduleChange = async () => { + await loadScheduleTemplates() } - const startEditingSchedule = (schedule) => { - setMessage(null) - setEditingScheduleId(schedule.id) - setSelectedLine(String(schedule.busLineId)) - setSelectedBus(String(schedule.busId)) - setDriverId(schedule.driverId ? String(schedule.driverId) : '') - setSelectedDate(toInputDate(schedule.startTime)) - setStartTime(toInputTime(schedule.startTime)) - setSelectedWeekdays(Array.isArray(schedule.weekdays) ? [...schedule.weekdays] : []) - window.scrollTo({ top: 0, behavior: 'smooth' }) - } - - const submitSchedule = async (e) => { - e.preventDefault() - setMessage(null) - - if (!selectedLine || !selectedBus || !startTime) { - setMessage('Lijn, voertuig en start-tijd zijn verplicht') - return - } - - try { - const startDateTime = `${selectedDate}T${startTime}: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 - ? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ') - : 'eenmalig' - setMessage(editingScheduleId - ? `Dienst bijgewerkt: #${res.data.schedule.id} (${dayLabels})` - : `Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`) - resetScheduleForm() - await loadSchedules() - await loadScheduleTemplates() - await loadOverviewSchedules() - } 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) { - resetScheduleForm() - } - await loadSchedules() - await loadScheduleTemplates() - await loadOverviewSchedules() - } catch (err) { - const errMsg = err.response?.data?.error || err.message - setMessage('Error: ' + errMsg) - } - } - - const toggleWeekday = (dayValue) => { - setSelectedWeekdays(prev => - prev.includes(dayValue) - ? prev.filter(d => d !== dayValue) - : [...prev, dayValue] - ) - } - - 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 formatTime = (timeString) => { - const date = new Date(timeString) - return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) - } - - // Fetch route stops for a given line and cache them in state - const loadRouteStops = async (busLineId) => { - if (!busLineId) return - try { - const res = await client.get(`/admin/bus-lines/${busLineId}/stops`) - const routeStops = res.data.routeStops || [] - setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops })) - } catch (err) { - // ignore silently, message already used elsewhere - } - } - - const getCandidateWindow = () => { - const line = busLines.find(l => String(l.id) === String(selectedLine)) - if (!line || !selectedDate || !startTime) return null - - const start = new Date(`${selectedDate}T${startTime}:00`) - const end = new Date(start) - end.setMinutes(end.getMinutes() + (line.estimatedDurationMinutes || 60)) - return { start, end } - } - - const hasOverlap = (candidate, existing) => { - if (!candidate || !existing?.startTime || !existing?.endTime) return false - - const cStart = candidate.start.getTime() - const cEnd = candidate.end.getTime() - const eStart = new Date(existing.startTime).getTime() - const eEnd = new Date(existing.endTime).getTime() - const timeOverlap = cStart < eEnd && cEnd > eStart - if (!timeOverlap) return false - - const cWeekdays = selectedWeekdays || [] - const eWeekdays = Array.isArray(existing.weekdays) ? existing.weekdays : [] - - if (cWeekdays.length === 0 && eWeekdays.length === 0) { - return candidate.start.toDateString() === new Date(existing.startTime).toDateString() - } - - if (cWeekdays.length > 0 && eWeekdays.length > 0) { - return cWeekdays.some(d => eWeekdays.includes(d)) - } - - if (cWeekdays.length > 0 && eWeekdays.length === 0) { - return cWeekdays.includes(new Date(existing.startTime).getDay()) - } - - return eWeekdays.includes(candidate.start.getDay()) - } - - const candidateWindow = getCandidateWindow() - const busyDriverIds = new Set( - schedules - .filter(s => s.driverId && hasOverlap(candidateWindow, s)) - .map(s => Number(s.driverId)) - ) - const busyBusIds = new Set( - schedules - .filter(s => s.busId && hasOverlap(candidateWindow, s)) - .map(s => Number(s.busId)) - ) - - const scheduleHasDriverConflict = (schedule) => { - if (!schedule?.driverId) return false - return schedules.some(other => - other.id !== schedule.id && - Number(other.driverId) === Number(schedule.driverId) && - hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other) - ) - } - - const scheduleHasBusConflict = (schedule) => { - if (!schedule?.busId) return false - return schedules.some(other => - other.id !== schedule.id && - Number(other.busId) === Number(schedule.busId) && - hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other) - ) - } - - // Group schedules by date and time using the API-backed overview - const timeSlots = generateTimeSlots() - const schedulesBySlot = groupSchedulesByTimeSlot(overviewSchedules, timeSlots) - return ( -
-
-
-

Diensten Inplannen

-

Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.

-
-
- - -
-
+
+ {/* Timetable widget - compact daily view */} + + {/* Fixed service editor - separate widget */} {isAdmin && ( -
-
-
-

{editingScheduleId ? 'Vaste dienst bewerken' : 'Dienst aanmaken'}

-

- {editingScheduleId ? 'Werk de bestaande dienst bij en sla de wijzigingen op.' : 'Maak een nieuwe dienst of vaste herhaling aan.'} -

-
- {editingScheduleId && ( - - )} -
- -
- - -
- -
- - setSelectedDate(e.target.value)} - /> - setStartTime(e.target.value)} - placeholder="Start time" - /> -
- -
- -
- {WEEKDAYS.map((day, idx) => ( - - ))} -
-
- -
- -
-
- )} - -

Timetable

-
- setOverviewDate(e.target.value)} + - -
-
- - - - - - - - - {timeSlots.map((slot, idx) => ( - - - - - ))} - -
TijdvakLijnen en Voertuigen
{slot} -
- {schedulesBySlot[slot]?.map(schedule => ( -
-
- Lijn {schedule.lineNumber} - - {formatTime(schedule.startTime)} - {formatTime(schedule.endTime)} - -
-
-
{schedule.busName}
-
{schedule.licensePlate}
- {schedule.driverName && ( -
- {schedule.driverName} -
- )} - {scheduleHasDriverConflict(schedule) && ( -
- Chauffeur in gebruik -
- )} - {scheduleHasBusConflict(schedule) && ( -
- Voertuig in gebruik -
- )} -
- {schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)} -
-
- - {/* Expandable stops list */} - - - {expandedSchedule === schedule.id && ( -
- {(() => { - const lineId = schedule.busLineId - const cached = routeStopsByLine[lineId] - if (!cached) { - // Load and show loading text - loadRouteStops(lineId) - return

Laden haltes...

- } - - if (cached.length === 0) { - return

Geen haltes gekoppeld

- } - - // Render editable list of stops with ETA inputs - return ( - - - {cached.map(rs => { - const departureDate = new Date(schedule.startTime) - departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0)) - return ( - - - - - - - ) - })} - -
{rs.stopOrder}.{rs.name}{formatTime(departureDate.toISOString())} -
- { - const val = Number(e.target.value) - setRouteStopsByLine(prev => ({ - ...prev, - [lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item) - })) - }} - style={{ width: '8rem' }} - /> - { - const val = e.target.value === '' ? 0 : Number(e.target.value) - setRouteStopsByLine(prev => ({ - ...prev, - [lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item) - })) - }} - style={{ width: '4.5rem' }} - /> -
- -
- ) - })()} -
- )} - - {isAdmin && ( - - )} -
- ))} -
-
-
- -

Vaste diensten beheren

-

- Pas bestaande vaste diensten aan of verwijder ze direct vanuit dit overzicht. -

-
- {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 => ( -
-
- Lijn {schedule.lineNumber} - - {formatTime(schedule.startTime)} - {formatTime(schedule.endTime)} - -
-
-
{schedule.busName}
-
{schedule.licensePlate}
- {schedule.driverName && ( -
- {schedule.driverName} -
- )} -
- {schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)} -
-
- -
- - -
-
- )) - )} -
- - {message &&
{message}
} - - {deleteConfirm && ( -
-
-

Delete Service?

-

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

-

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

-
- - -
-
-
)} -
+ ) } - -function generateTimeSlots() { - const slots = [] - for (let hour = 0; hour < 24; hour++) { - slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`) - } - return slots -} - -function groupSchedulesByTimeSlot(schedules, slots) { - const grouped = {} - slots.forEach(slot => { - grouped[slot] = [] - }) - - schedules.forEach(schedule => { - const startHour = new Date(schedule.startTime).getHours() - const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00` - if (grouped[slot]) { - grouped[slot].push(schedule) - } - }) - - return grouped -} - -function formatTime(timeString) { - const date = new Date(timeString) - return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) -} - -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)', -} diff --git a/admin_ui/src/components/TimetableDisplay.jsx b/admin_ui/src/components/TimetableDisplay.jsx new file mode 100644 index 0000000..fb0684c --- /dev/null +++ b/admin_ui/src/components/TimetableDisplay.jsx @@ -0,0 +1,215 @@ +import React, { useState, useEffect } from 'react' + +const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDriverFilter, setSelectedDriverFilter, drivers }) => { + const [overviewSchedules, setOverviewSchedules] = useState([]) + const [message, setMessage] = useState(null) + const [expandedSchedule, setExpandedSchedule] = useState(null) + const [routeStopsByLine, setRouteStopsByLine] = useState({}) + + const loadOverviewSchedules = async () => { + try { + const params = new URLSearchParams({ date: overviewDate }) + if (selectedDriverFilter) { + params.set('driverId', selectedDriverFilter) + } + + const res = await client.get(`/schedules/overview?${params.toString()}`) + setOverviewSchedules(res.data.schedules || []) + } catch (err) { + setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message)) + } + } + + useEffect(() => { + loadOverviewSchedules() + }, [overviewDate, selectedDriverFilter]) + + const formatTime = (timeString) => { + const date = new Date(timeString) + return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) + } + + const loadRouteStops = async (busLineId) => { + if (!busLineId) return + try { + const res = await client.get(`/admin/bus-lines/${busLineId}/stops`) + const routeStops = res.data.routeStops || [] + setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops })) + } catch (err) { + // ignore silently + } + } + + const getWeekdayLabel = (weekdayValues) => { + const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] + const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] + 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 timeSlots = (() => { + const slots = [] + for (let hour = 0; hour < 24; hour++) { + slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`) + } + return slots + })() + + const schedulesBySlot = (() => { + const grouped = {} + timeSlots.forEach(slot => { + grouped[slot] = [] + }) + + overviewSchedules.forEach(schedule => { + const startHour = new Date(schedule.startTime).getHours() + const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00` + if (grouped[slot]) { + grouped[slot].push(schedule) + } + }) + + return grouped + })() + + return ( +
+
+
+

Dagelijks Overzicht

+

Alle geplande diensten voor de geselecteerde dag

+
+
+ setOverviewDate(e.target.value)} + /> + + +
+
+ +
+ + + + + + + + + {timeSlots.map((slot, idx) => ( + + + + + ))} + +
TijdvakLijnen en Voertuigen
{slot} +
+ {schedulesBySlot[slot]?.map(schedule => ( +
+
+ Lijn {schedule.lineNumber} + + {formatTime(schedule.startTime)} - {formatTime(schedule.endTime)} + +
+
+
{schedule.busName}
+
{schedule.licensePlate}
+ {schedule.driverName && ( +
+ {schedule.driverName} +
+ )} +
+ {schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)} +
+
+ + {/* Expandable stops list */} + + + {expandedSchedule === schedule.id && ( +
+ {(() => { + const lineId = schedule.busLineId + const cached = routeStopsByLine[lineId] + if (!cached) { + loadRouteStops(lineId) + return

Laden haltes...

+ } + + if (cached.length === 0) { + return

Geen haltes gekoppeld

+ } + + return ( + + + {cached.map(rs => { + const departureDate = new Date(schedule.startTime) + departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0)) + return ( + + + + + + ) + })} + +
{rs.stopOrder}.{rs.name}{formatTime(departureDate.toISOString())}
+ ) + })()} +
+ )} +
+ ))} +
+
+
+ + {message &&
{message}
} +
+ ) +} + +export default TimetableDisplay diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index 1115a62..160496a 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -82,28 +82,56 @@ class ScheduleService { } } - /// Get daily schedule for a specific date + /// Get daily schedule for a specific date (includes recurring schedules) Future getDailySchedule(DateTime date) async { try { - final headers = await _authService.getAuthHeaders(); final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - - final response = await http.get( - Uri.parse('$_apiBaseUrl/schedule/daily?date=$dateStr'), - headers: headers, + + // Call /schedules/overview which includes both one-time and recurring schedules for the date + final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace( + queryParameters: {'date': dateStr} ); + final response = await http.get(uri); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; - return DailySchedule.fromJson(data); - } else if (response.statusCode == 401 || response.statusCode == 403) { - await _authService.logout(); - throw UnauthorizedException('Session expired - Please login again'); + final schedules = (data['schedules'] as List? ?? []) + .map((s) => s as Map) + .map(ServiceSchedule.fromJson) + .toList(); + + // Convert ServiceSchedule to Route objects for display + final routes = schedules.map((schedule) { + return Route( + id: schedule.id, + busLine: BusLine( + id: schedule.busLineId, + lineNumber: schedule.lineNumber, + startStop: '', + endStop: '', + estimatedDuration: 60, + description: '', + ), + busType: BusType( + id: schedule.busId, + name: schedule.busName, + seatCapacity: 0, + licensePlate: '', + ), + startTime: schedule.startTime, + endTime: schedule.endTime, + status: schedule.status, + ); + }).toList(); + + return DailySchedule( + routes: routes, + date: date, + ); } else { throw Exception('Failed to load schedule: ${response.statusCode}'); } } catch (e) { - if (e is UnauthorizedException) rethrow; throw Exception('Schedule error: $e'); } } diff --git a/transit_api/index.js b/transit_api/index.js index f9f377a..dad4181 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -2357,6 +2357,148 @@ app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async ( } }); +app.post('/admin/schedules/bulk', authMiddleware, adminMiddleware, async (req, res) => { + const requestedSchedules = Array.isArray(req.body?.schedules) ? req.body.schedules : []; + + if (requestedSchedules.length === 0) { + return res.status(400).json({ error: 'missing_required_fields: schedules array required' }); + } + + const normalizedSchedules = requestedSchedules.map((schedule, index) => { + const busLineId = Number(schedule?.busLineId); + const busId = Number(schedule?.busId); + const driverId = schedule?.driverId !== null && schedule?.driverId !== undefined && schedule?.driverId !== '' + ? Number(schedule.driverId) + : null; + const startTime = schedule?.startTime ? String(schedule.startTime) : ''; + const weekdays = Array.isArray(schedule?.weekdays) && schedule.weekdays.length > 0 + ? schedule.weekdays.map(day => Number(day)).filter(day => day >= 0 && day <= 6) + : []; + + if (!Number.isFinite(busLineId) || !Number.isFinite(busId) || !startTime) { + throw new Error(`invalid_schedule_entry:${index}`); + } + + return { busLineId, busId, driverId, startTime, weekdays }; + }); + + try { + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; + + const createdSchedules = []; + + for (const entry of normalizedSchedules) { + const busLine = _mock.busLines.find(bl => bl.id == entry.busLineId); + if (!busLine) { + return res.status(404).json({ error: 'bus_line_not_found' }); + } + + const startDate = new Date(entry.startTime); + const endDate = new Date(startDate); + endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60)); + + const departureTimes = calculateDepartureTimes(entry.startTime, Number(entry.busLineId), _mock); + + const schedule = { + id: (_mock.nextScheduleId || 1), + busLineId: entry.busLineId, + busId: entry.busId, + driverId: entry.driverId, + startTime: entry.startTime, + endTime: endDate.toISOString(), + weekdays: entry.weekdays, + departureTimes, + status: 'planned', + createdAt: new Date().toISOString(), + }; + + _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1); + _mock.schedules.push(schedule); + createdSchedules.push(schedule); + } + + return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); + } + + const dbClient = await db.pool.connect(); + try { + await dbClient.query('BEGIN'); + + const hasWeekdaysColumn = await dbClient.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays' + `); + + const createdSchedules = []; + + for (const entry of normalizedSchedules) { + const busLine = await dbClient.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [entry.busLineId]); + if (!busLine.rows[0]) { + throw new Error('bus_line_not_found'); + } + + const startDate = new Date(entry.startTime); + const endDate = new Date(startDate); + endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); + + let result; + if (hasWeekdaysColumn.rows.length > 0) { + result = await dbClient.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules + ) + INSERT INTO transit.schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays) + SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 + FROM next_id + RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at + `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString(), JSON.stringify(entry.weekdays)]); + } else { + result = await dbClient.query(` + WITH next_id AS ( + SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules + ) + INSERT INTO transit.schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status) + SELECT next_id.id, $1, $2, $3, $4, $5, 'planned' + FROM next_id + RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at + `, [entry.busLineId, entry.busId, entry.driverId, entry.startTime, endDate.toISOString()]); + } + + const schedule = result.rows[0]; + createdSchedules.push({ + id: Number(schedule.id), + busLineId: Number(schedule.bus_line_id), + busId: Number(schedule.bus_id), + driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null, + startTime: schedule.start_time, + endTime: schedule.end_time, + weekdays: parseWeekdaysValue(schedule.weekdays), + status: schedule.status, + createdAt: schedule.created_at, + }); + } + + await dbClient.query('COMMIT'); + return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); + } catch (err) { + await dbClient.query('ROLLBACK'); + throw err; + } finally { + dbClient.release(); + } + } catch (err) { + console.error(err); + const errorMessage = err.message === 'bus_line_not_found' + ? 'bus_line_not_found' + : err.message.startsWith('invalid_schedule_entry:') + ? err.message + : 'internal_error'; + const statusCode = errorMessage === 'bus_line_not_found' ? 404 : errorMessage.startsWith('invalid_schedule_entry:') ? 400 : 500; + res.status(statusCode).json({ error: errorMessage, details: err.message }); + } +}); + app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => { try { const { scheduleId } = req.params;