feat: add bulk schedule creation endpoint and UI for managing fixed schedules
- Implemented a new POST endpoint for bulk schedule creation in transit_api/index.js. - Added FixedScheduleEditor component for creating and editing single and batch schedules in admin_ui. - Introduced TimetableDisplay component for displaying daily schedules with filter options. - Enhanced error handling and validation for schedule entries. - Added functionality for saving and loading schedule presets in local storage.
This commit is contained in:
@@ -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 (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h3>Vaste Diensten Beheren</h3>
|
||||||
|
<p>Maak, wijzig of verwijder vaste diensten en herhaalde routeschema's</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<form onSubmit={submitSchedule} className="stack-form">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<h4 style={{ margin: 0 }}>{editingScheduleId ? 'Vaste dienst bewerken' : 'Nieuwe vaste dienst'}</h4>
|
||||||
|
<p style={{ margin: '0.25rem 0 0', color: '#666', fontSize: '0.9rem' }}>
|
||||||
|
{editingScheduleId
|
||||||
|
? 'Wijzig één bestaande dienst.'
|
||||||
|
: 'Maak één rit of meteen een complete dienstenreeks met meerdere vertrektijden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{editingScheduleId ? (
|
||||||
|
<button type="button" className="secondary" onClick={resetForm}>
|
||||||
|
Bewerken annuleren
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className={scheduleMode === 'single' ? '' : 'secondary'} onClick={() => setScheduleMode('single')}>
|
||||||
|
Enkele rit
|
||||||
|
</button>
|
||||||
|
<button type="button" className={scheduleMode === 'batch' ? '' : 'secondary'} onClick={() => setScheduleMode('batch')}>
|
||||||
|
Dienstenreeks
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
|
||||||
|
<option value="">Kies lijn</option>
|
||||||
|
{busLines.map(line => (
|
||||||
|
<option key={line.id} value={line.id}>
|
||||||
|
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
|
||||||
|
<option value="">Kies voertuig</option>
|
||||||
|
{buses.map(bus => (
|
||||||
|
<option key={bus.id} value={bus.id}>
|
||||||
|
{bus.name} ({bus.licensePlate})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={driverId} onChange={e => setDriverId(e.target.value)}>
|
||||||
|
<option value="">Bestuurder (optioneel)</option>
|
||||||
|
{drivers.map(driver => (
|
||||||
|
<option key={driver.id} value={driver.id}>
|
||||||
|
{driver.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={e => setSelectedDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
{editingScheduleId || scheduleMode === 'single' ? (
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={singleTime}
|
||||||
|
onChange={e => setSingleTime(e.target.value)}
|
||||||
|
placeholder="Start time"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: '0.5rem', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))' }}>
|
||||||
|
{SERVICE_PRESETS.map(preset => (
|
||||||
|
<button key={preset.label} type="button" className="secondary" onClick={() => applyPreset(preset)}>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500 }}>
|
||||||
|
Herhaal op weekdagen
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{QUICK_PRESETS.map(preset => (
|
||||||
|
<button key={preset.label} type="button" className="secondary" onClick={() => setWeekdaysPreset(preset.weekdays)}>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="secondary" onClick={() => setSelectedWeekdays([])}>
|
||||||
|
Wissen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||||
|
{WEEKDAYS.map((day, idx) => {
|
||||||
|
const dayValue = WEEKDAY_VALUES[idx]
|
||||||
|
const active = selectedWeekdays.includes(dayValue)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleWeekday(dayValue)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
backgroundColor: active ? '#1D4ED8' : '#e0e0e0',
|
||||||
|
color: active ? '#fff' : '#000',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '999px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!editingScheduleId && (
|
||||||
|
<div style={{ marginBottom: '1rem', padding: '1rem', border: '1px solid #e5e7eb', borderRadius: '12px', background: '#fbfdff' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<strong>Reusable presets</strong>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.9rem' }}>Sla huidige configuratie op en hergebruik hem later</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={presetName}
|
||||||
|
onChange={e => setPresetName(e.target.value)}
|
||||||
|
placeholder="Preset naam, bijvoorbeeld schooldag-ochtend"
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary" onClick={saveCurrentPreset}>
|
||||||
|
Bewaar preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||||
|
{savedPresets.length === 0 ? (
|
||||||
|
<span style={{ color: '#999', fontSize: '0.9rem' }}>Nog geen presets opgeslagen</span>
|
||||||
|
) : savedPresets.map(preset => (
|
||||||
|
<span
|
||||||
|
key={preset.id}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.45rem 0.7rem',
|
||||||
|
background: '#eef2ff',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid #c7d2fe',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="secondary" onClick={() => loadPreset(preset)}>
|
||||||
|
{preset.name}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePreset(preset.id)}
|
||||||
|
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#6b7280' }}
|
||||||
|
aria-label={`Verwijder preset ${preset.name}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!editingScheduleId && scheduleMode === 'batch' && (
|
||||||
|
<div style={{ marginBottom: '1rem', padding: '1rem', border: '1px solid #e5e7eb', borderRadius: '12px', background: '#fafafa' }}>
|
||||||
|
<div className="form-row" style={{ alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 500 }}>Van</label>
|
||||||
|
<input type="time" value={batchStartTime} onChange={e => setBatchStartTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 500 }}>Tot</label>
|
||||||
|
<input type="time" value={batchEndTime} onChange={e => setBatchEndTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 500 }}>Interval</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="5"
|
||||||
|
step="5"
|
||||||
|
value={batchInterval}
|
||||||
|
onChange={e => setBatchInterval(Number(e.target.value) || 15)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={generateBatchTimes}>
|
||||||
|
Genereer tijden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row" style={{ alignItems: 'end', marginTop: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 500 }}>Aantal ritten</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={batchCount}
|
||||||
|
onChange={e => setBatchCount(Number(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={generateBatchTimesByCount}>
|
||||||
|
Genereer vanaf eerste tijd
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 500 }}>Extra tijd toevoegen</label>
|
||||||
|
<div className="form-row">
|
||||||
|
<input type="time" value={newBatchTime} onChange={e => setNewBatchTime(e.target.value)} />
|
||||||
|
<button type="button" className="secondary" onClick={addBatchTime}>
|
||||||
|
Voeg toe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<strong>Preview</strong>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.9rem' }}>{previewBatchTimes.length} ritten</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{previewBatchTimes.length === 0 ? (
|
||||||
|
<span style={{ color: '#999', fontSize: '0.9rem' }}>Nog geen tijden toegevoegd</span>
|
||||||
|
) : previewBatchTimes.map(time => (
|
||||||
|
<span
|
||||||
|
key={time}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0.45rem 0.7rem',
|
||||||
|
background: '#eaf2ff',
|
||||||
|
borderRadius: '999px',
|
||||||
|
border: '1px solid #c7dcff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeBatchTime(time)}
|
||||||
|
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '1rem', lineHeight: 1 }}
|
||||||
|
aria-label={`Verwijder ${time}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row" style={{ alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
|
||||||
|
<div style={{ color: '#666', fontSize: '0.9rem' }}>
|
||||||
|
{editingScheduleId
|
||||||
|
? 'Je past één bestaande dienst aan.'
|
||||||
|
: scheduleMode === 'batch'
|
||||||
|
? `Reeks wordt aangemaakt voor ${previewBatchTimes.length} vertrektijden.`
|
||||||
|
: 'Maak één dienst aan met optionele herhaling.'}
|
||||||
|
</div>
|
||||||
|
<button type="submit">
|
||||||
|
{editingScheduleId
|
||||||
|
? 'Dienst bijwerken'
|
||||||
|
: scheduleMode === 'batch'
|
||||||
|
? `Reeks aanmaken (${previewBatchTimes.length})`
|
||||||
|
: 'Dienst aanmaken'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h4 style={{ marginTop: '2rem', marginBottom: '0.5rem' }}>Bestaande vaste diensten</h4>
|
||||||
|
<p style={{ margin: 0, color: '#666', fontSize: '0.9rem', marginBottom: '1rem' }}>
|
||||||
|
Bewerk of verwijder hieronder bestaande diensten
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
{scheduleTemplates.length === 0 ? (
|
||||||
|
<div className="message">Nog geen vaste diensten gevonden.</div>
|
||||||
|
) : (
|
||||||
|
scheduleTemplates
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.lineNumber - b.lineNumber || new Date(a.startTime) - new Date(b.startTime))
|
||||||
|
.map(schedule => (
|
||||||
|
<article key={`template-${schedule.id}`} className="schedule-card" style={{ position: 'relative' }}>
|
||||||
|
<div className="schedule-header">
|
||||||
|
<strong>Lijn {schedule.lineNumber}</strong>
|
||||||
|
<span className="schedule-time">
|
||||||
|
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-details">
|
||||||
|
<div>{schedule.busName}</div>
|
||||||
|
<div className="schedule-plate">{schedule.licensePlate}</div>
|
||||||
|
{schedule.driverName && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
{schedule.driverName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="form-row" style={{ marginTop: '0.75rem', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => startEditingSchedule(schedule)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Bewerken
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => cloneTemplateIntoForm(schedule)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Klonen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger"
|
||||||
|
onClick={() => setDeleteConfirm(schedule)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Delete Service?</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#999', fontSize: '0.9rem' }}>
|
||||||
|
{new Date(deleteConfirm.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" type="button" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,54 +1,18 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
import { transitClient } from '../api'
|
||||||
|
import TimetableDisplay from './TimetableDisplay'
|
||||||
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
|
import FixedScheduleEditor from './FixedScheduleEditor'
|
||||||
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')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduleMaker({ token, user }) {
|
export default function ScheduleMaker({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
const [schedules, setSchedules] = useState([])
|
|
||||||
const [scheduleTemplates, setScheduleTemplates] = useState([])
|
const [scheduleTemplates, setScheduleTemplates] = useState([])
|
||||||
const [overviewSchedules, setOverviewSchedules] = useState([])
|
|
||||||
const [busLines, setBusLines] = useState([])
|
const [busLines, setBusLines] = useState([])
|
||||||
const [buses, setBuses] = useState([])
|
const [buses, setBuses] = useState([])
|
||||||
const [drivers, setDrivers] = useState([])
|
const [drivers, setDrivers] = useState([])
|
||||||
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
|
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
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 [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 () => {
|
const loadScheduleTemplates = async () => {
|
||||||
try {
|
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 () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.get('/admin/bus-lines')
|
const res = await client.get('/admin/bus-lines')
|
||||||
@@ -101,616 +51,43 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSchedules()
|
|
||||||
loadScheduleTemplates()
|
loadScheduleTemplates()
|
||||||
loadBusLines()
|
loadBusLines()
|
||||||
loadBuses()
|
loadBuses()
|
||||||
loadDrivers()
|
loadDrivers()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleScheduleChange = async () => {
|
||||||
loadOverviewSchedules()
|
|
||||||
}, [overviewDate, selectedDriverFilter])
|
|
||||||
|
|
||||||
const resetScheduleForm = () => {
|
|
||||||
setEditingScheduleId(null)
|
|
||||||
setSelectedLine('')
|
|
||||||
setSelectedBus('')
|
|
||||||
setStartTime('08:00')
|
|
||||||
setDriverId('')
|
|
||||||
setSelectedWeekdays([])
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 (
|
return (
|
||||||
<section className="card">
|
|
||||||
<div className="section-header">
|
|
||||||
<div>
|
<div>
|
||||||
<h2>Diensten Inplannen</h2>
|
{/* Timetable widget - compact daily view */}
|
||||||
<p>Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.</p>
|
<TimetableDisplay
|
||||||
</div>
|
client={client}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
overviewDate={overviewDate}
|
||||||
<button className="secondary" onClick={loadSchedules}>Refresh</button>
|
setOverviewDate={setOverviewDate}
|
||||||
<select
|
selectedDriverFilter={selectedDriverFilter}
|
||||||
value={selectedDriverFilter}
|
setSelectedDriverFilter={setSelectedDriverFilter}
|
||||||
onChange={e => setSelectedDriverFilter(e.target.value)}
|
drivers={drivers}
|
||||||
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
|
/>
|
||||||
>
|
|
||||||
<option value="">Alle chauffeurs</option>
|
|
||||||
{drivers.map(d => (
|
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Fixed service editor - separate widget */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<form onSubmit={submitSchedule} className="stack-form">
|
<FixedScheduleEditor
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
|
client={client}
|
||||||
<div>
|
token={token}
|
||||||
<h3 style={{ margin: 0 }}>{editingScheduleId ? 'Vaste dienst bewerken' : 'Dienst aanmaken'}</h3>
|
user={user}
|
||||||
<p style={{ margin: '0.25rem 0 0', color: '#666' }}>
|
scheduleTemplates={scheduleTemplates}
|
||||||
{editingScheduleId ? 'Werk de bestaande dienst bij en sla de wijzigingen op.' : 'Maak een nieuwe dienst of vaste herhaling aan.'}
|
busLines={busLines}
|
||||||
</p>
|
buses={buses}
|
||||||
</div>
|
drivers={drivers}
|
||||||
{editingScheduleId && (
|
message={message}
|
||||||
<button type="button" className="secondary" onClick={resetScheduleForm}>
|
setMessage={setMessage}
|
||||||
Bewerken annuleren
|
onScheduleChange={handleScheduleChange}
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
|
|
||||||
<option value="">Kies lijn</option>
|
|
||||||
{busLines.map(line => (
|
|
||||||
<option key={line.id} value={line.id}>
|
|
||||||
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
|
|
||||||
<option value="">Kies voertuig</option>
|
|
||||||
{buses.map(bus => (
|
|
||||||
<option key={bus.id} value={bus.id} disabled={busyBusIds.has(Number(bus.id))}>
|
|
||||||
{bus.name} ({bus.licensePlate}) {busyBusIds.has(Number(bus.id)) ? '- in gebruik' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<select value={driverId} onChange={e => setDriverId(e.target.value)}>
|
|
||||||
<option value="">Bestuurder (optioneel)</option>
|
|
||||||
{drivers.map(driver => (
|
|
||||||
<option key={driver.id} value={driver.id} disabled={busyDriverIds.has(Number(driver.id))}>
|
|
||||||
{driver.name} {busyDriverIds.has(Number(driver.id)) ? '- in gebruik' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={e => setSelectedDate(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={startTime}
|
|
||||||
onChange={e => setStartTime(e.target.value)}
|
|
||||||
placeholder="Start time"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>
|
|
||||||
Herhaal op weekdagen (optioneel):
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
||||||
{WEEKDAYS.map((day, idx) => (
|
|
||||||
<button
|
|
||||||
key={day}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleWeekday(WEEKDAY_VALUES[idx])}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
backgroundColor: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#1D4ED8' : '#e0e0e0',
|
|
||||||
color: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#fff' : '#000',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<button type="submit">{editingScheduleId ? 'Dienst bijwerken' : 'Dienst aanmaken'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
|
|
||||||
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={overviewDate}
|
|
||||||
onChange={e => setOverviewDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
|
|
||||||
Vandaag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="timetable-container">
|
|
||||||
<table className="timetable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Tijdvak</th>
|
|
||||||
<th>Lijnen en Voertuigen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{timeSlots.map((slot, idx) => (
|
|
||||||
<tr key={idx}>
|
|
||||||
<td className="time-cell">{slot}</td>
|
|
||||||
<td className="schedule-cell">
|
|
||||||
<div className="schedule-items">
|
|
||||||
{schedulesBySlot[slot]?.map(schedule => (
|
|
||||||
<article
|
|
||||||
key={schedule.id}
|
|
||||||
className="schedule-card"
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
>
|
|
||||||
<div className="schedule-header">
|
|
||||||
<strong>Lijn {schedule.lineNumber}</strong>
|
|
||||||
<span className="schedule-time">
|
|
||||||
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="schedule-details">
|
|
||||||
<div>{schedule.busName}</div>
|
|
||||||
<div className="schedule-plate">{schedule.licensePlate}</div>
|
|
||||||
{schedule.driverName && (
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
|
||||||
{schedule.driverName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{scheduleHasDriverConflict(schedule) && (
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
|
|
||||||
Chauffeur in gebruik
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{scheduleHasBusConflict(schedule) && (
|
|
||||||
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
|
|
||||||
Voertuig in gebruik
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
|
||||||
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expandable stops list */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
|
|
||||||
style={{
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.4rem',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
backgroundColor: '#f0f0f0',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '3px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{expandedSchedule === schedule.id && (
|
|
||||||
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
|
|
||||||
{(() => {
|
|
||||||
const lineId = schedule.busLineId
|
|
||||||
const cached = routeStopsByLine[lineId]
|
|
||||||
if (!cached) {
|
|
||||||
// Load and show loading text
|
|
||||||
loadRouteStops(lineId)
|
|
||||||
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached.length === 0) {
|
|
||||||
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render editable list of stops with ETA inputs
|
|
||||||
return (
|
|
||||||
<table style={{ width: '100%', fontSize: '0.8rem' }}>
|
|
||||||
<tbody>
|
|
||||||
{cached.map(rs => {
|
|
||||||
const departureDate = new Date(schedule.startTime)
|
|
||||||
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
|
|
||||||
return (
|
|
||||||
<tr key={rs.id}>
|
|
||||||
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
|
|
||||||
<td style={{ flex: 1 }}>{rs.name}</td>
|
|
||||||
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
|
|
||||||
<td style={{ paddingLeft: '0.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="240"
|
|
||||||
value={Number(rs.estimatedArrivalMinutes ?? 0)}
|
|
||||||
onChange={e => {
|
|
||||||
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' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={Number(rs.estimatedArrivalMinutes ?? 0)}
|
|
||||||
onChange={e => {
|
|
||||||
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' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={async () => {
|
|
||||||
const newVal = Number((routeStopsByLine[lineId].find(i => i.id === rs.id)?.estimatedArrivalMinutes) || 0)
|
|
||||||
try {
|
|
||||||
await client.post(`/admin/bus-lines/${lineId}/stops`, {
|
|
||||||
stopId: rs.stopId,
|
|
||||||
stopOrder: rs.stopOrder,
|
|
||||||
estimatedArrivalMinutes: newVal,
|
|
||||||
})
|
|
||||||
setMessage('ETA updated')
|
|
||||||
await loadRouteStops(lineId)
|
|
||||||
await loadSchedules()
|
|
||||||
await loadOverviewSchedules()
|
|
||||||
} catch (err) {
|
|
||||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
|
||||||
}
|
|
||||||
}} style={{ marginLeft: '0.5rem' }}>Save</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<button
|
|
||||||
className="danger"
|
|
||||||
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
|
|
||||||
onClick={() => setDeleteConfirm(schedule)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: '2rem' }}>Vaste diensten beheren</h3>
|
|
||||||
<p style={{ marginTop: 0, color: '#666' }}>
|
|
||||||
Pas bestaande vaste diensten aan of verwijder ze direct vanuit dit overzicht.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
|
||||||
{scheduleTemplates.length === 0 ? (
|
|
||||||
<div className="message">Nog geen vaste diensten gevonden.</div>
|
|
||||||
) : (
|
|
||||||
scheduleTemplates
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.lineNumber - b.lineNumber || new Date(a.startTime) - new Date(b.startTime))
|
|
||||||
.map(schedule => (
|
|
||||||
<article key={`template-${schedule.id}`} className="schedule-card" style={{ position: 'relative' }}>
|
|
||||||
<div className="schedule-header">
|
|
||||||
<strong>Lijn {schedule.lineNumber}</strong>
|
|
||||||
<span className="schedule-time">
|
|
||||||
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="schedule-details">
|
|
||||||
<div>{schedule.busName}</div>
|
|
||||||
<div className="schedule-plate">{schedule.licensePlate}</div>
|
|
||||||
{schedule.driverName && (
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
|
||||||
{schedule.driverName}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
|
||||||
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
|
||||||
<button type="button" className="secondary" onClick={() => startEditingSchedule(schedule)}>
|
|
||||||
Bewerken
|
|
||||||
</button>
|
|
||||||
<button type="button" className="danger" onClick={() => setDeleteConfirm(schedule)}>
|
|
||||||
Verwijderen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
|
||||||
|
|
||||||
{deleteConfirm && (
|
|
||||||
<div style={overlay}>
|
|
||||||
<div style={modal}>
|
|
||||||
<h3>Delete Service?</h3>
|
|
||||||
<p>
|
|
||||||
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
|
|
||||||
</p>
|
|
||||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>
|
|
||||||
{new Date(deleteConfirm.startTime).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
|
||||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
|
||||||
<button
|
|
||||||
className="danger"
|
|
||||||
onClick={() =>
|
|
||||||
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="card" style={{ marginTop: '2rem' }}>
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h3>Dagelijks Overzicht</h3>
|
||||||
|
<p>Alle geplande diensten voor de geselecteerde dag</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={overviewDate}
|
||||||
|
onChange={e => setOverviewDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
|
||||||
|
Vandaag
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={selectedDriverFilter}
|
||||||
|
onChange={e => setSelectedDriverFilter(e.target.value)}
|
||||||
|
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||||
|
>
|
||||||
|
<option value="">Alle chauffeurs</option>
|
||||||
|
{drivers.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="timetable-container">
|
||||||
|
<table className="timetable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tijdvak</th>
|
||||||
|
<th>Lijnen en Voertuigen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timeSlots.map((slot, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="time-cell">{slot}</td>
|
||||||
|
<td className="schedule-cell">
|
||||||
|
<div className="schedule-items">
|
||||||
|
{schedulesBySlot[slot]?.map(schedule => (
|
||||||
|
<article
|
||||||
|
key={schedule.id}
|
||||||
|
className="schedule-card"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div className="schedule-header">
|
||||||
|
<strong>Lijn {schedule.lineNumber}</strong>
|
||||||
|
<span className="schedule-time">
|
||||||
|
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-details">
|
||||||
|
<div>{schedule.busName}</div>
|
||||||
|
<div className="schedule-plate">{schedule.licensePlate}</div>
|
||||||
|
{schedule.driverName && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
{schedule.driverName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable stops list */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.4rem',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSchedule === schedule.id && (
|
||||||
|
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
|
||||||
|
{(() => {
|
||||||
|
const lineId = schedule.busLineId
|
||||||
|
const cached = routeStopsByLine[lineId]
|
||||||
|
if (!cached) {
|
||||||
|
loadRouteStops(lineId)
|
||||||
|
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.length === 0) {
|
||||||
|
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table style={{ width: '100%', fontSize: '0.8rem' }}>
|
||||||
|
<tbody>
|
||||||
|
{cached.map(rs => {
|
||||||
|
const departureDate = new Date(schedule.startTime)
|
||||||
|
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
|
||||||
|
return (
|
||||||
|
<tr key={rs.id}>
|
||||||
|
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
|
||||||
|
<td style={{ flex: 1 }}>{rs.name}</td>
|
||||||
|
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <div className="message">{message}</div>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimetableDisplay
|
||||||
@@ -82,28 +82,56 @@ class ScheduleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get daily schedule for a specific date
|
/// Get daily schedule for a specific date (includes recurring schedules)
|
||||||
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
||||||
try {
|
try {
|
||||||
final headers = await _authService.getAuthHeaders();
|
|
||||||
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
final response = await http.get(
|
// Call /schedules/overview which includes both one-time and recurring schedules for the date
|
||||||
Uri.parse('$_apiBaseUrl/schedule/daily?date=$dateStr'),
|
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(
|
||||||
headers: headers,
|
queryParameters: {'date': dateStr}
|
||||||
);
|
);
|
||||||
|
final response = await http.get(uri);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
return DailySchedule.fromJson(data);
|
final schedules = (data['schedules'] as List? ?? [])
|
||||||
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
.map((s) => s as Map<String, dynamic>)
|
||||||
await _authService.logout();
|
.map(ServiceSchedule.fromJson)
|
||||||
throw UnauthorizedException('Session expired - Please login again');
|
.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 {
|
} else {
|
||||||
throw Exception('Failed to load schedule: ${response.statusCode}');
|
throw Exception('Failed to load schedule: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is UnauthorizedException) rethrow;
|
|
||||||
throw Exception('Schedule error: $e');
|
throw Exception('Schedule error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { scheduleId } = req.params;
|
const { scheduleId } = req.params;
|
||||||
|
|||||||
Reference in New Issue
Block a user