@@ -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
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { transitClient } from '../api'
|
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 }) {
|
export default function ScheduleMaker({ token, user }) {
|
||||||
const client = transitClient(token)
|
const client = transitClient(token)
|
||||||
const [schedules, setSchedules] = useState([])
|
const [schedules, setSchedules] = useState([])
|
||||||
const [busLines, setBusLines] = useState([])
|
const [busLines, setBusLines] = useState([])
|
||||||
const [buses, setBuses] = useState([])
|
const [buses, setBuses] = useState([])
|
||||||
|
const [drivers, setDrivers] = 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 [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
const [expandedSchedule, setExpandedSchedule] = useState(null)
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [selectedLine, setSelectedLine] = useState('')
|
const [selectedLine, setSelectedLine] = useState('')
|
||||||
const [selectedBus, setSelectedBus] = useState('')
|
const [selectedBus, setSelectedBus] = useState('')
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||||
const [startTime, setStartTime] = useState('08:00')
|
const [startTime, setStartTime] = useState('08:00')
|
||||||
const [endTime, setEndTime] = useState('17:00')
|
|
||||||
const [driverId, setDriverId] = useState('')
|
const [driverId, setDriverId] = useState('')
|
||||||
|
const [selectedWeekdays, setSelectedWeekdays] = useState([])
|
||||||
|
|
||||||
const loadSchedules = async () => {
|
const loadSchedules = async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
loadSchedules()
|
loadSchedules()
|
||||||
loadBusLines()
|
loadBusLines()
|
||||||
loadBuses()
|
loadBuses()
|
||||||
|
loadDrivers()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createSchedule = async (e) => {
|
const createSchedule = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
setMessage(null)
|
||||||
|
|
||||||
if (!selectedLine || !selectedBus || !startTime || !endTime) {
|
if (!selectedLine || !selectedBus || !startTime) {
|
||||||
setMessage('Alle velden zijn verplicht')
|
setMessage('Lijn, voertuig en start-tijd zijn verplicht')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const startDateTime = `${selectedDate}T${startTime}:00`
|
const startDateTime = `${selectedDate}T${startTime}:00`
|
||||||
const endDateTime = `${selectedDate}T${endTime}:00`
|
|
||||||
|
|
||||||
const res = await client.post('/admin/schedules', {
|
const res = await client.post('/admin/schedules', {
|
||||||
busLineId: Number(selectedLine),
|
busLineId: Number(selectedLine),
|
||||||
busId: Number(selectedBus),
|
busId: Number(selectedBus),
|
||||||
driverId: driverId ? Number(driverId) : null,
|
driverId: driverId ? Number(driverId) : null,
|
||||||
startTime: startDateTime,
|
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('')
|
setSelectedLine('')
|
||||||
setSelectedBus('')
|
setSelectedBus('')
|
||||||
setStartTime('08:00')
|
setStartTime('08:00')
|
||||||
setEndTime('17:00')
|
|
||||||
setDriverId('')
|
setDriverId('')
|
||||||
|
setSelectedWeekdays([])
|
||||||
await loadSchedules()
|
await loadSchedules()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err.response?.data?.error || err.message
|
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
|
// Group schedules by date and time
|
||||||
const timeSlots = generateTimeSlots()
|
const timeSlots = generateTimeSlots()
|
||||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||||
@@ -107,7 +146,7 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Diensten Inplannen</h2>
|
<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>
|
</div>
|
||||||
<button className="secondary" onClick={loadSchedules}>Refresh</button>
|
<button className="secondary" onClick={loadSchedules}>Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,6 +171,18 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="form-row">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -144,21 +195,36 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
onChange={e => setStartTime(e.target.value)}
|
onChange={e => setStartTime(e.target.value)}
|
||||||
placeholder="Start time"
|
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>
|
||||||
|
|
||||||
|
<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">
|
<div className="form-row">
|
||||||
<button type="submit">Create Service</button>
|
<button type="submit">Dienst Aanmaken</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -182,16 +248,71 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
<article
|
<article
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
className="schedule-card"
|
className="schedule-card"
|
||||||
draggable
|
style={{ position: 'relative' }}
|
||||||
>
|
>
|
||||||
<div className="schedule-header">
|
<div className="schedule-header">
|
||||||
<strong>Lijn {schedule.lineNumber}</strong>
|
<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>
|
||||||
<div className="schedule-details">
|
<div className="schedule-details">
|
||||||
<div>{schedule.busName}</div>
|
<div>{schedule.busName}</div>
|
||||||
<div className="schedule-plate">{schedule.licensePlate}</div>
|
<div className="schedule-plate">{schedule.licensePlate}</div>
|
||||||
|
{schedule.driverName && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
|
||||||
|
{schedule.driverName}
|
||||||
</div>
|
</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 && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
className="danger"
|
className="danger"
|
||||||
@@ -268,7 +389,7 @@ function groupSchedulesByTimeSlot(schedules, slots) {
|
|||||||
|
|
||||||
function formatTime(timeString) {
|
function formatTime(timeString) {
|
||||||
const date = new Date(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 = {
|
const overlay = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'screens/daily_schedule_screen.dart';
|
import 'screens/schedule_overview_screen.dart';
|
||||||
|
import 'services/auth_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -19,9 +20,33 @@ class MainApp extends StatelessWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
routes: {
|
routes: {
|
||||||
'/login': (context) => const LoginScreen(),
|
'/login': (context) => const LoginScreen(),
|
||||||
'/schedule': (context) => const DailyScheduleScreen(),
|
'/schedule': (context) => const ScheduleOverviewScreen(),
|
||||||
|
},
|
||||||
|
home: const _InitialRoute(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial route checks if user is logged in
|
||||||
|
class _InitialRoute extends StatelessWidget {
|
||||||
|
const _InitialRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<bool>(
|
||||||
|
future: AuthService().isLoggedIn(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If logged in, go to schedule; otherwise go to login
|
||||||
|
return snapshot.data == true
|
||||||
|
? const ScheduleOverviewScreen()
|
||||||
|
: const LoginScreen();
|
||||||
},
|
},
|
||||||
home: const LoginScreen(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
/// Bus Line model
|
/// Bus Line model
|
||||||
class BusLine {
|
class BusLine {
|
||||||
final int id;
|
final int id;
|
||||||
@@ -140,3 +142,81 @@ class DailySchedule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop model for the new schedule system.
|
||||||
|
class ScheduleStop {
|
||||||
|
final int id;
|
||||||
|
final int order;
|
||||||
|
final String name;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final int estimatedArrivalMinutes;
|
||||||
|
|
||||||
|
ScheduleStop({
|
||||||
|
required this.id,
|
||||||
|
required this.order,
|
||||||
|
required this.name,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.estimatedArrivalMinutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScheduleStop.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ScheduleStop(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
order: (json['order'] as num).toInt(),
|
||||||
|
name: (json['name'] ?? json['stopName'] ?? '') as String,
|
||||||
|
latitude: (json['latitude'] as num).toDouble(),
|
||||||
|
longitude: (json['longitude'] as num).toDouble(),
|
||||||
|
estimatedArrivalMinutes: (json['estimatedArrivalMinutes'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule model for the new public schedule API.
|
||||||
|
class ServiceSchedule {
|
||||||
|
final int id;
|
||||||
|
final int busLineId;
|
||||||
|
final int busId;
|
||||||
|
final DateTime startTime;
|
||||||
|
final DateTime endTime;
|
||||||
|
final String status;
|
||||||
|
final int lineNumber;
|
||||||
|
final String busName;
|
||||||
|
final List<int> weekdays;
|
||||||
|
|
||||||
|
ServiceSchedule({
|
||||||
|
required this.id,
|
||||||
|
required this.busLineId,
|
||||||
|
required this.busId,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.status,
|
||||||
|
required this.lineNumber,
|
||||||
|
required this.busName,
|
||||||
|
required this.weekdays,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ServiceSchedule.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawWeekdays = json['weekdays'];
|
||||||
|
final weekdays = rawWeekdays is List
|
||||||
|
? rawWeekdays.map((day) => (day as num).toInt()).toList()
|
||||||
|
: rawWeekdays is String
|
||||||
|
? (jsonDecode(rawWeekdays) as List).map((day) => (day as num).toInt()).toList()
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
return ServiceSchedule(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
busLineId: (json['busLineId'] as num).toInt(),
|
||||||
|
busId: (json['busId'] as num).toInt(),
|
||||||
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
|
endTime: DateTime.parse(json['endTime'] as String),
|
||||||
|
status: (json['status'] ?? 'planned') as String,
|
||||||
|
lineNumber: (json['lineNumber'] as num).toInt(),
|
||||||
|
busName: (json['busName'] ?? '') as String,
|
||||||
|
weekdays: weekdays,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isRecurring => weekdays.isNotEmpty;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/schedule.dart';
|
import '../models/schedule.dart';
|
||||||
import '../services/schedule_service.dart';
|
import '../services/schedule_service.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/exceptions.dart';
|
||||||
import '../widgets/route_card.dart';
|
import '../widgets/route_card.dart';
|
||||||
|
|
||||||
class DailyScheduleScreen extends StatefulWidget {
|
class DailyScheduleScreen extends StatefulWidget {
|
||||||
@@ -12,6 +14,7 @@ class DailyScheduleScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
||||||
final _scheduleService = ScheduleService();
|
final _scheduleService = ScheduleService();
|
||||||
|
final _authService = AuthService();
|
||||||
late Future<DailySchedule> _scheduleFuture;
|
late Future<DailySchedule> _scheduleFuture;
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
@@ -21,6 +24,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
_scheduleFuture = _scheduleService.getTodaySchedule();
|
_scheduleFuture = _scheduleService.getTodaySchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogout() async {
|
||||||
|
await _authService.logout();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshSchedule() async {
|
Future<void> _refreshSchedule() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
|
_scheduleFuture = _scheduleService.getDailySchedule(_selectedDate);
|
||||||
@@ -65,6 +75,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Today on the Road'),
|
title: const Text('Today on the Road'),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
tooltip: 'Logout',
|
||||||
|
onPressed: _handleLogout,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
body: Container(
|
body: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -149,6 +166,13 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
|
// Auto-logout on session expiry
|
||||||
|
if (snapshot.error is UnauthorizedException) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_handleLogout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -162,15 +186,20 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Failed to load schedule',
|
snapshot.error is UnauthorizedException
|
||||||
|
? 'Session Expired'
|
||||||
|
: 'Failed to load schedule',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
snapshot.error.toString(),
|
snapshot.error is UnauthorizedException
|
||||||
|
? 'Redirecting to login...'
|
||||||
|
: snapshot.error.toString(),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
if (snapshot.error is! UnauthorizedException) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _refreshSchedule,
|
onPressed: _refreshSchedule,
|
||||||
@@ -178,6 +207,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
label: const Text('Retry'),
|
label: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/schedule.dart';
|
||||||
|
import '../services/auth_service.dart';
|
||||||
|
import '../services/schedule_service.dart';
|
||||||
|
|
||||||
|
class ScheduleOverviewScreen extends StatefulWidget {
|
||||||
|
const ScheduleOverviewScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
||||||
|
final _scheduleService = ScheduleService();
|
||||||
|
final _authService = AuthService();
|
||||||
|
|
||||||
|
late Future<List<ServiceSchedule>> _scheduleFuture;
|
||||||
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLogout() async {
|
||||||
|
await _authService.logout();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pushReplacementNamed('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshSchedules() async {
|
||||||
|
setState(() {
|
||||||
|
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate() async {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: _selectedDate,
|
||||||
|
firstDate: DateTime.now().subtract(const Duration(days: 7)),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 30)),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
dialogBackgroundColor: colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null && picked != _selectedDate) {
|
||||||
|
setState(() {
|
||||||
|
_selectedDate = picked;
|
||||||
|
});
|
||||||
|
_refreshSchedules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showScheduleDetails(ServiceSchedule schedule) async {
|
||||||
|
try {
|
||||||
|
final stops = await _scheduleService.getScheduleStops(schedule.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.75,
|
||||||
|
minChildSize: 0.45,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Lijn ${schedule.lineNumber}',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
schedule.busName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_InfoChip(label: schedule.status),
|
||||||
|
_InfoChip(label: schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalig'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Haltes',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (stops.isEmpty)
|
||||||
|
Text(
|
||||||
|
'Geen haltes gekoppeld aan deze dienst.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...stops.map((stop) {
|
||||||
|
final departureTime = schedule.startTime.add(
|
||||||
|
Duration(minutes: stop.estimatedArrivalMinutes),
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text('${stop.order}. ${stop.name}'),
|
||||||
|
subtitle: Text('${stop.estimatedArrivalMinutes} min na vertrek'),
|
||||||
|
trailing: Text(_formatTime(departureTime)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Kon dienstdetails niet laden: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final dateStr =
|
||||||
|
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Today on the Road'),
|
||||||
|
elevation: 0,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
tooltip: 'Logout',
|
||||||
|
onPressed: _handleLogout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: Theme.of(context).brightness == Brightness.dark
|
||||||
|
? const [Color(0xFF0B1220), Color(0xFF111827)]
|
||||||
|
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.16),
|
||||||
|
blurRadius: 22,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Geplande diensten',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.78),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
dateStr,
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Colors.white.withOpacity(0.84),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _selectDate,
|
||||||
|
icon: const Icon(Icons.calendar_today, size: 18),
|
||||||
|
label: const Text('Wijzig datum'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: BorderSide(color: Colors.white.withOpacity(0.28)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FutureBuilder<List<ServiceSchedule>>(
|
||||||
|
future: _scheduleFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Failed to load schedule',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
snapshot.error.toString(),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _refreshSchedules,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedules = snapshot.data ?? [];
|
||||||
|
if (schedules.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.calendar_today,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No routes scheduled',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Check back later for your schedule',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _refreshSchedules,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
itemCount: schedules.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final schedule = schedules[index];
|
||||||
|
return _ScheduleSummaryCard(
|
||||||
|
schedule: schedule,
|
||||||
|
onTap: () => _showScheduleDetails(schedule),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleSummaryCard extends StatelessWidget {
|
||||||
|
final ServiceSchedule schedule;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ScheduleSummaryCard({
|
||||||
|
required this.schedule,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [colorScheme.primary, colorScheme.tertiary],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Line ${schedule.lineNumber}',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_InfoChip(label: schedule.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
schedule.busName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalige dienst',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: onTap,
|
||||||
|
icon: const Icon(Icons.list_alt, size: 18),
|
||||||
|
label: const Text('Haltes'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _InfoChip({required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(DateTime time) {
|
||||||
|
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _weekdayLabel(List<int> weekdays) {
|
||||||
|
const labels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
|
||||||
|
if (weekdays.isEmpty) {
|
||||||
|
return 'Eenmalige dienst';
|
||||||
|
}
|
||||||
|
if (weekdays.length == 7) {
|
||||||
|
return 'Dagelijks';
|
||||||
|
}
|
||||||
|
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// Custom exception for authentication errors
|
||||||
|
class UnauthorizedException implements Exception {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
UnauthorizedException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../models/schedule.dart';
|
import '../models/schedule.dart';
|
||||||
import '../models/navigation.dart';
|
import '../models/navigation.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
import 'exceptions.dart';
|
||||||
|
|
||||||
/// Schedule/Route Service
|
/// Schedule/Route Service
|
||||||
class ScheduleService {
|
class ScheduleService {
|
||||||
@@ -11,6 +12,51 @@ class ScheduleService {
|
|||||||
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
|
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
|
||||||
final _authService = AuthService();
|
final _authService = AuthService();
|
||||||
|
|
||||||
|
/// Get public schedules for a specific date.
|
||||||
|
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
|
||||||
|
try {
|
||||||
|
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
final queryParameters = <String, String>{'date': dateStr};
|
||||||
|
if (lineId != null) {
|
||||||
|
queryParameters['lineId'] = lineId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse('$_apiBaseUrl/schedules').replace(queryParameters: queryParameters);
|
||||||
|
final response = await http.get(uri);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return (data['schedules'] as List? ?? [])
|
||||||
|
.map((schedule) => schedule as Map<String, dynamic>)
|
||||||
|
.map(ServiceSchedule.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Failed to load schedules: ${response.statusCode}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Schedule error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get stop list for a specific schedule.
|
||||||
|
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse('$_apiBaseUrl/schedules/$scheduleId/stops'));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return (data['stops'] as List? ?? [])
|
||||||
|
.map((stop) => stop as Map<String, dynamic>)
|
||||||
|
.map(ScheduleStop.fromJson)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('Failed to load schedule stops: ${response.statusCode}');
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Schedule stops error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get daily schedule for a specific date
|
/// Get daily schedule for a specific date
|
||||||
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
Future<DailySchedule> getDailySchedule(DateTime date) async {
|
||||||
try {
|
try {
|
||||||
@@ -25,12 +71,14 @@ class ScheduleService {
|
|||||||
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);
|
return DailySchedule.fromJson(data);
|
||||||
} else if (response.statusCode == 401) {
|
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
throw Exception('Unauthorized - Please login again');
|
await _authService.logout();
|
||||||
|
throw UnauthorizedException('Session expired - Please login again');
|
||||||
} 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,12 +102,14 @@ class ScheduleService {
|
|||||||
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 Route.fromJson(data);
|
return Route.fromJson(data);
|
||||||
} else if (response.statusCode == 401) {
|
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
throw Exception('Unauthorized - Please login again');
|
await _authService.logout();
|
||||||
|
throw UnauthorizedException('Session expired - Please login again');
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Failed to load route: ${response.statusCode}');
|
throw Exception('Failed to load route: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is UnauthorizedException) rethrow;
|
||||||
throw Exception('Route error: $e');
|
throw Exception('Route error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,10 +126,14 @@ class ScheduleService {
|
|||||||
body: jsonEncode({'status': status}),
|
body: jsonEncode({'status': status}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
await _authService.logout();
|
||||||
|
throw UnauthorizedException('Session expired - Please login again');
|
||||||
|
} else if (response.statusCode != 200) {
|
||||||
throw Exception('Failed to update status: ${response.statusCode}');
|
throw Exception('Failed to update status: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is UnauthorizedException) rethrow;
|
||||||
throw Exception('Status update error: $e');
|
throw Exception('Status update error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,12 +166,14 @@ class ScheduleService {
|
|||||||
.whereType<NavigationPoint>()
|
.whereType<NavigationPoint>()
|
||||||
.toList() ?? [];
|
.toList() ?? [];
|
||||||
return stops;
|
return stops;
|
||||||
} else if (response.statusCode == 401) {
|
} else if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
throw Exception('Unauthorized - Please login again');
|
await _authService.logout();
|
||||||
|
throw UnauthorizedException('Session expired - Please login again');
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Failed to load stops: ${response.statusCode}');
|
throw Exception('Failed to load stops: ${response.statusCode}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e is UnauthorizedException) rethrow;
|
||||||
throw Exception('Get stops error: $e');
|
throw Exception('Get stops error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-14
@@ -17,13 +17,26 @@ const _mock = {
|
|||||||
busLines: [],
|
busLines: [],
|
||||||
fleetBuses: [],
|
fleetBuses: [],
|
||||||
schedules: [],
|
schedules: [],
|
||||||
|
drivers: [],
|
||||||
nextStopId: 1,
|
nextStopId: 1,
|
||||||
nextRouteStopId: 1,
|
nextRouteStopId: 1,
|
||||||
nextBusLineId: 1,
|
nextBusLineId: 1,
|
||||||
nextFleetBusId: 1,
|
nextFleetBusId: 1,
|
||||||
nextScheduleId: 1,
|
nextScheduleId: 1,
|
||||||
|
nextDriverId: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize mock drivers
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const adminUserCodes = new Set(
|
const adminUserCodes = new Set(
|
||||||
(process.env.ADMIN_USER_CODES || '')
|
(process.env.ADMIN_USER_CODES || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -42,6 +55,38 @@ const adminMiddleware = (req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate departure times for each stop in a route
|
||||||
|
* Returns array of { order, stopName, departureTime }
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
// Add estimated arrival minutes to get departure time
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ROUTES - Get daily schedule
|
// ROUTES - Get daily schedule
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -996,13 +1041,47 @@ app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Get drivers for scheduling
|
||||||
|
// ============================================
|
||||||
|
app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
return res.json({ drivers: _mock.drivers.slice().filter(d => d.status === 'active') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT id, name, email, phone
|
||||||
|
FROM workers.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
drivers: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
phone: row.phone,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ADMIN - Manage schedules (timetable)
|
// ADMIN - Manage schedules (timetable)
|
||||||
// ============================================
|
// ============================================
|
||||||
app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (DEV_MOCK) {
|
if (DEV_MOCK) {
|
||||||
return res.json({ schedules: _mock.schedules || [] });
|
const schedules = (_mock.schedules || []).map(s => ({
|
||||||
|
...s,
|
||||||
|
departureTimes: calculateDepartureTimes(s.startTime, s.busLineId, _mock),
|
||||||
|
}));
|
||||||
|
return res.json({ schedules });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db.query(`
|
const result = await db.query(`
|
||||||
@@ -1013,32 +1092,42 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
|
|||||||
s.driver_id,
|
s.driver_id,
|
||||||
s.start_time,
|
s.start_time,
|
||||||
s.end_time,
|
s.end_time,
|
||||||
|
s.weekdays,
|
||||||
s.status,
|
s.status,
|
||||||
s.created_at,
|
s.created_at,
|
||||||
bl.line_number,
|
bl.line_number,
|
||||||
|
bl.estimated_duration_minutes,
|
||||||
b.name as bus_name,
|
b.name as bus_name,
|
||||||
b.license_plate
|
b.license_plate,
|
||||||
|
e.name as driver_name
|
||||||
FROM transit.schedules s
|
FROM transit.schedules s
|
||||||
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
||||||
LEFT JOIN fleet.bus b ON s.bus_id = b.id
|
LEFT JOIN fleet.bus b ON s.bus_id = b.id
|
||||||
|
LEFT JOIN workers.employees e ON s.driver_id = e.id
|
||||||
ORDER BY s.start_time DESC
|
ORDER BY s.start_time DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({
|
const schedules = result.rows.map(row => {
|
||||||
schedules: result.rows.map(row => ({
|
const departureTimes = calculateDepartureTimes(row.start_time, row.bus_line_id, _mock);
|
||||||
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
busLineId: row.bus_line_id,
|
busLineId: row.bus_line_id,
|
||||||
busId: row.bus_id,
|
busId: row.bus_id,
|
||||||
driverId: row.driver_id,
|
driverId: row.driver_id,
|
||||||
|
driverName: row.driver_name || null,
|
||||||
startTime: row.start_time,
|
startTime: row.start_time,
|
||||||
endTime: row.end_time,
|
endTime: row.end_time,
|
||||||
|
weekdays: row.weekdays ? JSON.parse(row.weekdays) : [],
|
||||||
|
departureTimes,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
lineNumber: row.line_number,
|
lineNumber: row.line_number,
|
||||||
busName: row.bus_name,
|
busName: row.bus_name,
|
||||||
licensePlate: row.license_plate,
|
licensePlate: row.license_plate,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
})),
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
res.json({ schedules });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
@@ -1047,21 +1136,42 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
|
|||||||
|
|
||||||
app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { busLineId, busId, driverId, startTime, endTime } = req.body;
|
const { busLineId, busId, driverId, startTime, weekdays } = req.body;
|
||||||
|
|
||||||
if (!busLineId || !busId || !startTime || !endTime) {
|
if (!busLineId || !busId || !startTime) {
|
||||||
return res.status(400).json({ error: 'missing_required_fields' });
|
return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate weekdays - should be array of day numbers (0-6) or empty for single schedule
|
||||||
|
const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0
|
||||||
|
? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (DEV_MOCK) {
|
if (DEV_MOCK) {
|
||||||
if (!_mock.schedules) _mock.schedules = [];
|
if (!_mock.schedules) _mock.schedules = [];
|
||||||
|
|
||||||
|
// Find the bus line to get duration
|
||||||
|
const busLine = _mock.busLines.find(bl => bl.id == busLineId);
|
||||||
|
if (!busLine) {
|
||||||
|
return res.status(404).json({ error: 'bus_line_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(startTime);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60));
|
||||||
|
|
||||||
|
// Calculate departure times for stops
|
||||||
|
const departureTimes = calculateDepartureTimes(startTime, Number(busLineId), _mock);
|
||||||
|
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: (_mock.nextScheduleId || 1),
|
id: (_mock.nextScheduleId || 1),
|
||||||
busLineId,
|
busLineId,
|
||||||
busId,
|
busId,
|
||||||
driverId: driverId || null,
|
driverId: driverId ? Number(driverId) : null,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime: endDate.toISOString(),
|
||||||
|
weekdays: scheduleWeekdays,
|
||||||
|
departureTimes,
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -1070,17 +1180,29 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
|
|||||||
return res.status(201).json({ schedule });
|
return res.status(201).json({ schedule });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Database mode (production)
|
||||||
|
const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]);
|
||||||
|
if (!busLine.rows[0]) {
|
||||||
|
return res.status(404).json({ error: 'bus_line_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(startTime);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
|
||||||
|
|
||||||
const result = await db.query(`
|
const result = await db.query(`
|
||||||
WITH next_id AS (
|
WITH next_id AS (
|
||||||
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
|
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)
|
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'
|
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6
|
||||||
FROM next_id
|
FROM next_id
|
||||||
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
|
||||||
`, [busLineId, busId, driverId || null, startTime, endTime]);
|
`, [busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]);
|
||||||
|
|
||||||
const schedule = result.rows[0];
|
const schedule = result.rows[0];
|
||||||
|
const departureTimes = calculateDepartureTimes(startTime, busLineId, _mock);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
schedule: {
|
schedule: {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
@@ -1089,6 +1211,8 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
|
|||||||
driverId: schedule.driver_id,
|
driverId: schedule.driver_id,
|
||||||
startTime: schedule.start_time,
|
startTime: schedule.start_time,
|
||||||
endTime: schedule.end_time,
|
endTime: schedule.end_time,
|
||||||
|
weekdays: schedule.weekdays ? JSON.parse(schedule.weekdays) : [],
|
||||||
|
departureTimes,
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
createdAt: schedule.created_at,
|
createdAt: schedule.created_at,
|
||||||
},
|
},
|
||||||
@@ -1151,6 +1275,7 @@ app.get('/schedules', async (req, res) => {
|
|||||||
s.bus_id,
|
s.bus_id,
|
||||||
s.start_time,
|
s.start_time,
|
||||||
s.end_time,
|
s.end_time,
|
||||||
|
s.weekdays,
|
||||||
s.status,
|
s.status,
|
||||||
bl.line_number,
|
bl.line_number,
|
||||||
b.name as bus_name
|
b.name as bus_name
|
||||||
@@ -1183,6 +1308,7 @@ app.get('/schedules', async (req, res) => {
|
|||||||
busId: row.bus_id,
|
busId: row.bus_id,
|
||||||
startTime: row.start_time,
|
startTime: row.start_time,
|
||||||
endTime: row.end_time,
|
endTime: row.end_time,
|
||||||
|
weekdays: row.weekdays ? (typeof row.weekdays === 'string' ? JSON.parse(row.weekdays) : row.weekdays) : [],
|
||||||
status: row.status,
|
status: row.status,
|
||||||
lineNumber: row.line_number,
|
lineNumber: row.line_number,
|
||||||
busName: row.bus_name,
|
busName: row.bus_name,
|
||||||
|
|||||||
Reference in New Issue
Block a user