10 Commits

Author SHA1 Message Date
Sven laptop 9a56b40f50 refactor: update ADMIN_USER_CODES configuration; move to auth_api and clarify usage in documentation
Build and Push Docker Images / build-auth-api (push) Has been cancelled
Build and Push Docker Images / build-employee-api (push) Failing after 2m52s
Build and Push Docker Images / build-admin-ui (push) Failing after 24s
Build and Push Docker Images / build-transit-api (push) Failing after 26s
Build and Push Docker Images / build-error-log-api (push) Failing after 27s
2026-05-18 00:33:43 +02:00
Sven laptop 0e19aa0521 feat: add centralized error logging system
- Implemented Error Log API to capture and store error logs from various services.
- Updated admin_ui to include Error Log Viewer component for inspecting logs.
- Enhanced error handling in auth_api, employee_api, and transit_api to report errors to the Error Log API.
- Modified existing services to utilize the new error logging functionality.
- Updated Docker Compose configuration to include the Error Log API service.
- Added error logging service in bus_terminal for Flutter application.
2026-05-17 23:58:23 +02:00
Sven laptop 73de0d713e Enhance navigation and error handling; implement global navigator and messenger keys, update schedule progress retrieval, and improve error messaging in various services 2026-05-17 22:29:41 +02:00
Sven laptop 0e10757c49 Enhance FixedScheduleEditor; implement driver assignment dialog and conflict checking for bulk schedules 2026-05-14 01:30:50 +02:00
Sven laptop ad48e79db2 Enhance FixedScheduleEditor; add time manipulation functions, support for batch wrapping, and improve preset management 2026-05-14 01:04:50 +02:00
Sven laptop b04dfba4c7 feat: add bulk schedule creation endpoint and UI for managing fixed schedules
- Implemented a new POST endpoint for bulk schedule creation in transit_api/index.js.
- Added FixedScheduleEditor component for creating and editing single and batch schedules in admin_ui.
- Introduced TimetableDisplay component for displaying daily schedules with filter options.
- Enhanced error handling and validation for schedule entries.
- Added functionality for saving and loading schedule presets in local storage.
2026-05-13 19:54:55 +02:00
Sven laptop 6e62a9cac1 Enhance schedule management; implement recurring schedules retrieval, update TimetableViewer to display summary, and improve schedule loading logic 2026-05-13 18:15:08 +02:00
Sven laptop 1e41c05214 Enhance schedule management; add recurring schedules screen, implement schedule editing functionality, and improve schedule loading and filtering in ScheduleMaker 2026-05-13 17:41:25 +02:00
Sven laptop 86ffce07b0 Enhance schedule stop progress; add scheduled_passed_date column and update unique constraints 2026-05-12 12:05:34 +02:00
Sven laptop 8caf189c4c Enhance schedule progress tracking; add support for occurrence dates in schedule stop progress and retrieval 2026-05-12 11:54:14 +02:00
41 changed files with 3893 additions and 936 deletions
+43
View File
@@ -9,6 +9,7 @@ on:
- 'auth_api/**'
- 'employee_api/**'
- 'transit_api/**'
- 'error_log_api/**'
- 'admin_ui/**'
- '.github/workflows/docker-build.yml'
pull_request:
@@ -153,6 +154,48 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-error-log-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/error_log_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./error_log_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-admin-ui:
runs-on: ubuntu-latest
permissions:
+2
View File
@@ -1,6 +1,7 @@
.env
/*.env
transit_api/.env
error_log_api/.env
# Local build / node_modules
admin_ui/node_modules/
@@ -11,6 +12,7 @@ ADMIN_DEPLOYMENT_SUMMARY.md
CI_CD_PORTAINER_GUIDE.md
DEPLOYMENT.md
GETTING_STARTED.md
handoff.md
INTERNAL_*.md
SETUP_*.md
DEPLOY_*.md
+1 -1
View File
@@ -24,7 +24,7 @@ POSTGRES_PASSWORD_AUTH=your_password
POSTGRES_USER_TRANSIT=transitAPI
POSTGRES_PASSWORD_TRANSIT=your_password
JWT_SECRET=your-secure-secret
ADMIN_USER_CODES=42
# ADMIN_USER_CODES should be set in auth_api/.env (comma-separated admin user codes)
```
### Step 3: Start Services
+35 -3
View File
@@ -5,6 +5,7 @@ Dit repository bevat een suite van drie Node.js microservices (APIs) en bijbehor
- `auth_api` — Authenticatie en token-uitgifte (standaard port 5000)
- `employee_api` — Employee onboarding / management (standaard port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003)
Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR).
@@ -77,9 +78,14 @@ POSTGRES_USER_EMPLOYEE=employee_user
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password_transit
POSTGRES_USER_ERROR_LOG=error_logger
POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
# JWT secret voor services die JWT gebruiken
JWT_SECRET=super_secret_jwt_key_here
# Optioneel: centraal error log endpoint voor clients/services
ERROR_LOG_API_URL=http://error_log_api:5003/error_log
```
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
@@ -218,6 +224,30 @@ Database schema en migraties zitten niet in deze repository. Zorg dat je:
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt
- gebruikers en rechten configureert voor de verschillende services
### Error logging schema
Voor de centrale foutlogging maak je schema `data` en tabel `data.errors` aan:
```sql
CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"service" TEXT NOT NULL,
"partOfService" TEXT,
"error" TEXT NOT NULL
);
```
Gebruik een aparte gebruiker met minimale rechten, bijvoorbeeld `error_logger`:
```sql
CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log';
GRANT USAGE ON SCHEMA data TO error_logger;
GRANT INSERT, SELECT ON data.errors TO error_logger;
```
Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development):
```bash
@@ -340,10 +370,11 @@ Admin dashboard uses simple JWT-based authentication:
2. Receive JWT token from `auth_api`
3. Use token for admin operations (`/admin/*` endpoints)
Admin authorization is controlled by `ADMIN_USER_CODES` environment variable:
Admin authorization is controlled by `ADMIN_USER_CODES` environment variable configured in the `auth_api` service:
```bash
ADMIN_USER_CODES=42,101,205 # Comma-separated admin user codes
# Set admin user codes for the auth service (comma-separated)
ADMIN_USER_CODES=42,101,205
```
---
@@ -355,7 +386,8 @@ ADMIN_USER_CODES=42,101,205 # Comma-separated admin user codes
Use `DEV_MOCK=1` to work without a database (in-memory mock storage):
```bash
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
# For local development set admin codes on the auth_api and start services
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./auth_api start
```
### Production Mode
+6 -1
View File
@@ -10,6 +10,7 @@ Modern React + Vite web application for comprehensive transit system administrat
✅ **Stop Linking** - Assign stops to bus lines with order and ETA
✅ **JWT Authentication** - Secure authentication with role-based access
✅ **Production Ready** - Docker containerized with runtime env configuration
✅ **Error Log Viewer** - Inspect centralized error logs from all services
## Local Development
@@ -31,6 +32,7 @@ Create a `.env` file in the `admin_ui` directory:
```env
VITE_AUTH_API=http://localhost:5000
VITE_TRANSIT_API=http://localhost:5001
VITE_ERROR_LOG_API=http://localhost:5003
```
### Run Development Server
@@ -109,12 +111,14 @@ When running in Docker, environment variables are injected at runtime via `docke
- `BusLinesManager.jsx` - Create/list bus routes
- `FleetManager.jsx` - View fleet vehicles
- `LinkStop.jsx` - Link stops to bus lines
- `ErrorLogViewer.jsx` - Central error log viewer
### API Integration
The app communicates with:
- **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log)
All requests include JWT Bearer token for authentication.
@@ -130,7 +134,7 @@ All requests include JWT Bearer token for authentication.
### Admin Authorization
Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` environment variable in the transit API to access admin endpoints.
Users must have a `userCode` in their JWT and, for admin access, the `isAdmin` flag set by the `auth_api` based on the `ADMIN_USER_CODES` environment variable.
### Production Setup
@@ -139,6 +143,7 @@ Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` enviro
3. Configure proper JWT generation in auth_api
4. Set strong `JWT_SECRET` environment variable
5. Configure `ADMIN_USER_CODES` with actual admin user IDs
6. Configure `VITE_ERROR_LOG_API` if you use a custom logging endpoint
## Troubleshooting
+2 -1
View File
@@ -4,7 +4,8 @@ set -e
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = {
AUTH_API: "${AUTH_API:-http://localhost:5000}",
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}"
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}",
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}"
}
EOF
+2
View File
@@ -5,6 +5,7 @@ import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
@@ -46,6 +47,7 @@ export default function App() {
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} />
<ErrorLogViewer token={token} />
</main>
</div>
)
+41
View File
@@ -14,9 +14,38 @@ const normalizeApiBaseUrl = (value, fallbackPath) => {
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
const reportClientError = async ({ service, partOfService, error }) => {
try {
await fetch(`${ERROR_LOG_API}/error_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service,
partOfService,
error: typeof error === 'string' ? error : JSON.stringify(error),
}),
})
} catch (e) {
// ignore logging failures
}
}
export const authClient = axios.create({ baseURL: AUTH_API })
authClient.interceptors.response.use(
response => response,
error => {
void reportClientError({
service: 'admin_ui',
partOfService: 'authClient',
error: error?.response?.data?.error || error?.message || 'authClient_error',
})
return Promise.reject(error)
}
)
export const transitClient = (token) => {
const client = axios.create({
baseURL: TRANSIT_API,
@@ -35,6 +64,11 @@ export const transitClient = (token) => {
// reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload()
}
void reportClientError({
service: 'admin_ui',
partOfService: 'transitClient',
error: error?.response?.data?.error || error?.message || 'transitClient_error',
})
} catch (e) {
// ignore
}
@@ -44,3 +78,10 @@ export const transitClient = (token) => {
return client
}
export const errorLogClient = (token) => axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
export { reportClientError }
+1 -1
View File
@@ -11,7 +11,7 @@ export default function BusLinesManager({ token, user }) {
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = useState(null)
@@ -0,0 +1,74 @@
import React, { useEffect, useMemo, useState } from 'react'
import { errorLogClient } from '../api'
const formatDate = (value) => {
if (!value) return '—'
try {
return new Date(value).toLocaleString()
} catch (_) {
return String(value)
}
}
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token])
const [logs, setLogs] = useState([])
const [service, setService] = useState('')
const [partOfService, setPartOfService] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState(null)
const loadLogs = async () => {
setLoading(true)
setMessage(null)
try {
const params = { limit: 100 }
if (service) params.service = service
if (partOfService) params.partOfService = partOfService
const res = await client.get('/error_log', { params })
setLogs(res.data.errors || [])
} catch (err) {
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadLogs()
}, [service, partOfService])
return (
<section className="card">
<div className="section-header">
<div>
<h2>Error log</h2>
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? 'Laden...' : 'Ververs'}
</button>
</div>
<div className="form-row">
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} />
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} />
</div>
{message && <div className="message">{message}</div>}
<div className="list-grid">
{logs.map(log => (
<article className="mini-card" key={log.id}>
<strong>{log.service}</strong>
<div>{formatDate(log.date)}</div>
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
<div style={{ marginTop: 8 }}>{log.error}</div>
</article>
))}
</div>
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
</section>
)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ export default function FleetManager({ token, user }) {
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
+1 -1
View File
@@ -13,7 +13,7 @@ export default function LinkStop({ token, user }) {
const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode)
const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops])
+34 -547
View File
@@ -1,52 +1,25 @@
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...
import TimetableDisplay from './TimetableDisplay'
import FixedScheduleEditor from './FixedScheduleEditor'
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [schedules, setSchedules] = useState([])
const [overviewSchedules, setOverviewSchedules] = useState([])
const [scheduleTemplates, setScheduleTemplates] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({})
// Form state
const [selectedLine, setSelectedLine] = useState('')
const [selectedBus, setSelectedBus] = useState('')
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const [startTime, setStartTime] = useState('08:00')
const [driverId, setDriverId] = useState('')
const [selectedWeekdays, setSelectedWeekdays] = useState([])
const loadSchedules = async () => {
const loadScheduleTemplates = async () => {
try {
const res = await client.get('/admin/schedules')
setSchedules(res.data.schedules || [])
const res = await client.get('/admin/schedules?templates=1')
setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0))
} catch (err) {
setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message))
}
}
const loadOverviewSchedules = async () => {
try {
const params = new URLSearchParams({ date: overviewDate })
if (selectedDriverFilter) {
params.set('driverId', selectedDriverFilter)
}
const res = await client.get(`/schedules/overview?${params.toString()}`)
setOverviewSchedules(res.data.schedules || [])
} catch (err) {
setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message))
setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message))
}
}
@@ -78,529 +51,43 @@ export default function ScheduleMaker({ token, user }) {
}
useEffect(() => {
loadSchedules()
loadScheduleTemplates()
loadBusLines()
loadBuses()
loadDrivers()
}, [])
useEffect(() => {
loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter])
const createSchedule = async (e) => {
e.preventDefault()
setMessage(null)
if (!selectedLine || !selectedBus || !startTime) {
setMessage('Lijn, voertuig en start-tijd zijn verplicht')
return
}
try {
const startDateTime = `${selectedDate}T${startTime}:00`
const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b)
const res = await client.post('/admin/schedules', {
busLineId: Number(selectedLine),
busId: Number(selectedBus),
driverId: driverId ? Number(driverId) : null,
startTime: startDateTime,
weekdays: normalizedWeekdays,
})
const dayLabels = normalizedWeekdays.length > 0
? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ')
: 'eenmalig'
setMessage(`Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
setSelectedLine('')
setSelectedBus('')
setStartTime('08:00')
setDriverId('')
setSelectedWeekdays([])
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
const handleScheduleChange = async () => {
await loadScheduleTemplates()
}
const deleteSchedule = async (scheduleId, lineNumber, busName) => {
setMessage(null)
try {
await client.delete(`/admin/schedules/${scheduleId}`)
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
setDeleteConfirm(null)
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}
const toggleWeekday = (dayValue) => {
setSelectedWeekdays(prev =>
prev.includes(dayValue)
? prev.filter(d => d !== dayValue)
: [...prev, dayValue]
)
}
const getWeekdayLabel = (weekdayValues) => {
if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig'
if (weekdayValues.length === 7) return 'Dagelijks'
const labels = weekdayValues
.slice()
.sort((a, b) => a - b)
.map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)])
return labels.join(', ')
}
const formatTime = (timeString) => {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
// Fetch route stops for a given line and cache them in state
const loadRouteStops = async (busLineId) => {
if (!busLineId) return
try {
const res = await client.get(`/admin/bus-lines/${busLineId}/stops`)
const routeStops = res.data.routeStops || []
setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops }))
} catch (err) {
// ignore silently, message already used elsewhere
}
}
const getCandidateWindow = () => {
const line = busLines.find(l => String(l.id) === String(selectedLine))
if (!line || !selectedDate || !startTime) return null
const start = new Date(`${selectedDate}T${startTime}:00`)
const end = new Date(start)
end.setMinutes(end.getMinutes() + (line.estimatedDurationMinutes || 60))
return { start, end }
}
const hasOverlap = (candidate, existing) => {
if (!candidate || !existing?.startTime || !existing?.endTime) return false
const cStart = candidate.start.getTime()
const cEnd = candidate.end.getTime()
const eStart = new Date(existing.startTime).getTime()
const eEnd = new Date(existing.endTime).getTime()
const timeOverlap = cStart < eEnd && cEnd > eStart
if (!timeOverlap) return false
const cWeekdays = selectedWeekdays || []
const eWeekdays = Array.isArray(existing.weekdays) ? existing.weekdays : []
if (cWeekdays.length === 0 && eWeekdays.length === 0) {
return candidate.start.toDateString() === new Date(existing.startTime).toDateString()
}
if (cWeekdays.length > 0 && eWeekdays.length > 0) {
return cWeekdays.some(d => eWeekdays.includes(d))
}
if (cWeekdays.length > 0 && eWeekdays.length === 0) {
return cWeekdays.includes(new Date(existing.startTime).getDay())
}
return eWeekdays.includes(candidate.start.getDay())
}
const candidateWindow = getCandidateWindow()
const busyDriverIds = new Set(
schedules
.filter(s => s.driverId && hasOverlap(candidateWindow, s))
.map(s => Number(s.driverId))
)
const busyBusIds = new Set(
schedules
.filter(s => s.busId && hasOverlap(candidateWindow, s))
.map(s => Number(s.busId))
)
const scheduleHasDriverConflict = (schedule) => {
if (!schedule?.driverId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.driverId) === Number(schedule.driverId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
const scheduleHasBusConflict = (schedule) => {
if (!schedule?.busId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.busId) === Number(schedule.busId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
// Group schedules by date and time using the API-backed overview
const timeSlots = generateTimeSlots()
const schedulesBySlot = groupSchedulesByTimeSlot(overviewSchedules, timeSlots)
return (
<section className="card">
<div className="section-header">
<div>
<h2>Diensten Inplannen</h2>
<p>Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button className="secondary" onClick={loadSchedules}>Refresh</button>
<select
value={selectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)}
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
<div>
{/* Timetable widget - compact daily view */}
<TimetableDisplay
client={client}
overviewDate={overviewDate}
setOverviewDate={setOverviewDate}
selectedDriverFilter={selectedDriverFilter}
setSelectedDriverFilter={setSelectedDriverFilter}
drivers={drivers}
/>
{/* Fixed service editor - separate widget */}
{isAdmin && (
<form onSubmit={createSchedule} className="stack-form">
<div className="form-row">
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
<option value="">Kies lijn</option>
{busLines.map(line => (
<option key={line.id} value={line.id}>
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
</option>
))}
</select>
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
<option value="">Kies voertuig</option>
{buses.map(bus => (
<option key={bus.id} value={bus.id} disabled={busyBusIds.has(Number(bus.id))}>
{bus.name} ({bus.licensePlate}) {busyBusIds.has(Number(bus.id)) ? '- in gebruik' : ''}
</option>
))}
</select>
</div>
<div className="form-row">
<select value={driverId} onChange={e => setDriverId(e.target.value)}>
<option value="">Bestuurder (optioneel)</option>
{drivers.map(driver => (
<option key={driver.id} value={driver.id} disabled={busyDriverIds.has(Number(driver.id))}>
{driver.name} {busyDriverIds.has(Number(driver.id)) ? '- in gebruik' : ''}
</option>
))}
</select>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
/>
<input
type="time"
value={startTime}
onChange={e => setStartTime(e.target.value)}
placeholder="Start time"
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>
Herhaal op weekdagen (optioneel):
</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{WEEKDAYS.map((day, idx) => (
<button
key={day}
type="button"
onClick={() => toggleWeekday(WEEKDAY_VALUES[idx])}
style={{
padding: '0.5rem 1rem',
backgroundColor: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#1D4ED8' : '#e0e0e0',
color: selectedWeekdays.includes(WEEKDAY_VALUES[idx]) ? '#fff' : '#000',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{day}
</button>
))}
</div>
</div>
<div className="form-row">
<button type="submit">Dienst Aanmaken</button>
</div>
</form>
)}
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
<div className="form-row" style={{ marginBottom: '1rem' }}>
<input
type="date"
value={overviewDate}
onChange={e => setOverviewDate(e.target.value)}
<FixedScheduleEditor
client={client}
token={token}
user={user}
scheduleTemplates={scheduleTemplates}
busLines={busLines}
buses={buses}
drivers={drivers}
message={message}
setMessage={setMessage}
onScheduleChange={handleScheduleChange}
/>
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
</button>
</div>
<div className="timetable-container">
<table className="timetable">
<thead>
<tr>
<th>Tijdvak</th>
<th>Lijnen en Voertuigen</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => (
<tr key={idx}>
<td className="time-cell">{slot}</td>
<td className="schedule-cell">
<div className="schedule-items">
{schedulesBySlot[slot]?.map(schedule => (
<article
key={schedule.id}
className="schedule-card"
style={{ position: 'relative' }}
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
{schedule.driverName && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.driverName}
</div>
)}
{scheduleHasDriverConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Chauffeur in gebruik
</div>
)}
{scheduleHasBusConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Voertuig in gebruik
</div>
)}
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
</div>
</div>
{/* Expandable stops list */}
<button
type="button"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
style={{
marginTop: '0.5rem',
width: '100%',
padding: '0.4rem',
fontSize: '0.85rem',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
{(() => {
const lineId = schedule.busLineId
const cached = routeStopsByLine[lineId]
if (!cached) {
// Load and show loading text
loadRouteStops(lineId)
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
}
if (cached.length === 0) {
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
}
// Render editable list of stops with ETA inputs
return (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<tbody>
{cached.map(rs => {
const departureDate = new Date(schedule.startTime)
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
return (
<tr key={rs.id}>
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
<td style={{ flex: 1 }}>{rs.name}</td>
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
<td style={{ paddingLeft: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="range"
min="0"
max="240"
value={Number(rs.estimatedArrivalMinutes ?? 0)}
onChange={e => {
const val = Number(e.target.value)
setRouteStopsByLine(prev => ({
...prev,
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
}))
}}
style={{ width: '8rem' }}
/>
<input
type="number"
value={Number(rs.estimatedArrivalMinutes ?? 0)}
onChange={e => {
const val = e.target.value === '' ? 0 : Number(e.target.value)
setRouteStopsByLine(prev => ({
...prev,
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
}))
}}
style={{ width: '4.5rem' }}
/>
</div>
<button type="button" onClick={async () => {
const newVal = Number((routeStopsByLine[lineId].find(i => i.id === rs.id)?.estimatedArrivalMinutes) || 0)
try {
await client.post(`/admin/bus-lines/${lineId}/stops`, {
stopId: rs.stopId,
stopOrder: rs.stopOrder,
estimatedArrivalMinutes: newVal,
})
setMessage('ETA updated')
await loadRouteStops(lineId)
await loadSchedules()
await loadOverviewSchedules()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}} style={{ marginLeft: '0.5rem' }}>Save</button>
</td>
</tr>
)
})}
</tbody>
</table>
)
})()}
</div>
)}
{isAdmin && (
<button
className="danger"
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
onClick={() => setDeleteConfirm(schedule)}
>
Delete
</button>
)}
</article>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{message && <div className="message">{message}</div>}
{deleteConfirm && (
<div style={overlay}>
<div style={modal}>
<h3>Delete Service?</h3>
<p>
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
</p>
<p style={{ color: '#999', fontSize: '0.9rem' }}>
{new Date(deleteConfirm.startTime).toLocaleString()}
</p>
<div className="form-row" style={{ marginTop: '1rem' }}>
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
<button
className="danger"
onClick={() =>
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
}
>
Delete
</button>
</div>
</div>
</div>
)}
</section>
</div>
)
}
function generateTimeSlots() {
const slots = []
for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
}
return slots
}
function groupSchedulesByTimeSlot(schedules, slots) {
const grouped = {}
slots.forEach(slot => {
grouped[slot] = []
})
schedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
if (grouped[slot]) {
grouped[slot].push(schedule)
}
})
return grouped
}
function formatTime(timeString) {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
const overlay = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}
const modal = {
backgroundColor: '#fff',
padding: '2rem',
borderRadius: '8px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function StopsManager({ token, user }) {
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = useState(null)
@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react'
const TimetableDisplay = ({ client, overviewDate, setOverviewDate, selectedDriverFilter, setSelectedDriverFilter, drivers }) => {
const [overviewSchedules, setOverviewSchedules] = useState([])
const [message, setMessage] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({})
const loadOverviewSchedules = async () => {
try {
const params = new URLSearchParams({ date: overviewDate })
if (selectedDriverFilter) {
params.set('driverId', selectedDriverFilter)
}
const res = await client.get(`/schedules/overview?${params.toString()}`)
setOverviewSchedules(res.data.schedules || [])
} catch (err) {
setMessage('Error loading timetable: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter])
const formatTime = (timeString) => {
const date = new Date(timeString)
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
}
const loadRouteStops = async (busLineId) => {
if (!busLineId) return
try {
const res = await client.get(`/admin/bus-lines/${busLineId}/stops`)
const routeStops = res.data.routeStops || []
setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops }))
} catch (err) {
// ignore silently
}
}
const getWeekdayLabel = (weekdayValues) => {
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0]
if (!weekdayValues || weekdayValues.length === 0) return 'Eenmalig'
if (weekdayValues.length === 7) return 'Dagelijks'
const labels = weekdayValues
.slice()
.sort((a, b) => a - b)
.map(w => WEEKDAYS[WEEKDAY_VALUES.indexOf(w)])
return labels.join(', ')
}
const timeSlots = (() => {
const slots = []
for (let hour = 0; hour < 24; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
}
return slots
})()
const schedulesBySlot = (() => {
const grouped = {}
timeSlots.forEach(slot => {
grouped[slot] = []
})
overviewSchedules.forEach(schedule => {
const startHour = new Date(schedule.startTime).getHours()
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
if (grouped[slot]) {
grouped[slot].push(schedule)
}
})
return grouped
})()
return (
<section className="card" style={{ marginTop: '2rem' }}>
<div className="section-header">
<div>
<h3>Dagelijks Overzicht</h3>
<p>Alle geplande diensten voor de geselecteerde dag</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="date"
value={overviewDate}
onChange={e => setOverviewDate(e.target.value)}
/>
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
</button>
<select
value={selectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)}
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }}
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
<div className="timetable-container">
<table className="timetable">
<thead>
<tr>
<th>Tijdvak</th>
<th>Lijnen en Voertuigen</th>
</tr>
</thead>
<tbody>
{timeSlots.map((slot, idx) => (
<tr key={idx}>
<td className="time-cell">{slot}</td>
<td className="schedule-cell">
<div className="schedule-items">
{schedulesBySlot[slot]?.map(schedule => (
<article
key={schedule.id}
className="schedule-card"
style={{ position: 'relative' }}
>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
{schedule.driverName && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.driverName}
</div>
)}
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
</div>
</div>
{/* Expandable stops list */}
<button
type="button"
onClick={() => setExpandedSchedule(expandedSchedule === schedule.id ? null : schedule.id)}
style={{
marginTop: '0.5rem',
width: '100%',
padding: '0.4rem',
fontSize: '0.85rem',
backgroundColor: '#f0f0f0',
border: '1px solid #ddd',
borderRadius: '3px',
cursor: 'pointer',
}}
>
{expandedSchedule === schedule.id ? '▼' : '▶'} Haltes ({schedule.departureTimes?.length || 0})
</button>
{expandedSchedule === schedule.id && (
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
{(() => {
const lineId = schedule.busLineId
const cached = routeStopsByLine[lineId]
if (!cached) {
loadRouteStops(lineId)
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
}
if (cached.length === 0) {
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
}
return (
<table style={{ width: '100%', fontSize: '0.8rem' }}>
<tbody>
{cached.map(rs => {
const departureDate = new Date(schedule.startTime)
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
return (
<tr key={rs.id}>
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
<td style={{ flex: 1 }}>{rs.name}</td>
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
</tr>
)
})}
</tbody>
</table>
)
})()}
</div>
)}
</article>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{message && <div className="message">{message}</div>}
</section>
)
}
export default TimetableDisplay
+26 -4
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([])
const [summary, setSummary] = useState(null)
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([])
@@ -28,13 +29,14 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
setLoading(true)
setError(null)
try {
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
if (selectedLineId) {
url += `&lineId=${selectedLineId}`
}
const res = await fetch(url)
const data = await res.json()
setSchedules(data.schedules || [])
setSummary(data.summary || null)
} catch (err) {
setError('Failed to load schedules: ' + err.message)
} finally {
@@ -72,6 +74,20 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
</select>
</div>
{summary && (
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#eef2ff', color: '#3730a3' }}>
Total: {summary.total}
</span>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#ecfeff', color: '#155e75' }}>
Vast: {summary.recurring}
</span>
<span style={{ padding: '6px 10px', borderRadius: '999px', backgroundColor: '#f8fafc', color: '#334155' }}>
Eenmalig: {summary.oneTime}
</span>
</div>
)}
{loading && <p>Loading schedules...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
@@ -81,7 +97,8 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Day status</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Delay</th>
</tr>
</thead>
<tbody>
@@ -104,14 +121,19 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'active' ? '#166534' : '#374151',
backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
fontSize: '0.9rem',
}}
>
{schedule.status}
</span>
</td>
<td style={{ padding: '12px' }}>
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
: '—'}
</td>
</tr>
))
})}
+51 -4
View File
@@ -18,7 +18,51 @@ app.use((req, res, next) => {
next();
});
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'auth_api',
partOfService,
error,
}),
});
} catch (_) {}
}
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USER_CODES = new Set(
String(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(c => Number(c.trim()))
.filter(Number.isFinite)
);
app.post('/login', async (req, res) => {
@@ -33,13 +77,15 @@ app.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
const payload = { userId: user.id, userCode: user.usercode };
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
const payload = { userId: user.id, userCode: user.usercode, isAdmin };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
res.json({ token, user: { id: user.id, userCode: user.userCode, job: user.job } });
res.json({ token, user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin } });
} catch (err) {
await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({ error: 'internal_error' });
res.status(500).json({ error: friendlyErrorMessage(500) });
}
});
@@ -51,7 +97,8 @@ app.get('/me', async (req, res) => {
const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload });
} catch (err) {
res.status(401).json({ error: 'invalid_token' });
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
res.status(401).json({ error: friendlyErrorMessage(401) });
}
});
+40 -1
View File
@@ -1,11 +1,48 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart';
import 'theme/app_theme.dart';
import 'services/app_navigator.dart';
import 'services/error_log_service.dart';
void main() {
runApp(const MainApp());
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
void report() => ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'FlutterError.onError',
error: details.exceptionAsString(),
);
// ignore: unawaited_futures
report();
};
PlatformDispatcher.instance.onError = (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'PlatformDispatcher.onError',
error: '$error\n$stack',
);
return true;
};
runZonedGuarded(() {
runApp(const MainApp());
}, (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'runZonedGuarded',
error: '$error\n$stack',
);
});
}
class MainApp extends StatelessWidget {
@@ -15,6 +52,8 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bus Terminal',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.system,
+2 -1
View File
@@ -19,7 +19,8 @@ class User {
} else if (rawUserCode is String) {
parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode'));
} else {
throw FormatException('Missing userCode');
// If the backend doesn't return a userCode, treat as unknown (-1)
parsedUserCode = -1;
}
return User(
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import '../models/schedule.dart';
import '../models/schedule.dart' as schedule;
import '../services/schedule_service.dart';
import '../services/auth_service.dart';
import '../services/exceptions.dart';
@@ -15,7 +15,7 @@ class DailyScheduleScreen extends StatefulWidget {
class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<DailySchedule> _scheduleFuture;
late Future<schedule.DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
@@ -62,6 +62,16 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
}
}
// Group routes by hour for better visual organization
Map<int, List<schedule.Route>> _groupByHour(List<schedule.Route> routes) {
final grouped = <int, List<schedule.Route>>{};
for (final route in routes) {
final hour = route.startTime.hour;
grouped.putIfAbsent(hour, () => []).add(route);
}
return grouped;
}
void _moveDay(int deltaDays) {
setState(() {
_selectedDate = _selectedDate.add(Duration(days: deltaDays));
@@ -198,7 +208,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
),
),
Expanded(
child: FutureBuilder<DailySchedule>(
child: FutureBuilder<schedule.DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
@@ -267,12 +277,12 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
),
const SizedBox(height: 16),
Text(
'No routes scheduled',
'Geen actieve ritten',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Check back later for your schedule',
'Je hebt geen geplande ritten voor deze dag',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -288,25 +298,64 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
..sort((a, b) => a.startTime.compareTo(b.startTime));
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
final completedRoutes = routes.where((r) => r.status == 'completed').toList();
// Merge and group by hour
final allActiveRoutes = [...inProgressRoutes, ...scheduledRoutes]
..sort((a, b) => a.startTime.compareTo(b.startTime));
final hourGroups = _groupByHour(allActiveRoutes);
final sortedHours = hourGroups.keys.toList()..sort();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
if (inProgressRoutes.isNotEmpty) ...[
_sectionHeader('In Progress', colorScheme.tertiary),
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (scheduledRoutes.isNotEmpty) ...[
_sectionHeader('Scheduled', colorScheme.primary),
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (completedRoutes.isNotEmpty) ...[
_sectionHeader('Completed', colorScheme.secondary),
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (allActiveRoutes.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
'Geen actieve ritten voor deze dag',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
)
else
...sortedHours.map((hour) {
final routesForHour = hourGroups[hour] ?? [];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
child: Row(
children: [
Text(
'${hour.toString().padLeft(2, '0')}:00 uur',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant,
),
),
],
),
),
...routesForHour.map((route) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: RouteCard(route: route, onTap: () {}),
)),
const SizedBox(height: 4),
],
);
}),
const SizedBox(height: 8),
],
),
@@ -319,17 +368,4 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
),
);
}
Widget _sectionHeader(String title, Color color) {
return Padding(
padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
}
+17 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget {
@@ -37,7 +38,17 @@ class _LoginScreenState extends State<LoginScreen> {
final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) {
throw Exception('User code must be a number');
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin',
error: 'User code must be a number',
);
if (mounted) {
setState(() {
_errorMessage = 'User code must be a number';
});
}
return;
}
await _authService.login(
@@ -50,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
Navigator.of(context).pushReplacementNamed('/schedule');
}
} catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
@@ -0,0 +1,332 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import '../models/schedule.dart' as sched;
import '../services/schedule_service.dart';
class RecurringSchedulesScreen extends StatefulWidget {
const RecurringSchedulesScreen({super.key});
@override
State<RecurringSchedulesScreen> createState() => _RecurringSchedulesScreenState();
}
class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
final _scheduleService = ScheduleService();
late Future<List<sched.ServiceSchedule>> _schedulesFuture;
String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
final Set<int> _expandedLineIds = {};
@override
void initState() {
super.initState();
_loadRecurringSchedules();
}
void _loadRecurringSchedules() {
_schedulesFuture = _scheduleService.getRecurringSchedules();
}
String _formatWeekdays(List<int> weekdays) {
const dayLabels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
if (weekdays.isEmpty) return 'Eenmalig';
if (weekdays.length == 7) return 'Dagelijks';
final sorted = weekdays.toSet().toList()..sort((a, b) => a.compareTo(b));
return sorted.map((d) => dayLabels[d]).join(', ');
}
String _formatTime(DateTime dt) {
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
List<sched.ServiceSchedule> _sortSchedules(List<sched.ServiceSchedule> schedules) {
final sorted = List<sched.ServiceSchedule>.from(schedules);
switch (_selectedSortBy) {
case 'line':
sorted.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
break;
case 'time':
sorted.sort((a, b) => a.startTime.compareTo(b.startTime));
break;
case 'weekday':
sorted.sort((a, b) {
final aFirst = a.weekdays.isEmpty ? 7 : a.weekdays.first;
final bFirst = b.weekdays.isEmpty ? 7 : b.weekdays.first;
return aFirst.compareTo(bFirst);
});
break;
}
return sorted;
}
Map<int, List<sched.ServiceSchedule>> _groupByLine(List<sched.ServiceSchedule> schedules) {
final grouped = <int, List<sched.ServiceSchedule>>{};
for (final schedule in schedules) {
grouped.putIfAbsent(schedule.lineNumber, () => []).add(schedule);
}
return grouped;
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Vaste Diensten'),
centerTitle: true,
actions: [
PopupMenuButton<String>(
onSelected: (value) {
setState(() => _selectedSortBy = value);
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'line',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sort_by_alpha, color: _selectedSortBy == 'line' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Lijn'),
],
),
),
PopupMenuItem(
value: 'time',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, color: _selectedSortBy == 'time' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Tijd'),
],
),
),
PopupMenuItem(
value: 'weekday',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, color: _selectedSortBy == 'weekday' ? colorScheme.primary : null),
const SizedBox(width: 8),
const Text('Weekdag'),
],
),
),
],
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Icon(Icons.filter_list),
),
),
],
),
body: FutureBuilder<List<sched.ServiceSchedule>>(
future: _schedulesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'Fout bij laden: ${snapshot.error}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() => _loadRecurringSchedules());
},
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final allSchedules = snapshot.data ?? [];
// Filter to show only truly recurring schedules (with weekdays > 0)
final schedules = allSchedules.where((s) => s.weekdays.isNotEmpty).toList();
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text(
'Geen herhaalde diensten gevonden',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Herhaalde diensten worden hier weergegeven',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final sortedSchedules = _sortSchedules(schedules);
final groupedByLine = _groupByLine(sortedSchedules);
return ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
itemCount: groupedByLine.length,
itemBuilder: (context, lineIndex) {
final lineNumber = groupedByLine.keys.toList()[lineIndex];
final lineSchedules = groupedByLine[lineNumber] ?? [];
final isExpanded = _expandedLineIds.contains(lineNumber);
return Column(
children: [
Card(
child: InkWell(
onTap: () {
setState(() {
if (isExpanded) {
_expandedLineIds.remove(lineNumber);
} else {
_expandedLineIds.add(lineNumber);
}
});
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lijn $lineNumber',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'${lineSchedules.length} diensten',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
Icon(
isExpanded ? Icons.expand_less : Icons.expand_more,
color: colorScheme.primary,
),
],
),
),
),
),
if (isExpanded)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: lineSchedules.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, scheduleIndex) {
final schedule = lineSchedules[scheduleIndex];
return _ScheduleCard(schedule: schedule, formatTime: _formatTime, formatWeekdays: _formatWeekdays);
},
),
),
const SizedBox(height: 8),
],
);
},
);
},
),
);
}
}
class _ScheduleCard extends StatelessWidget {
final sched.ServiceSchedule schedule;
final String Function(DateTime) formatTime;
final String Function(List<int>) formatWeekdays;
const _ScheduleCard({
required this.schedule,
required this.formatTime,
required this.formatWeekdays,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${formatTime(schedule.startTime)} - ${formatTime(schedule.endTime)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
schedule.busName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.outline,
),
),
],
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
label: Text(
formatWeekdays(schedule.weekdays),
style: Theme.of(context).textTheme.labelSmall,
),
avatar: const Icon(Icons.calendar_today, size: 16),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
}
}
@@ -6,6 +6,7 @@ import '../models/navigation.dart';
import '../services/auth_service.dart';
import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart';
import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key});
@@ -14,17 +15,25 @@ class ScheduleOverviewScreen extends StatefulWidget {
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
}
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<List<sched.ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
late TabController _tabController;
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _handleLogout() async {
@@ -111,16 +120,17 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
],
),
const SizedBox(height: 20),
SizedBox(
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
onPressed: () async {
try {
await _scheduleService.updateScheduleStatus(svc.id, 'in_progress');
await _scheduleService.updateScheduleStatus(
svc.id,
'in_progress',
date: svc.startTime,
);
if (!mounted) return;
// Close the sheet and start navigation with provided stops
Navigator.of(sheetContext).pop();
final routeObj = sched.Route(
@@ -151,11 +161,18 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
))
.toList();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
),
);
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
),
);
} catch (navErr) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
@@ -209,14 +226,19 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
@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'),
title: const Text('Diensten'),
centerTitle: true,
elevation: 0,
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
Tab(icon: Icon(Icons.timeline), text: 'Voortgang'),
],
),
actions: [
IconButton(
icon: const Icon(Icons.logout),
@@ -225,174 +247,298 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
),
],
),
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,
body: TabBarView(
controller: _tabController,
children: [
_DailyScheduleTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
onSelectDate: _selectDate,
onRefresh: _refreshSchedules,
onShowDetails: _showScheduleDetails,
scheduleFuture: _scheduleFuture,
),
const RecurringSchedulesScreen(),
_ProgressTab(
scheduleService: _scheduleService,
selectedDate: _selectedDate,
scheduleFuture: _scheduleFuture,
),
],
),
);
}
}
class _ProgressTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _ProgressTab({
required this.scheduleService,
required this.selectedDate,
required this.scheduleFuture,
});
@override
Widget build(BuildContext context) {
final dateStr = '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Voortgang voor $dateStr', style: Theme.of(context).textTheme.titleLarge),
IconButton(
onPressed: () async {
// refresh handled by parent via reselecting date; show a simple feedback
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Ververs de datum in Dagschema-tab om andere datum te laden')));
},
icon: const Icon(Icons.info_outline),
),
],
),
),
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<sched.ServiceSchedule>>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
if (snapshot.hasError) return Center(child: Text('Fout bij laden: ${snapshot.error}'));
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: Text('Geen diensten op $dateStr'));
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,
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: schedules.length,
itemBuilder: (context, index) {
final svc = schedules[index];
return Card(
child: ListTile(
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
subtitle: Text(_formatTime(svc.startTime)),
trailing: TextButton(
onPressed: () async {
try {
final progress = await scheduleService.getScheduleProgress(svc.id, selectedDate);
if (!context.mounted) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final stops = (progress['stops'] as List<dynamic>?) ?? [];
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Voortgang — Lijn ${svc.lineNumber}', style: Theme.of(ctx).textTheme.headlineSmall),
const SizedBox(height: 8),
Text('Status: ${progress['schedule']?['status'] ?? 'onbekend'}'),
Text('Driver state: ${progress['driverState'] ?? 'onbekend'}'),
Text('Passed: ${progress['passedStopCount'] ?? 0} / ${progress['totalStopCount'] ?? 0}'),
const SizedBox(height: 12),
const Text('Haltes:'),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
shrinkWrap: true,
itemCount: stops.length,
itemBuilder: (c, i) {
final s = stops[i] as Map<String, dynamic>;
return ListTile(
dense: true,
title: Text('${s['stopOrder']}. ${s['stopName']}'),
subtitle: Text('Scheduled: ${s['scheduledPassedAt'] ?? '-'}\nActual: ${s['actualPassedAt'] ?? '-'}'),
);
},
),
),
],
),
textAlign: TextAlign.center,
),
],
),
);
},
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e')));
}
},
child: const Text('Bekijk voortgang'),
),
);
}
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 _DailyScheduleTab extends StatelessWidget {
final ScheduleService scheduleService;
final DateTime selectedDate;
final VoidCallback onSelectDate;
final VoidCallback onRefresh;
final Function(sched.ServiceSchedule) onShowDetails;
final Future<List<sched.ServiceSchedule>> scheduleFuture;
const _DailyScheduleTab({
required this.scheduleService,
required this.selectedDate,
required this.onSelectDate,
required this.onRefresh,
required this.onShowDetails,
required this.scheduleFuture,
});
@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 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: onSelectDate,
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<sched.ServiceSchedule>>(
future: scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: onRefresh,
child: const Text('Opnieuw proberen'),
),
],
),
);
}
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
const SizedBox(height: 16),
Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
],
),
);
}
return RefreshIndicator(
onRefresh: () async => onRefresh(),
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
itemCount: schedules.length,
itemBuilder: (context, index) {
final schedule = schedules[index];
return _ScheduleSummaryCard(
schedule: schedule,
onTap: () => onShowDetails(schedule),
);
},
),
);
},
),
),
],
),
);
}
@@ -446,7 +592,9 @@ class _ScheduleSummaryCard extends StatelessWidget {
),
),
),
_InfoChip(label: schedule.status),
// Only show status for one-time schedules; recurring schedules have no fixed status
if (!schedule.isRecurring)
_InfoChip(label: schedule.status),
],
),
const SizedBox(height: 16),
@@ -523,4 +671,4 @@ String _weekdayLabel(List<int> weekdays) {
return 'Dagelijks';
}
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'auth_service.dart';
/// Global navigator and messenger keys so services can navigate/logout
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<ScaffoldMessengerState> messengerKey = GlobalKey<ScaffoldMessengerState>();
/// Handle expired authentication: clear stored token and navigate to login.
Future<void> handleAuthExpired({String? message}) async {
try {
await AuthService().logout();
} catch (_) {}
// Show a message if possible
try {
final messenger = messengerKey.currentState;
if (messenger != null && message != null) {
messenger.showSnackBar(SnackBar(content: Text(message)));
}
} catch (_) {}
// Navigate to login, removing all previous routes
try {
final nav = navigatorKey.currentState;
if (nav != null) {
nav.pushNamedAndRemoveUntil('/login', (r) => false);
}
} catch (_) {}
}
+12 -1
View File
@@ -2,14 +2,23 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import 'error_log_service.dart';
/// Authentication Service
class AuthService {
static const String _apiBaseUrl = 'http://192.168.2.66:5000';
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs;
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
@@ -42,6 +51,7 @@ class AuthService {
return authResponse;
} catch (e) {
await _reportError('AuthService.login.parse', e);
// Surface the response body to help debug backend field names / formats
throw Exception('Login parse error: $e -- response body: $body');
}
@@ -51,6 +61,7 @@ class AuthService {
throw Exception('Login failed: ${response.statusCode}');
}
} catch (e) {
await _reportError('AuthService.login', e);
throw Exception('Login error: $e');
}
}
@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ErrorLogService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static Future<void> report({
required String service,
required String partOfService,
required String error,
}) async {
try {
await http.post(
Uri.parse('$_apiBaseUrl/error_log'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'service': service,
'partOfService': partOfService,
'error': error,
}),
);
} catch (_) {
// Intentionally ignore logging failures.
}
}
}
+11 -4
View File
@@ -1,9 +1,16 @@
/// Custom exception for authentication errors
// Custom exception for authentication errors
import 'app_navigator.dart';
class UnauthorizedException implements Exception {
final String message;
UnauthorizedException(this.message);
UnauthorizedException(this.message) {
// Trigger centralized logout/navigation asynchronously
try {
handleAuthExpired(message: message);
} catch (_) {}
}
@override
String toString() => message;
}
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'error_log_service.dart';
import '../models/navigation.dart';
/// Navigation Service using OpenStreetMap
@@ -10,6 +11,13 @@ class NavigationService {
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving';
/// Get current device location
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<NavigationPoint?> getCurrentLocation() async {
try {
final permission = await Geolocator.checkPermission();
@@ -27,6 +35,7 @@ class NavigationService {
name: 'Current Location',
);
} catch (e) {
await _reportError('NavigationService.getCurrentLocation', e);
throw Exception('Failed to get location: $e');
}
}
@@ -85,9 +94,10 @@ class NavigationService {
throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}');
}
} else {
throw Exception('Failed to get route: ${response.statusCode}');
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
}
} catch (e) {
await _reportError('NavigationService.getRoute', e);
throw Exception('Navigation error: $e');
}
}
@@ -110,7 +120,25 @@ class NavigationService {
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to get route: ${response.statusCode}');
if (response.statusCode == 400) {
final distanceMeters = _sumDistancesBetween(stops);
final durationSec = (distanceMeters / 11.111111).toInt();
final leg = RouteLeg.fromPoints(
points: stops,
distance: distanceMeters,
duration: durationSec,
summary: 'Direct fallback',
);
return NavigationRoute(
start: stops.first,
end: stops.last,
legs: [leg],
totalDistance: distanceMeters,
totalDuration: durationSec,
);
}
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -134,6 +162,7 @@ class NavigationService {
end: stops.last,
);
} catch (e) {
await _reportError('NavigationService.getRouteThroughStops', e);
throw Exception('Navigation error: $e');
}
}
@@ -165,7 +194,7 @@ class NavigationService {
final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
return OptimizedRouteResult(route: route, orderedStops: stops);
}
throw Exception('Failed to get optimized route: ${response.statusCode}');
throw Exception('Failed to get optimized route: ${response.statusCode} -- response body: ${response.body}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -234,6 +263,7 @@ class NavigationService {
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
);
} catch (e) {
await _reportError('NavigationService.getOptimizedRouteThroughStops', e);
throw Exception('Navigation error: $e');
}
}
@@ -287,9 +317,10 @@ class NavigationService {
final leg = RouteLeg.fromPoints(points: [start, end], distance: distanceMeters, duration: durationSec, summary: 'Direct');
return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)];
} else {
throw Exception('Failed to get routes: ${response.statusCode}');
throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
}
} catch (e) {
await _reportError('NavigationService.getMultipleRoutes', e);
throw Exception('Navigation error: $e');
}
}
@@ -340,6 +371,7 @@ class NavigationService {
return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) {
await _reportError('NavigationService.geocodePlace', e);
throw Exception('Geocode error: $e');
}
}
+130 -41
View File
@@ -3,15 +3,22 @@ import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import '../models/navigation.dart';
import 'auth_service.dart';
import 'error_log_service.dart';
import 'exceptions.dart';
/// Schedule/Route Service
class ScheduleService {
// TODO: Implement API connector - configure base URL from env/config
// Transit API runs on port 5001
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
final _authService = AuthService();
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
/// Get public schedule overview for a specific date.
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
try {
@@ -34,10 +41,37 @@ class ScheduleService {
throw Exception('Failed to load schedules: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getSchedules', e);
throw Exception('Schedule error: $e');
}
}
/// Get all recurring schedule templates, independent of a specific date.
Future<List<ServiceSchedule>> getRecurringSchedules({int? lineId}) async {
try {
final queryParameters = <String, String>{};
if (lineId != null) {
queryParameters['lineId'] = lineId.toString();
}
final uri = Uri.parse('$_apiBaseUrl/schedules/recurring').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 recurring schedules: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getRecurringSchedules', e);
throw Exception('Recurring schedule error: $e');
}
}
/// Get stop list for a specific schedule.
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
try {
@@ -53,32 +87,62 @@ class ScheduleService {
throw Exception('Failed to load schedule stops: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getScheduleStops', e);
throw Exception('Schedule stops error: $e');
}
}
/// Get daily schedule for a specific date
/// Get daily schedule for a specific date (includes recurring schedules)
Future<DailySchedule> getDailySchedule(DateTime date) async {
try {
final headers = await _authService.getAuthHeaders();
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final response = await http.get(
Uri.parse('$_apiBaseUrl/schedule/daily?date=$dateStr'),
headers: headers,
// Call /schedules/overview which includes both one-time and recurring schedules for the date
final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(
queryParameters: {'date': dateStr}
);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return DailySchedule.fromJson(data);
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
final schedules = (data['schedules'] as List? ?? [])
.map((s) => s as Map<String, dynamic>)
.map(ServiceSchedule.fromJson)
.toList();
// Convert ServiceSchedule to Route objects for display
final routes = schedules.map((schedule) {
return Route(
id: schedule.id,
busLine: BusLine(
id: schedule.busLineId,
lineNumber: schedule.lineNumber,
startStop: '',
endStop: '',
estimatedDuration: 60,
description: '',
),
busType: BusType(
id: schedule.busId,
name: schedule.busName,
seatCapacity: 0,
licensePlate: '',
),
startTime: schedule.startTime,
endTime: schedule.endTime,
status: schedule.status,
);
}).toList();
return DailySchedule(
routes: routes,
date: date,
);
} else {
throw Exception('Failed to load schedule: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getDailySchedule', e);
throw Exception('Schedule error: $e');
}
}
@@ -88,12 +152,29 @@ class ScheduleService {
return getDailySchedule(DateTime.now());
}
/// Get per-date progress for a schedule occurrence (public endpoint)
Future<Map<String, dynamic>> getScheduleProgress(int scheduleId, DateTime date) async {
try {
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final uri = Uri.parse('$_apiBaseUrl/public/schedules/$scheduleId/progress').replace(queryParameters: {'date': dateStr});
final response = await http.get(uri);
if (response.statusCode == 200) {
return jsonDecode(response.body) as Map<String, dynamic>;
}
throw Exception('Failed to load progress: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getScheduleProgress', e);
throw Exception('Schedule progress error: $e');
}
}
/// Get route details
Future<Route> getRouteDetails(int routeId) async {
try {
final headers = await _authService.getAuthHeaders();
// Transit API exposes route details at /routes/:routeId
final response = await http.get(
Uri.parse('$_apiBaseUrl/routes/$routeId'),
headers: headers,
@@ -103,13 +184,14 @@ class ScheduleService {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return Route.fromJson(data);
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else {
throw Exception('Failed to load route: ${response.statusCode}');
final body = response.body;
throw Exception('Failed to load route: ${response.statusCode} -- response body: $body');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getRouteDetails', e);
throw Exception('Route error: $e');
}
}
@@ -127,36 +209,42 @@ class ScheduleService {
);
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}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateRouteStatus', e);
throw Exception('Status update error: $e');
}
}
/// Update schedule status for driver-facing schedule workflows.
Future<void> updateScheduleStatus(int scheduleId, String status) async {
Future<void> updateScheduleStatus(int scheduleId, String status, {DateTime? date}) async {
try {
final headers = await _authService.getAuthHeaders();
final body = <String, dynamic>{'status': status};
if (date != null) {
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
body['date'] = dateStr;
}
final response = await http.patch(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'),
headers: headers,
body: jsonEncode({'status': status}),
body: jsonEncode(body),
);
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 schedule status: ${response.statusCode}');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateScheduleStatus', e);
throw Exception('Schedule status update error: $e');
}
}
@@ -173,30 +261,29 @@ class ScheduleService {
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final stops = (data['stops'] as List?)
?.map((s) => s as Map<String, dynamic>)
.map((s) {
final lat = (s['latitude'] ?? s['lat']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
if (lat == null || lon == null) return null;
return NavigationPoint(
latitude: lat.toDouble(),
longitude: lon.toDouble(),
name: name,
);
})
.whereType<NavigationPoint>()
.toList() ?? [];
final raw = data['stops'] as List? ?? [];
final stops = raw.map((s) => s as Map<String, dynamic>).map((s) {
final lat = (s['latitude'] ?? s['lat']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
if (lat == null || lon == null) return null;
return NavigationPoint(
latitude: lat.toDouble(),
longitude: lon.toDouble(),
name: name,
);
}).whereType<NavigationPoint>().toList();
return stops;
} else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again');
} else {
throw Exception('Failed to load stops: ${response.statusCode}');
final body = response.body;
throw Exception('Failed to load stops: ${response.statusCode} -- response body: $body');
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getStops', e);
throw Exception('Get stops error: $e');
}
}
@@ -210,12 +297,13 @@ class ScheduleService {
try {
final headers = await _authService.getAuthHeaders();
final body = <String, dynamic>{};
if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String();
final response = await http.post(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'),
headers: headers,
body: jsonEncode({
if (actualPassedAt != null) 'actualPassedAt': actualPassedAt.toIso8601String(),
}),
body: jsonEncode(body),
);
if (response.statusCode == 401 || response.statusCode == 403) {
@@ -228,6 +316,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.markScheduleStopPassed', e);
throw Exception('Mark stop passed error: $e');
}
}
+12 -5
View File
@@ -67,11 +67,18 @@ class RouteCard extends StatelessWidget {
// Ignore status update errors here; navigation should still open.
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NavigationScreen(route: route),
),
);
try {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => NavigationScreen(route: route),
),
);
} catch (e) {
// Surface navigation errors to the user so they can report/log.
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${e.toString()}')),
);
}
},
borderRadius: BorderRadius.circular(20),
splashFactory: InkSparkle.splashFactory,
+28 -2
View File
@@ -1,6 +1,21 @@
version: '3.9'
services:
error_log_api:
build:
context: ./error_log_api
container_name: error_log_api
ports:
- "5003:5003"
environment:
PORT: 5003
PG_HOST: ${POSTGRES_HOST}
PG_PORT: ${POSTGRES_PORT}
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_LOGGER}
PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
restart: unless-stopped
auth_api:
image: ghcr.io/sven491/xtransit/auth_api:latest
container_name: auth_api
@@ -14,7 +29,11 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_AUTH}
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_BYPASS: ${DEV_BYPASS:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
employee_api:
@@ -29,6 +48,9 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_EMPLOYEE}
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
transit_api:
@@ -44,8 +66,10 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_MOCK: ${DEV_MOCK:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
admin_ui:
@@ -56,7 +80,9 @@ services:
environment:
AUTH_API: ${AUTH_API}
TRANSIT_API: ${TRANSIT_API}
ERROR_LOG_API: ${ERROR_LOG_API}
depends_on:
- auth_api
- transit_api
- error_log_api
restart: unless-stopped
+43 -3
View File
@@ -6,6 +6,44 @@ require('dotenv').config();
const app = express();
app.use(express.json());
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'employee_api',
partOfService,
error,
}),
});
} catch (_) {}
}
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
// Employee onboarding
app.post('/createemployee', async (req, res) => {
try {
@@ -20,8 +58,9 @@ app.post('/createemployee', async (req, res) => {
return res.status(201).json({ employee: firstname, code: newUser.usercoden });
}
catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error' });
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({ error: friendlyErrorMessage(500) });
}
})
@@ -30,8 +69,9 @@ app.get('/health', async (_req, res, _next) => {
return res.status(200).json({endpoint: 'reachable'})
}
catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({endpoint: 'unreachable'})
res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) })
}
})
+4
View File
@@ -0,0 +1,4 @@
.env
node_modules
npm-debug.log
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 5003
CMD ["npm", "start"]
+26
View File
@@ -0,0 +1,26 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
user: process.env.PG_USER,
password: process.env.PG_PASS,
database: process.env.PG_DB,
options: '-c search_path=data,public',
max: 10,
idleTimeoutMillis: 30000,
});
pool.query('SELECT 1')
.then(() => console.log('PostgreSQL connection OK'))
.catch(err => {
console.error('PostgreSQL connection FAILED');
console.error(err);
process.exit(1);
});
module.exports = {
query: (text, params) => pool.query(text, params),
pool,
};
+139
View File
@@ -0,0 +1,139 @@
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const db = require('./db');
const app = express();
app.use(express.json({ limit: '64kb' }));
app.use(cors());
const PORT = Number(process.env.PORT || 5003);
const JWT_SECRET = process.env.JWT_SECRET || '';
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
function toFriendlyError(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
function normalizeErrorText(value) {
if (!value) return 'Unknown error';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (_) {
return String(value);
}
}
async function insertErrorLog({ service, partOfService, error }) {
const serviceName = String(service || 'unknown_service').slice(0, 120);
const part = partOfService ? String(partOfService).slice(0, 240) : null;
const errorText = String(normalizeErrorText(error)).slice(0, 8000);
await db.query(
'INSERT INTO data.errors ("service", "partOfService", "error") VALUES ($1, $2, $3)',
[serviceName, part, errorText]
);
}
function verifyAdmin(req, res, next) {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return res.status(401).json({ error: toFriendlyError(401) });
}
try {
const payload = jwt.verify(auth.slice(7), JWT_SECRET);
if (!payload?.isAdmin) {
return res.status(403).json({ error: toFriendlyError(403) });
}
req.user = payload;
return next();
} catch (err) {
return res.status(401).json({ error: toFriendlyError(401) });
}
}
app.get('/health', async (_req, res) => {
res.json({ status: 'ok', service: 'error_log_api' });
});
app.post('/error_log', async (req, res) => {
try {
const service = req.body?.service;
const partOfService = req.body?.partOfService || null;
const error = req.body?.error;
if (!service || !error) {
return res.status(400).json({ error: toFriendlyError(400) });
}
await insertErrorLog({ service, partOfService, error });
return res.status(201).json({ ok: true });
} catch (err) {
console.error('POST /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.get('/error_log', verifyAdmin, async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit || 100), 500);
const service = req.query.service ? String(req.query.service) : null;
const partOfService = req.query.partOfService ? String(req.query.partOfService) : null;
const where = [];
const params = [];
if (service) {
params.push(service);
where.push(`"service" = $${params.length}`);
}
if (partOfService) {
params.push(partOfService);
where.push(`"partOfService" ILIKE $${params.length}`);
params[params.length - 1] = `%${partOfService}%`;
}
params.push(limit);
const rows = await db.query(
`SELECT id, "date", "service", "partOfService", "error"
FROM data.errors
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY "date" DESC, id DESC
LIMIT $${params.length}`,
params
);
return res.json({ errors: rows.rows });
} catch (err) {
console.error('GET /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.use(async (err, req, res, _next) => {
try {
await insertErrorLog({
service: 'error_log_api',
partOfService: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
} catch (logErr) {
console.error('Failed to log internal error:', logErr);
}
if (res.headersSent) return;
return res.status(500).json({ error: toFriendlyError(500) });
});
app.listen(PORT, () => console.log(`Error Log API listening on ${PORT}`));
module.exports = app;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "error_log_api",
"version": "1.0.0",
"description": "Central error logging API",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.0"
}
}
+24 -14
View File
@@ -1,30 +1,40 @@
# Session Handoff
**Goal:** [What I'm trying to achieve]
**Goal:** Make schedules work correctly across recurring and one-time events, with accurate per-day status/progress in the API and calendar views in the admin UI.
**Current Status:**
- [What is already done]
- [What is in progress]
- [What still needs to happen]
- `transit_api/index.js` now materializes recurring schedules into concrete occurrences for `/schedules/overview`.
- `transit.schedule_stop_progress` is now occurrence-aware via `scheduled_passed_date`; the API backfills old rows and writes/reads stop progress per date.
- The API check passed with `node --check transit_api/index.js`.
- The admin UI still needs to be wired to use the range-aware overview endpoint in its calendar data source.
**Important Context:**
- [Audience, tone, constraints, preferences]
- [Key files, links, notes, or source material]
- [Deadline or success criteria]
- The schedule template lives in the database as a row with `weekdays`; recurring events are not stored as separate rows.
- The backend should be the source of truth for date-specific materialization and live progress/status.
- Key files: `transit_api/index.js`, `admin_ui/src/components/ScheduleMaker.jsx`, `transit_api/README.md`.
- The admin UI build already succeeded earlier with `npm --prefix admin_ui run build`.
**Decisions Already Made:**
- [Decision + short reason]
- Recurring schedules are materialized on read instead of creating one DB row per occurrence. This keeps the DB simple and makes the calendar/date-range API deterministic.
- Progress must be keyed by occurrence date, not only by schedule id and stop order, otherwise a recurring schedule reuses yesterday’s stop state.
- `/schedules/overview` should support both single-date and date-range requests so month/week views can render all relevant occurrences with one call.
**What to Avoid:**
- [Failed attempts, wrong direction, out-of-scope items]
- Do not treat recurring schedules as a single mutable live schedule row for progress/status.
- Do not reuse the old `schedule_id + stop_order` uniqueness for stop progress.
- Avoid reworking unrelated API routes or admin screens unless they block the calendar flow.
**Open Questions or Blockers:**
- [What is unresolved]
- The admin UI calendar source still needs to be confirmed and wired to call `/schedules/overview?fromDate=...&toDate=...`.
- If the admin calendar view expects a different event shape, map the overview response there rather than changing the backend again.
**Next Best Step:**
- [What you should do first]
1. Inspect the calendar/timetable code path in `admin_ui/src/components/ScheduleMaker.jsx` and any adjacent component that renders the calendar range.
2. Wire that view to request `/schedules/overview` with `fromDate` and `toDate`.
3. Map returned schedules into calendar events and surface the current status/progress fields.
4. Run a focused admin UI build to confirm the change compiles.
**How to Respond:**
- Start with a short summary of your understanding.
- Continue from here instead of restarting from scratch.
- If anything critical is missing, ask only the minimum questions needed.
- Start by summarizing the current state in one short paragraph.
- Continue from the existing implementation instead of re-discovering the schedule model.
- If anything critical is missing, ask only the minimum question needed to proceed.
+8 -1
View File
@@ -42,10 +42,11 @@ PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
JWT_SECRET=your_jwt_secret
ADMIN_USER_CODES=12345,67890
PORT=5001
```
Note: `ADMIN_USER_CODES` has been moved to the `auth_api` service. Configure admin user codes in `auth_api/.env` as a comma-separated list.
### 4. API starten
```bash
@@ -96,10 +97,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
- Haalt een datum-gebonden overzicht op van geplande diensten
- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays`
- Incidentele diensten worden alleen meegenomen op hun exacte datum
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients.
Voorbeeld:
```bash
curl "http://localhost:5001/schedules/overview?date=2026-05-06&chauffeurId=12&busStopId=3"
```
### Navigation
**POST** `/navigation/route`
+919 -85
View File
File diff suppressed because it is too large Load Diff