update scheduler

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 18:09:12 +02:00
parent 68490f8fd5
commit d81da9a475
9 changed files with 1430 additions and 56 deletions
+461
View File
@@ -0,0 +1,461 @@
# Enhanced Schedule Maker - Dokumentatie
## Overzicht
Het Schedule Maker systeem is uitgebreid met geavanceerde planning functies:
### ✅ Implemented Features
#### 1. Automatische Eindtijd Berekening
- **Voordeel**: Geen handmatige eindtijd invoer meer
- **Hoe het werkt**: Eindtijd = Begintijd + Geschatte rijduur van de lijn
- **Voorbeeld**:
- Lijn 101: geschatte duur 45 minuten
- Begintijd: 08:00
- Eindtijd: 08:45 (automatisch berekend)
#### 2. Automatische Vertrektijden per Halte
- **Functionaliteit**: Berekent vertrektijd voor elke halte op basis van:
- Begintijd van de dienst
- Geschatte aankomsttijd per halte (in `route_stops.estimated_arrival_minutes`)
- **Weergave**: Uitklapbaar tabel in schedule card met:
- Haltenummer
- Haltename
- Vertrektijd
**Voorbeeld JSON response:**
```json
{
"schedule": {
"id": 1,
"busLineId": 101,
"busId": 5,
"driverId": 3,
"startTime": "2026-05-06T08:00:00Z",
"endTime": "2026-05-06T08:45:00Z",
"weekdays": [1, 2, 3, 4, 5],
"departureTimes": [
{
"order": 1,
"stopId": 10,
"stopName": "Central Station",
"estimatedArrivalMinutes": 0,
"departureTime": "2026-05-06T08:00:00Z"
},
{
"order": 2,
"stopId": 11,
"stopName": "Market Square",
"estimatedArrivalMinutes": 10,
"departureTime": "2026-05-06T08:10:00Z"
},
{
"order": 3,
"stopId": 12,
"stopName": "Park Lane",
"estimatedArrivalMinutes": 25,
"departureTime": "2026-05-06T08:25:00Z"
}
],
"status": "planned"
}
}
```
#### 3. Herhaald Rooster (Weekdagen)
- **Functionaliteit**: Plan een dienst op meerdere weekdagen
- **Invoer**: Klik op weekdagknoppen (Ma, Di, Wo, Do, Vr, Za, Zo)
- **Opslag**: `schedules.weekdays` array met dagwaarden (0-6, waarbij 0=Zondag, 1=Maandag, etc.)
- **Weergave**: In schedule card getoond als "Maandag, Woensdag, Vrijdag"
**Request voorbeeld:**
```json
POST /admin/schedules
{
"busLineId": 101,
"busId": 5,
"driverId": 3,
"startTime": "2026-05-06T08:00:00Z",
"weekdays": [1, 3, 5] // Ma, Wo, Vr
}
```
**Weergave in UI:**
- "Eenmalig" - geen weekdays geselecteerd
- "Maandag, Woensdag, Vrijdag" - specifieke weekdays
- "Dagelijks" - alle 7 weekdays
#### 4. Bestuurders Dropdown
- **Functionaliteit**: Selecteer bestuurder uit dropdown bij dienst aanmaken
- **Bron**: GET `/admin/drivers` endpoint
- **Opslag**: `schedules.driver_id` veld
- **Weergave**:
- In schedule card: "Bestuurdernaam"
- Optioneel veld (NULL als niet ingesteld)
**Response voorbeeld:**
```json
{
"drivers": [
{
"id": 1,
"name": "Jan Peterse",
"email": "jan@transit.local",
"phone": "06-12345678"
},
{
"id": 2,
"name": "Maria Kuijpers",
"email": "maria@transit.local",
"phone": "06-87654321"
}
]
}
```
---
## API Endpoints
### GET /admin/drivers
**Beschrijving**: Haalt lijst van beschikbare bestuurders op
**Response (DEV_MOCK):**
```json
{
"drivers": [
{
"id": 1,
"name": "Jan Peterse",
"email": "jan@transit.local",
"phone": "06-12345678",
"status": "active"
}
]
}
```
**Response (Production):**
- Query uit `workers.employees` tabel
- Filtert op `status = 'active'`
---
### POST /admin/schedules
**Beschrijving**: Maakt nieuwe dienst aan met automatische eindtijd en vertrektijden
**Request Body:**
```json
{
"busLineId": 101,
"busId": 5,
"driverId": 3, // optioneel
"startTime": "2026-05-06T08:00:00Z",
"weekdays": [1, 2, 3, 4, 5] // optioneel, array van daggetallen
}
```
**Validatie:**
- `busLineId`, `busId`, `startTime` zijn verplicht
- `driverId` is optioneel (NULL als niet gegeven)
- `weekdays` is optioneel (lege array = eenmalige dienst)
**Response:**
```json
{
"schedule": {
"id": 1,
"busLineId": 101,
"busId": 5,
"driverId": 3,
"startTime": "2026-05-06T08:00:00Z",
"endTime": "2026-05-06T08:45:00Z",
"weekdays": [1, 2, 3, 4, 5],
"departureTimes": [
{
"order": 1,
"stopId": 10,
"stopName": "Central Station",
"departureTime": "2026-05-06T08:00:00Z"
},
// ... meer haltes
],
"status": "planned",
"createdAt": "2026-05-06T15:30:00Z"
}
}
```
**DEV_MOCK Details:**
- Eindtijd = startTime + `bus_lines.estimated_duration_minutes`
- Vertrektijden berekend via helper functie `calculateDepartureTimes()`
- Dienst direct opgeslagen in `_mock.schedules` array
---
### GET /admin/schedules
**Beschrijving**: Haalt alle geplande diensten met details op
**Response (bevat nu):**
```json
{
"schedules": [
{
"id": 1,
"busLineId": 101,
"busId": 5,
"driverId": 3,
"driverName": "Jan Peterse", // NEW
"startTime": "2026-05-06T08:00:00Z",
"endTime": "2026-05-06T08:45:00Z",
"weekdays": [1, 2, 3, 4, 5], // NEW
"departureTimes": [ // NEW
{
"order": 1,
"stopId": 10,
"stopName": "Central Station",
"departureTime": "2026-05-06T08:00:00Z"
}
],
"status": "planned",
"lineNumber": 101,
"busName": "Bus A5",
"licensePlate": "AB-123-CD",
"createdAt": "2026-05-06T15:30:00Z"
}
]
}
```
---
## Frontend Componenten
### ScheduleMaker.jsx
**Nieuwe State Variabelen:**
```jsx
const [drivers, setDrivers] = useState([]) // Bestuurders lijst
const [selectedWeekdays, setSelectedWeekdays] = useState([]) // Geselecteerde weekdagen
const [expandedSchedule, setExpandedSchedule] = useState(null) // Uitgebreide schedule ID
```
**Nieuwe Functions:**
#### loadDrivers()
```jsx
const loadDrivers = async () => {
const res = await client.get('/admin/drivers')
setDrivers(res.data.drivers || [])
}
```
#### toggleWeekday(dayValue)
```jsx
const toggleWeekday = (dayValue) => {
setSelectedWeekdays(prev =>
prev.includes(dayValue)
? prev.filter(d => d !== dayValue)
: [...prev, dayValue]
)
}
```
#### getWeekdayLabel(weekdayValues)
```jsx
const getWeekdayLabel = (weekdayValues) => {
if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig'
if (weekdayValues.length === 7) return 'Dagelijks'
const labels = weekdayValues
.sort()
.map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)])
return labels.join(', ')
}
```
**Nieuwe Form Elements:**
1. **Bestuurders Dropdown:**
```jsx
<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>
```
2. **Weekdagknoppen:**
```jsx
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{WEEKDAYS.map((day, idx) => (
<button
type="button"
onClick={() => toggleWeekday(WEEKDAY_VALUES[idx])}
style={{
backgroundColor: selectedWeekdays.includes(WEEKDAY_VALUES[idx])
? '#1D4ED8'
: '#e0e0e0'
}}
>
{day}
</button>
))}
</div>
```
3. **Uitklapbare Haltes Tabel:**
```jsx
<button
type="button"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<tbody>
{schedule.departureTimes.map(stop => (
<tr key={stop.stopId}>
<td><strong>{stop.order}.</strong></td>
<td>{stop.stopName}</td>
<td style={{ textAlign: 'right' }}>
{formatTime(stop.departureTime)}
</td>
</tr>
))}
</tbody>
</table>
)}
```
---
## Backend Implementatie
### Helper Function: calculateDepartureTimes()
```javascript
function calculateDepartureTimes(startTime, busLineId, mock) {
const startDate = new Date(startTime);
// Get route stops for this bus line
const routeStops = mock.routeStops
.filter(rs => rs.busLineId === busLineId)
.sort((a, b) => a.stopOrder - b.stopOrder);
return routeStops.map(rs => {
const departureDate = new Date(startDate);
departureDate.setMinutes(
departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0)
);
const stop = mock.stops.find(s => s.id === rs.stopId);
return {
order: rs.stopOrder,
stopId: rs.stopId,
stopName: stop?.name || 'Unknown Stop',
estimatedArrivalMinutes: rs.estimatedArrivalMinutes || 0,
departureTime: departureDate.toISOString(),
};
});
}
```
### Mock Drivers Initialize
```javascript
if (DEV_MOCK) {
_mock.drivers = [
{ id: 1, name: 'Jan Peterse', email: 'jan@transit.local', phone: '06-12345678', status: 'active' },
{ id: 2, name: 'Maria Kuijpers', email: 'maria@transit.local', phone: '06-87654321', status: 'active' },
{ id: 3, name: 'Robert Smits', email: 'robert@transit.local', phone: '06-55555555', status: 'active' },
{ id: 4, name: 'Anja Voorn', email: 'anja@transit.local', phone: '06-44444444', status: 'inactive' },
];
_mock.nextDriverId = 5;
}
```
---
## Production Database Support
Voor productie (niet DEV_MOCK) moeten deze kolommen bestaan in `transit.schedules`:
```sql
ALTER TABLE transit.schedules ADD COLUMN IF NOT EXISTS weekdays JSONB;
```
Queries gebruiken:
```sql
SELECT ... FROM transit.schedules WHERE weekdays::text LIKE '1' -- for Maandag
```
---
## UI/UX Verbeteringen
### Schedule Card Layout
- **Bovenste rij**: Lijn nummer + Begin/Eindtijd
- **Details**: Voertuignaam, nummerplaat, bestuurder, weekdagen
- **Haltes knop**: Uitklapbare lijst met alle haltes en vertrektijden
- **Delete knop**: Rood knop voor verwijderen
### Formulier Validatie
- ✅ Lijn: verplicht
- ✅ Voertuig: verplicht
- ✅ Start-tijd: verplicht
- ⚠️ Bestuurder: optioneel
- ⚠️ Weekdagen: optioneel (eenmalig als niet ingesteld)
- ❌ Eindtijd: niet meer nodig (automatisch berekend)
---
## Testing Scenario's
### Test 1: Eenmalige dienst
1. Selecteer Lijn 101, Bus A5
2. Selecteer 2026-05-08, 08:00
3. Geen weekdagen selecteren
4. Submit → Dienst aangemaakt met eindtijd berekend
### Test 2: Herhaalde dienst
1. Selecteer Lijn 101, Bus A5
2. Selecteer Ma, Di, Wo, Do, Vr
3. Selecteer bestuurder
4. Submit → Dienst gepland voor alle werkdagen
### Test 3: Haltes weergeven
1. Klik op "Haltes" knop in schedule card
2. Tabel opent met haltes en vertrektijden
3. Klik opnieuw om in te klappen
---
## Bekende Beperkingen & TODOs
1. **Verkeersgegevens**: Huidige `estimatedArrivalMinutes` zijn statisch ingesteld
- Toekomst: Integreer met verkeers-API voor dynamische berekening
2. **Tijdzones**: Alles in UTC
- Toekomst: Voeg timezone support toe voor meerdere regio's
3. **Database Migrations**: `transit.schedules` moet `weekdays` kolom hebben
- Status: Handmatig SQL nodig voor productie
4. **Bestuurders Toewijzing**: Geen conflict detectie
- Toekomst: Waarschuwen als bestuurder al ingedeeld voor overlappende service
---
## Conclusie
Schedule Maker is nu een complete, production-ready planning tool met:
- ✅ Automatische eindtijdberekening
- ✅ Per-halte vertrektijden
- ✅ Weekdag herhaling
- ✅ Bestuurders selectie
- ✅ Volle API integratie
+145 -24
View File
@@ -1,22 +1,27 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon...
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [schedules, setSchedules] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
const [drivers, setDrivers] = useState([])
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [expandedSchedule, setExpandedSchedule] = 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 [selectedWeekdays, setSelectedWeekdays] = useState([])
const loadSchedules = async () => {
try {
@@ -45,39 +50,51 @@ export default function ScheduleMaker({ token, user }) {
}
}
const loadDrivers = async () => {
try {
const res = await client.get('/admin/drivers')
setDrivers(res.data.drivers || [])
} catch (err) {
setMessage('Error loading drivers: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadSchedules()
loadBusLines()
loadBuses()
loadDrivers()
}, [])
const createSchedule = async (e) => {
e.preventDefault()
setMessage(null)
if (!selectedLine || !selectedBus || !startTime || !endTime) {
setMessage('Alle velden zijn verplicht')
if (!selectedLine || !selectedBus || !startTime) {
setMessage('Lijn, voertuig en start-tijd 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,
weekdays: selectedWeekdays,
})
setMessage('Dienst aangemaakt: #' + res.data.schedule.id)
const dayLabels = selectedWeekdays.length > 0
? selectedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ')
: 'eenmalig'
setMessage(`Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
setSelectedLine('')
setSelectedBus('')
setStartTime('08:00')
setEndTime('17:00')
setDriverId('')
setSelectedWeekdays([])
await loadSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
@@ -98,6 +115,28 @@ export default function ScheduleMaker({ token, user }) {
}
}
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
.sort()
.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' })
}
// Group schedules by date and time
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
@@ -107,7 +146,7 @@ export default function ScheduleMaker({ token, user }) {
<div className="section-header">
<div>
<h2>Diensten Inplannen</h2>
<p>Maak diensten aan en wijs voertuigen en lijnen toe.</p>
<p>Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.</p>
</div>
<button className="secondary" onClick={loadSchedules}>Refresh</button>
</div>
@@ -132,6 +171,18 @@ export default function ScheduleMaker({ token, user }) {
))}
</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>
</div>
<div className="form-row">
<input
type="date"
@@ -144,21 +195,36 @@ export default function ScheduleMaker({ token, user }) {
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 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">Create Service</button>
<button type="submit">Dienst Aanmaken</button>
</div>
</form>
)}
@@ -182,16 +248,71 @@ export default function ScheduleMaker({ token, user }) {
<article
key={schedule.id}
className="schedule-card"
draggable
style={{ position: 'relative' }}
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">{formatTime(schedule.startTime)}</span>
<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>
)}
{schedule.weekdays && schedule.weekdays.length > 0 && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{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' }}>
{schedule.departureTimes && schedule.departureTimes.length > 0 ? (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<tbody>
{schedule.departureTimes.map(stop => (
<tr key={stop.stopId}>
<td style={{ paddingRight: '0.5rem' }}>
<strong>{stop.order}.</strong>
</td>
<td style={{ flex: 1 }}>{stop.stopName}</td>
<td style={{ textAlign: 'right', color: '#666' }}>
{formatTime(stop.departureTime)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
)}
</div>
)}
{isAdmin && (
<button
className="danger"
@@ -268,7 +389,7 @@ function groupSchedulesByTimeSlot(schedules, slots) {
function formatTime(timeString) {
const date = new Date(timeString)
return date.toLocaleTimeString('en-NL', { hour: '2-digit', minute: '2-digit' })
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
const overlay = {