scheduling feature

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 15:58:02 +02:00
parent c7874f2678
commit 68490f8fd5
17 changed files with 1855 additions and 3 deletions
@@ -12,6 +12,7 @@ export default function BusLinesManager({ token, user }) {
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const loadBusLines = async () => {
try {
@@ -59,6 +60,19 @@ export default function BusLinesManager({ token, user }) {
}
}
const deleteBusLine = async (lineId, lineNumber) => {
setMessage(null)
try {
await client.delete(`/admin/bus-lines/${lineId}`)
setMessage(`Buslijn ${lineNumber} verwijderd`)
setDeleteConfirm(null)
await loadBusLines()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
return (
<section className="card">
<div className="section-header">
@@ -76,6 +90,15 @@ export default function BusLinesManager({ token, user }) {
<div>{line.startStop} → {line.endStop}</div>
<div>{line.estimatedDurationMinutes} min</div>
{line.description ? <div className="muted">{line.description}</div> : null}
{isAdmin && (
<button
className="danger"
onClick={() => setDeleteConfirm(line)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
)}
</article>
))}
</div>
@@ -106,6 +129,43 @@ export default function BusLinesManager({ token, user }) {
) : <div>You must be admin to create bus lines.</div>}
{message && <div className="message">{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Bus Line?</h3>
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
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)',
}
+54
View File
@@ -11,6 +11,7 @@ export default function LinkStop({ token, user }) {
const [message, setMessage] = useState(null)
const [stops, setStops] = useState([])
const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode)
@@ -145,6 +146,19 @@ export default function LinkStop({ token, user }) {
}
}
const deleteRouteStop = async (routeStopId, stopName) => {
setMessage(null)
try {
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
setMessage(`Halte "${stopName}" verwijderd uit de lijn`)
setDeleteConfirm(null)
await loadStopsForLine()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
return (
<section className="card">
<div className="section-header">
@@ -215,6 +229,9 @@ export default function LinkStop({ token, user }) {
<div className="route-stop-actions">
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
{isAdmin && (
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
)}
</div>
</article>
))}
@@ -268,6 +285,43 @@ export default function LinkStop({ token, user }) {
<div>You must be admin to link stops.</div>
)}
{message && <div className="message">{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Remove Stop from Line?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
Remove
</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)',
}
+293
View File
@@ -0,0 +1,293 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [schedules, setSchedules] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
// Form state
const [selectedLine, setSelectedLine] = useState('')
const [selectedBus, setSelectedBus] = useState('')
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [startTime, setStartTime] = useState('08:00')
const [endTime, setEndTime] = useState('17:00')
const [driverId, setDriverId] = 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 loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
}
}
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadSchedules()
loadBusLines()
loadBuses()
}, [])
const createSchedule = async (e) => {
e.preventDefault()
setMessage(null)
if (!selectedLine || !selectedBus || !startTime || !endTime) {
setMessage('Alle velden zijn verplicht')
return
}
try {
const startDateTime = `${selectedDate}T${startTime}:00`
const endDateTime = `${selectedDate}T${endTime}:00`
const res = await client.post('/admin/schedules', {
busLineId: Number(selectedLine),
busId: Number(selectedBus),
driverId: driverId ? Number(driverId) : null,
startTime: startDateTime,
endTime: endDateTime,
})
setMessage('Dienst aangemaakt: #' + res.data.schedule.id)
setSelectedLine('')
setSelectedBus('')
setStartTime('08:00')
setEndTime('17:00')
setDriverId('')
await loadSchedules()
} 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)
await loadSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
// Group schedules by date and time
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
return (
<section className="card">
<div className="section-header">
<div>
<h2>Diensten Inplannen</h2>
<p>Maak diensten aan en wijs voertuigen en lijnen toe.</p>
</div>
<button className="secondary" onClick={loadSchedules}>Refresh</button>
</div>
{isAdmin && (
<form onSubmit={createSchedule} className="stack-form">
<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">
<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"
/>
<input
type="time"
value={endTime}
onChange={e => setEndTime(e.target.value)}
placeholder="End time"
/>
<input
type="text"
value={driverId}
onChange={e => setDriverId(e.target.value)}
placeholder="Driver ID (optional)"
/>
</div>
<div className="form-row">
<button type="submit">Create Service</button>
</div>
</form>
)}
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
<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"
draggable
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">{formatTime(schedule.startTime)}</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
</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>
{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('en-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)',
}
+60 -1
View File
@@ -9,6 +9,7 @@ export default function StopsManager({ token, user }) {
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const loadStops = async () => {
try {
@@ -47,6 +48,19 @@ export default function StopsManager({ token, user }) {
}
}
const deleteStop = async (stopId, stopName) => {
setMessage(null)
try {
await client.delete(`/admin/stops/${stopId}`)
setMessage(`Halte "${stopName}" verwijderd`)
setDeleteConfirm(null)
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
return (
<section className="card">
<div className="section-header">
@@ -61,6 +75,15 @@ export default function StopsManager({ token, user }) {
<article className="mini-card" key={stop.id}>
<strong>#{stop.id} {stop.name}</strong>
<div>{stop.latitude}, {stop.longitude}</div>
{isAdmin && (
<button
className="danger"
onClick={() => setDeleteConfirm(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
)}
</article>
))}
</div>
@@ -78,7 +101,43 @@ export default function StopsManager({ token, user }) {
)}
{message && <div className="message">{message}</div>}
<p>Note: To view stops per line, use the Bus Line Stops tool below.</p>
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Stop?</h3>
<p><strong>{deleteConfirm.name}</strong></p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
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)',
}
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react'
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([])
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// Fetch available bus lines
useEffect(() => {
const fetchLines = async () => {
try {
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
const data = await res.json()
setLines(data.busLines || [])
} catch (err) {
console.error('Failed to load lines:', err)
}
}
fetchLines()
}, [apiBaseUrl])
// Fetch schedules based on date and line filter
useEffect(() => {
const fetchSchedules = async () => {
setLoading(true)
setError(null)
try {
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
if (selectedLineId) {
url += `&lineId=${selectedLineId}`
}
const res = await fetch(url)
const data = await res.json()
setSchedules(data.schedules || [])
} catch (err) {
setError('Failed to load schedules: ' + err.message)
} finally {
setLoading(false)
}
}
fetchSchedules()
}, [selectedDate, selectedLineId, apiBaseUrl])
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
return (
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
<h1>Public Transit Timetable</h1>
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
/>
<select
value={selectedLineId}
onChange={e => setSelectedLineId(e.target.value)}
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
>
<option value="">All lines</option>
{lines.map(line => (
<option key={line.id} value={line.id}>
Line {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
))}
</select>
</div>
{loading && <p>Loading schedules...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
<thead style={{ backgroundColor: '#f1f5f9' }}>
<tr>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => {
const slotSchedules = schedulesBySlot[slot] || []
if (slotSchedules.length === 0) return null
return slotSchedules.map((schedule, sidx) => (
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '12px' }}>
{sidx === 0 ? slot : ''}
</td>
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
Line {schedule.lineNumber}
</td>
<td style={{ padding: '12px' }}>
{schedule.busName} ({schedule.licensePlate})
</td>
<td style={{ padding: '12px' }}>
<span
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'active' ? '#166534' : '#374151',
fontSize: '0.9rem',
}}
>
{schedule.status}
</span>
</td>
</tr>
))
})}
</tbody>
</table>
{schedules.length === 0 && !loading && (
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
No schedules found for the selected date and line.
</p>
)}
</div>
)
}
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
}