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/**' - 'auth_api/**'
- 'employee_api/**' - 'employee_api/**'
- 'transit_api/**' - 'transit_api/**'
- 'error_log_api/**'
- 'admin_ui/**' - 'admin_ui/**'
- '.github/workflows/docker-build.yml' - '.github/workflows/docker-build.yml'
pull_request: pull_request:
@@ -153,6 +154,48 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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: build-admin-ui:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
+2
View File
@@ -1,6 +1,7 @@
.env .env
/*.env /*.env
transit_api/.env transit_api/.env
error_log_api/.env
# Local build / node_modules # Local build / node_modules
admin_ui/node_modules/ admin_ui/node_modules/
@@ -11,6 +12,7 @@ ADMIN_DEPLOYMENT_SUMMARY.md
CI_CD_PORTAINER_GUIDE.md CI_CD_PORTAINER_GUIDE.md
DEPLOYMENT.md DEPLOYMENT.md
GETTING_STARTED.md GETTING_STARTED.md
handoff.md
INTERNAL_*.md INTERNAL_*.md
SETUP_*.md SETUP_*.md
DEPLOY_*.md DEPLOY_*.md
+1 -1
View File
@@ -24,7 +24,7 @@ POSTGRES_PASSWORD_AUTH=your_password
POSTGRES_USER_TRANSIT=transitAPI POSTGRES_USER_TRANSIT=transitAPI
POSTGRES_PASSWORD_TRANSIT=your_password POSTGRES_PASSWORD_TRANSIT=your_password
JWT_SECRET=your-secure-secret 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 ### 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) - `auth_api` — Authenticatie en token-uitgifte (standaard port 5000)
- `employee_api` — Employee onboarding / management (standaard port 4000) - `employee_api` — Employee onboarding / management (standaard port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001) - `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). 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_PASSWORD_EMPLOYEE=strong_password_employee
POSTGRES_USER_TRANSIT=transit_user POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password_transit 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 voor services die JWT gebruiken
JWT_SECRET=super_secret_jwt_key_here 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. 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 - de juiste schema's (`workers`, `routes`, etc.) aanmaakt
- gebruikers en rechten configureert voor de verschillende services - 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): Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development):
```bash ```bash
@@ -340,10 +370,11 @@ Admin dashboard uses simple JWT-based authentication:
2. Receive JWT token from `auth_api` 2. Receive JWT token from `auth_api`
3. Use token for admin operations (`/admin/*` endpoints) 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 ```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): Use `DEV_MOCK=1` to work without a database (in-memory mock storage):
```bash ```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 ### 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 ✅ **Stop Linking** - Assign stops to bus lines with order and ETA
✅ **JWT Authentication** - Secure authentication with role-based access ✅ **JWT Authentication** - Secure authentication with role-based access
✅ **Production Ready** - Docker containerized with runtime env configuration ✅ **Production Ready** - Docker containerized with runtime env configuration
✅ **Error Log Viewer** - Inspect centralized error logs from all services
## Local Development ## Local Development
@@ -31,6 +32,7 @@ Create a `.env` file in the `admin_ui` directory:
```env ```env
VITE_AUTH_API=http://localhost:5000 VITE_AUTH_API=http://localhost:5000
VITE_TRANSIT_API=http://localhost:5001 VITE_TRANSIT_API=http://localhost:5001
VITE_ERROR_LOG_API=http://localhost:5003
``` ```
### Run Development Server ### 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 - `BusLinesManager.jsx` - Create/list bus routes
- `FleetManager.jsx` - View fleet vehicles - `FleetManager.jsx` - View fleet vehicles
- `LinkStop.jsx` - Link stops to bus lines - `LinkStop.jsx` - Link stops to bus lines
- `ErrorLogViewer.jsx` - Central error log viewer
### API Integration ### API Integration
The app communicates with: The app communicates with:
- **Auth API** (/login, /me) - **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses) - **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log)
All requests include JWT Bearer token for authentication. All requests include JWT Bearer token for authentication.
@@ -130,7 +134,7 @@ All requests include JWT Bearer token for authentication.
### Admin Authorization ### 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 ### 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 3. Configure proper JWT generation in auth_api
4. Set strong `JWT_SECRET` environment variable 4. Set strong `JWT_SECRET` environment variable
5. Configure `ADMIN_USER_CODES` with actual admin user IDs 5. Configure `ADMIN_USER_CODES` with actual admin user IDs
6. Configure `VITE_ERROR_LOG_API` if you use a custom logging endpoint
## Troubleshooting ## Troubleshooting
+2 -1
View File
@@ -4,7 +4,8 @@ set -e
cat > /usr/share/nginx/html/env-config.js <<EOF cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = { window.__ENV__ = {
AUTH_API: "${AUTH_API:-http://localhost:5000}", 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 EOF
+2
View File
@@ -5,6 +5,7 @@ import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager' import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager' import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker' import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
export default function App() { export default function App() {
const [token, setToken] = useState(localStorage.getItem('token')) const [token, setToken] = useState(localStorage.getItem('token'))
@@ -46,6 +47,7 @@ export default function App() {
<FleetManager token={token} user={user} /> <FleetManager token={token} user={user} />
<LinkStop token={token} user={user} /> <LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} /> <ScheduleMaker token={token} user={user} />
<ErrorLogViewer token={token} />
</main> </main>
</div> </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 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 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 }) 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) => { export const transitClient = (token) => {
const client = axios.create({ const client = axios.create({
baseURL: TRANSIT_API, baseURL: TRANSIT_API,
@@ -35,6 +64,11 @@ export const transitClient = (token) => {
// reload to let App show the Login screen // reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload() 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) { } catch (e) {
// ignore // ignore
} }
@@ -44,3 +78,10 @@ export const transitClient = (token) => {
return client 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 [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = 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 [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true) const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null) const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = 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 [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode) const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops]) const nextOrder = useMemo(() => stops.length + 1, [stops])
+32 -545
View File
@@ -1,52 +1,25 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { transitClient } from '../api' import { transitClient } from '../api'
import TimetableDisplay from './TimetableDisplay'
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] import FixedScheduleEditor from './FixedScheduleEditor'
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon...
export default function ScheduleMaker({ token, user }) { export default function ScheduleMaker({ token, user }) {
const client = transitClient(token) const client = transitClient(token)
const [schedules, setSchedules] = useState([]) const [scheduleTemplates, setScheduleTemplates] = useState([])
const [overviewSchedules, setOverviewSchedules] = useState([])
const [busLines, setBusLines] = useState([]) const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([]) const [buses, setBuses] = useState([])
const [drivers, setDrivers] = useState([]) const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('') const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
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 [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0]) const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const [startTime, setStartTime] = useState('08:00')
const [driverId, setDriverId] = useState('')
const [selectedWeekdays, setSelectedWeekdays] = useState([])
const loadSchedules = async () => { const loadScheduleTemplates = async () => {
try { try {
const res = await client.get('/admin/schedules') const res = await client.get('/admin/schedules?templates=1')
setSchedules(res.data.schedules || []) setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0))
} catch (err) { } catch (err) {
setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message)) setMessage('Error loading fixed 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))
} }
} }
@@ -78,529 +51,43 @@ export default function ScheduleMaker({ token, user }) {
} }
useEffect(() => { useEffect(() => {
loadSchedules() loadScheduleTemplates()
loadBusLines() loadBusLines()
loadBuses() loadBuses()
loadDrivers() loadDrivers()
}, []) }, [])
useEffect(() => { const handleScheduleChange = async () => {
loadOverviewSchedules() await loadScheduleTemplates()
}, [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 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 ( return (
<section className="card">
<div className="section-header">
<div> <div>
<h2>Diensten Inplannen</h2> {/* Timetable widget - compact daily view */}
<p>Maak diensten aan met automatische eindtijd, vertrektijden per halte en herhaald rooster.</p> <TimetableDisplay
</div> client={client}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}> overviewDate={overviewDate}
<button className="secondary" onClick={loadSchedules}>Refresh</button> setOverviewDate={setOverviewDate}
<select selectedDriverFilter={selectedDriverFilter}
value={selectedDriverFilter} setSelectedDriverFilter={setSelectedDriverFilter}
onChange={e => setSelectedDriverFilter(e.target.value)} drivers={drivers}
style={{ padding: '6px', borderRadius: '6px', border: '1px solid #ccc' }} />
>
<option value="">Alle chauffeurs</option>
{drivers.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
</div>
{/* Fixed service editor - separate widget */}
{isAdmin && ( {isAdmin && (
<form onSubmit={createSchedule} className="stack-form"> <FixedScheduleEditor
<div className="form-row"> client={client}
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}> token={token}
<option value="">Kies lijn</option> user={user}
{busLines.map(line => ( scheduleTemplates={scheduleTemplates}
<option key={line.id} value={line.id}> busLines={busLines}
Lijn {line.lineNumber} ({line.startStop} → {line.endStop}) buses={buses}
</option> drivers={drivers}
))} message={message}
</select> setMessage={setMessage}
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}> onScheduleChange={handleScheduleChange}
<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)}
/>
<button type="button" onClick={() => setOverviewDate(new Date().toISOString().split('T')[0])}>
Vandaag
</button>
</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>
)}
{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>
) )
} }
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 [lat, setLat] = useState('')
const [lon, setLon] = useState('') const [lon, setLon] = useState('')
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = 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' }) { export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
const [schedules, setSchedules] = useState([]) const [schedules, setSchedules] = useState([])
const [summary, setSummary] = useState(null)
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
const [selectedLineId, setSelectedLineId] = useState('') const [selectedLineId, setSelectedLineId] = useState('')
const [lines, setLines] = useState([]) const [lines, setLines] = useState([])
@@ -28,13 +29,14 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
let url = `${apiBaseUrl}/schedules?date=${selectedDate}` let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}`
if (selectedLineId) { if (selectedLineId) {
url += `&lineId=${selectedLineId}` url += `&lineId=${selectedLineId}`
} }
const res = await fetch(url) const res = await fetch(url)
const data = await res.json() const data = await res.json()
setSchedules(data.schedules || []) setSchedules(data.schedules || [])
setSummary(data.summary || null)
} catch (err) { } catch (err) {
setError('Failed to load schedules: ' + err.message) setError('Failed to load schedules: ' + err.message)
} finally { } finally {
@@ -72,6 +74,20 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
</select> </select>
</div> </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>} {loading && <p>Loading schedules...</p>}
{error && <p style={{ color: 'red' }}>{error}</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' }}>Time</th>
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</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' }}>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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -104,14 +121,19 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }
style={{ style={{
padding: '4px 8px', padding: '4px 8px',
borderRadius: '4px', borderRadius: '4px',
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6', backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6',
color: schedule.status === 'active' ? '#166534' : '#374151', color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151',
fontSize: '0.9rem', fontSize: '0.9rem',
}} }}
> >
{schedule.status} {schedule.status}
</span> </span>
</td> </td>
<td style={{ padding: '12px' }}>
{typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null
? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min`
: '—'}
</td>
</tr> </tr>
)) ))
})} })}
+51 -4
View File
@@ -18,7 +18,51 @@ app.use((req, res, next) => {
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 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) => { app.post('/login', async (req, res) => {
@@ -33,13 +77,15 @@ app.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.password); const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: 'invalid_credentials' }); 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' }); 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) { } catch (err) {
await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(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); const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload }); res.json({ user: payload });
} catch (err) { } 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) });
} }
}); });
+39
View File
@@ -1,11 +1,48 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart'; import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/app_navigator.dart';
import 'services/error_log_service.dart';
void main() { void main() {
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()); runApp(const MainApp());
}, (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'runZonedGuarded',
error: '$error\n$stack',
);
});
} }
class MainApp extends StatelessWidget { class MainApp extends StatelessWidget {
@@ -15,6 +52,8 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Bus Terminal', title: 'Bus Terminal',
navigatorKey: navigatorKey,
scaffoldMessengerKey: messengerKey,
theme: AppTheme.lightTheme(), theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(), darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
+2 -1
View File
@@ -19,7 +19,8 @@ class User {
} else if (rawUserCode is String) { } else if (rawUserCode is String) {
parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode')); parsedUserCode = int.tryParse(rawUserCode) ?? (throw FormatException('Invalid userCode'));
} else { } else {
throw FormatException('Missing userCode'); // If the backend doesn't return a userCode, treat as unknown (-1)
parsedUserCode = -1;
} }
return User( return User(
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/schedule.dart'; import '../models/schedule.dart' as schedule;
import '../services/schedule_service.dart'; import '../services/schedule_service.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/exceptions.dart'; import '../services/exceptions.dart';
@@ -15,7 +15,7 @@ class DailyScheduleScreen extends StatefulWidget {
class _DailyScheduleScreenState extends State<DailyScheduleScreen> { class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
final _scheduleService = ScheduleService(); final _scheduleService = ScheduleService();
final _authService = AuthService(); final _authService = AuthService();
late Future<DailySchedule> _scheduleFuture; late Future<schedule.DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
@override @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) { void _moveDay(int deltaDays) {
setState(() { setState(() {
_selectedDate = _selectedDate.add(Duration(days: deltaDays)); _selectedDate = _selectedDate.add(Duration(days: deltaDays));
@@ -198,7 +208,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
), ),
), ),
Expanded( Expanded(
child: FutureBuilder<DailySchedule>( child: FutureBuilder<schedule.DailySchedule>(
future: _scheduleFuture, future: _scheduleFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
@@ -267,12 +277,12 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No routes scheduled', 'Geen actieve ritten',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Check back later for your schedule', 'Je hebt geen geplande ritten voor deze dag',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -288,25 +298,64 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
..sort((a, b) => a.startTime.compareTo(b.startTime)); ..sort((a, b) => a.startTime.compareTo(b.startTime));
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList(); final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').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( return RefreshIndicator(
onRefresh: _refreshSchedule, onRefresh: _refreshSchedule,
child: ListView( child: ListView(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
children: [ children: [
if (inProgressRoutes.isNotEmpty) ...[ if (allActiveRoutes.isEmpty)
_sectionHeader('In Progress', colorScheme.tertiary), Center(
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})), 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,
),
),
], ],
if (scheduledRoutes.isNotEmpty) ...[ ),
_sectionHeader('Scheduled', colorScheme.primary), ),
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})), ...routesForHour.map((route) => Padding(
], padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
if (completedRoutes.isNotEmpty) ...[ child: RouteCard(route: route, onTap: () {}),
_sectionHeader('Completed', colorScheme.secondary), )),
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})), const SizedBox(height: 4),
], ],
);
}),
const SizedBox(height: 8), 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 'package:flutter/material.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart'; import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -37,7 +38,17 @@ class _LoginScreenState extends State<LoginScreen> {
final input = _userCodeController.text.trim(); final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input); final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) { 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( await _authService.login(
@@ -50,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
Navigator.of(context).pushReplacementNamed('/schedule'); Navigator.of(context).pushReplacementNamed('/schedule');
} }
} catch (e) { } catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', ''); _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/auth_service.dart';
import '../services/schedule_service.dart'; import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart'; import '../screens/navigation_screen.dart';
import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget { class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key}); const ScheduleOverviewScreen({super.key});
@@ -14,17 +15,25 @@ class ScheduleOverviewScreen extends StatefulWidget {
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState(); State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
} }
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> { class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
final _scheduleService = ScheduleService(); final _scheduleService = ScheduleService();
final _authService = AuthService(); final _authService = AuthService();
late Future<List<sched.ServiceSchedule>> _scheduleFuture; late Future<List<sched.ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate); _scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
} }
Future<void> _handleLogout() async { Future<void> _handleLogout() async {
@@ -116,11 +125,12 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () async { onPressed: () async {
try { try {
await _scheduleService.updateScheduleStatus(svc.id, 'in_progress'); await _scheduleService.updateScheduleStatus(
svc.id,
'in_progress',
date: svc.startTime,
);
if (!mounted) return; if (!mounted) return;
// Close the sheet and start navigation with provided stops
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
final routeObj = sched.Route( final routeObj = sched.Route(
@@ -151,11 +161,18 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
)) ))
.toList(); .toList();
Navigator.of(context).push( try {
await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops), builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
), ),
); );
} catch (navErr) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Navigatiefout: ${navErr.toString()}')),
);
}
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -209,14 +226,19 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
@override @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Today on the Road'), title: const Text('Diensten'),
centerTitle: true,
elevation: 0, 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: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
@@ -225,7 +247,167 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
), ),
], ],
), ),
body: Container( 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),
),
],
),
),
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}'));
final schedules = snapshot.data ?? [];
if (schedules.isEmpty) return Center(child: Text('Geen diensten op $dateStr'));
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'] ?? '-'}'),
);
},
),
),
],
),
);
},
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Kon voortgang niet laden: $e')));
}
},
child: const Text('Bekijk voortgang'),
),
),
);
},
);
},
),
),
],
);
}
}
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( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark colors: Theme.of(context).brightness == Brightness.dark
@@ -285,7 +467,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
child: SizedBox( child: SizedBox(
height: 48, height: 48,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: _selectDate, onPressed: onSelectDate,
icon: const Icon(Icons.calendar_today, size: 18), icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Wijzig datum'), label: const Text('Wijzig datum'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@@ -301,7 +483,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
), ),
Expanded( Expanded(
child: FutureBuilder<List<sched.ServiceSchedule>>( child: FutureBuilder<List<sched.ServiceSchedule>>(
future: _scheduleFuture, future: scheduleFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -309,81 +491,46 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.error_outline, size: 48, color: colorScheme.error),
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
'Failed to load schedule', const SizedBox(height: 16),
style: Theme.of(context).textTheme.titleMedium, FilledButton(
), onPressed: onRefresh,
const SizedBox(height: 8), child: const Text('Opnieuw proberen'),
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 ?? []; final schedules = snapshot.data ?? [];
if (schedules.isEmpty) { if (schedules.isEmpty) {
return Center( return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
Icons.calendar_today,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
'No routes scheduled',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Check back later for your schedule',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
], ],
), ),
),
); );
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: _refreshSchedules, onRefresh: () async => onRefresh(),
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
itemCount: schedules.length, itemCount: schedules.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final schedule = schedules[index]; final schedule = schedules[index];
return _ScheduleSummaryCard( return _ScheduleSummaryCard(
schedule: schedule, schedule: schedule,
onTap: () => _showScheduleDetails(schedule), onTap: () => onShowDetails(schedule),
); );
}, },
), ),
@@ -393,7 +540,6 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
), ),
], ],
), ),
),
); );
} }
} }
@@ -446,6 +592,8 @@ class _ScheduleSummaryCard extends StatelessWidget {
), ),
), ),
), ),
// Only show status for one-time schedules; recurring schedules have no fixed status
if (!schedule.isRecurring)
_InfoChip(label: schedule.status), _InfoChip(label: schedule.status),
], ],
), ),
@@ -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:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart'; import '../models/user.dart';
import 'error_log_service.dart';
/// Authentication Service /// Authentication Service
class AuthService { 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'; static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs; 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 { Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
} }
@@ -42,6 +51,7 @@ class AuthService {
return authResponse; return authResponse;
} catch (e) { } catch (e) {
await _reportError('AuthService.login.parse', e);
// Surface the response body to help debug backend field names / formats // Surface the response body to help debug backend field names / formats
throw Exception('Login parse error: $e -- response body: $body'); throw Exception('Login parse error: $e -- response body: $body');
} }
@@ -51,6 +61,7 @@ class AuthService {
throw Exception('Login failed: ${response.statusCode}'); throw Exception('Login failed: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
await _reportError('AuthService.login', e);
throw Exception('Login error: $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.
}
}
}
+9 -2
View File
@@ -1,8 +1,15 @@
/// Custom exception for authentication errors // Custom exception for authentication errors
import 'app_navigator.dart';
class UnauthorizedException implements Exception { class UnauthorizedException implements Exception {
final String message; final String message;
UnauthorizedException(this.message); UnauthorizedException(this.message) {
// Trigger centralized logout/navigation asynchronously
try {
handleAuthExpired(message: message);
} catch (_) {}
}
@override @override
String toString() => message; String toString() => message;
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'error_log_service.dart';
import '../models/navigation.dart'; import '../models/navigation.dart';
/// Navigation Service using OpenStreetMap /// Navigation Service using OpenStreetMap
@@ -10,6 +11,13 @@ class NavigationService {
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving'; static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving';
/// Get current device location /// 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 { Future<NavigationPoint?> getCurrentLocation() async {
try { try {
final permission = await Geolocator.checkPermission(); final permission = await Geolocator.checkPermission();
@@ -27,6 +35,7 @@ class NavigationService {
name: 'Current Location', name: 'Current Location',
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getCurrentLocation', e);
throw Exception('Failed to get location: $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()}'); throw Exception('Failed to get route (400) and fallback failed: ${e.toString()}');
} }
} else { } else {
throw Exception('Failed to get route: ${response.statusCode}'); throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
await _reportError('NavigationService.getRoute', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -110,7 +120,25 @@ class NavigationService {
final response = await http.get(Uri.parse(url)); final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) { 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>?; final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -134,6 +162,7 @@ class NavigationService {
end: stops.last, end: stops.last,
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getRouteThroughStops', e);
throw Exception('Navigation error: $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); final route = NavigationRoute(start: stops.first, end: stops.last, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec);
return OptimizedRouteResult(route: route, orderedStops: stops); 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>?; final data = jsonDecode(response.body) as Map<String, dynamic>?;
@@ -234,6 +263,7 @@ class NavigationService {
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops, orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
); );
} catch (e) { } catch (e) {
await _reportError('NavigationService.getOptimizedRouteThroughStops', e);
throw Exception('Navigation error: $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'); 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)]; return [NavigationRoute(start: start, end: end, legs: [leg], totalDistance: distanceMeters, totalDuration: durationSec)];
} else { } else {
throw Exception('Failed to get routes: ${response.statusCode}'); throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
} }
} catch (e) { } catch (e) {
await _reportError('NavigationService.getMultipleRoutes', e);
throw Exception('Navigation error: $e'); throw Exception('Navigation error: $e');
} }
} }
@@ -340,6 +371,7 @@ class NavigationService {
return NavigationPoint(latitude: lat, longitude: lon, name: display); return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) { } catch (e) {
await _reportError('NavigationService.geocodePlace', e);
throw Exception('Geocode error: $e'); throw Exception('Geocode error: $e');
} }
} }
+120 -31
View File
@@ -3,15 +3,22 @@ import 'package:http/http.dart' as http;
import '../models/schedule.dart'; import '../models/schedule.dart';
import '../models/navigation.dart'; import '../models/navigation.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'error_log_service.dart';
import 'exceptions.dart'; import 'exceptions.dart';
/// Schedule/Route Service /// Schedule/Route Service
class ScheduleService { class ScheduleService {
// TODO: Implement API connector - configure base URL from env/config static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
// Transit API runs on port 5001
static const String _apiBaseUrl = 'http://192.168.2.66:5001';
final _authService = AuthService(); 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. /// Get public schedule overview for a specific date.
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async { Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
try { try {
@@ -34,10 +41,37 @@ class ScheduleService {
throw Exception('Failed to load schedules: ${response.statusCode}'); throw Exception('Failed to load schedules: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getSchedules', e);
throw Exception('Schedule error: $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. /// Get stop list for a specific schedule.
Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async { Future<List<ScheduleStop>> getScheduleStops(int scheduleId) async {
try { try {
@@ -53,32 +87,62 @@ class ScheduleService {
throw Exception('Failed to load schedule stops: ${response.statusCode}'); throw Exception('Failed to load schedule stops: ${response.statusCode}');
} catch (e) { } catch (e) {
await _reportError('ScheduleService.getScheduleStops', e);
throw Exception('Schedule stops error: $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 { Future<DailySchedule> getDailySchedule(DateTime date) async {
try { try {
final headers = await _authService.getAuthHeaders();
final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; final dateStr = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final response = await http.get( // Call /schedules/overview which includes both one-time and recurring schedules for the date
Uri.parse('$_apiBaseUrl/schedule/daily?date=$dateStr'), final uri = Uri.parse('$_apiBaseUrl/schedules/overview').replace(
headers: headers, queryParameters: {'date': dateStr}
); );
final response = await http.get(uri);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
return DailySchedule.fromJson(data); final schedules = (data['schedules'] as List? ?? [])
} else if (response.statusCode == 401 || response.statusCode == 403) { .map((s) => s as Map<String, dynamic>)
await _authService.logout(); .map(ServiceSchedule.fromJson)
throw UnauthorizedException('Session expired - Please login again'); .toList();
// Convert ServiceSchedule to Route objects for display
final routes = schedules.map((schedule) {
return Route(
id: schedule.id,
busLine: BusLine(
id: schedule.busLineId,
lineNumber: schedule.lineNumber,
startStop: '',
endStop: '',
estimatedDuration: 60,
description: '',
),
busType: BusType(
id: schedule.busId,
name: schedule.busName,
seatCapacity: 0,
licensePlate: '',
),
startTime: schedule.startTime,
endTime: schedule.endTime,
status: schedule.status,
);
}).toList();
return DailySchedule(
routes: routes,
date: date,
);
} else { } else {
throw Exception('Failed to load schedule: ${response.statusCode}'); throw Exception('Failed to load schedule: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; await _reportError('ScheduleService.getDailySchedule', e);
throw Exception('Schedule error: $e'); throw Exception('Schedule error: $e');
} }
} }
@@ -88,12 +152,29 @@ class ScheduleService {
return getDailySchedule(DateTime.now()); 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 /// Get route details
Future<Route> getRouteDetails(int routeId) async { Future<Route> getRouteDetails(int routeId) async {
try { try {
final headers = await _authService.getAuthHeaders(); final headers = await _authService.getAuthHeaders();
// Transit API exposes route details at /routes/:routeId
final response = await http.get( final response = await http.get(
Uri.parse('$_apiBaseUrl/routes/$routeId'), Uri.parse('$_apiBaseUrl/routes/$routeId'),
headers: headers, headers: headers,
@@ -103,13 +184,14 @@ class ScheduleService {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
return Route.fromJson(data); return Route.fromJson(data);
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else { } 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) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getRouteDetails', e);
throw Exception('Route error: $e'); throw Exception('Route error: $e');
} }
} }
@@ -127,36 +209,42 @@ class ScheduleService {
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else if (response.statusCode != 200) { } else if (response.statusCode != 200) {
throw Exception('Failed to update status: ${response.statusCode}'); throw Exception('Failed to update status: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateRouteStatus', e);
throw Exception('Status update error: $e'); throw Exception('Status update error: $e');
} }
} }
/// Update schedule status for driver-facing schedule workflows. /// 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 { try {
final headers = await _authService.getAuthHeaders(); 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( final response = await http.patch(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'), Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/status'),
headers: headers, headers: headers,
body: jsonEncode({'status': status}), body: jsonEncode(body),
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else if (response.statusCode != 200) { } else if (response.statusCode != 200) {
throw Exception('Failed to update schedule status: ${response.statusCode}'); throw Exception('Failed to update schedule status: ${response.statusCode}');
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateScheduleStatus', e);
throw Exception('Schedule status update error: $e'); throw Exception('Schedule status update error: $e');
} }
} }
@@ -173,9 +261,8 @@ class ScheduleService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>; final data = jsonDecode(response.body) as Map<String, dynamic>;
final stops = (data['stops'] as List?) final raw = data['stops'] as List? ?? [];
?.map((s) => s as Map<String, dynamic>) final stops = raw.map((s) => s as Map<String, dynamic>).map((s) {
.map((s) {
final lat = (s['latitude'] ?? s['lat']) as num?; final lat = (s['latitude'] ?? s['lat']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?; final lon = (s['longitude'] ?? s['lon']) as num?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?; final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
@@ -185,18 +272,18 @@ class ScheduleService {
longitude: lon.toDouble(), longitude: lon.toDouble(),
name: name, name: name,
); );
}) }).whereType<NavigationPoint>().toList();
.whereType<NavigationPoint>()
.toList() ?? [];
return stops; return stops;
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await _authService.logout();
throw UnauthorizedException('Session expired - Please login again'); throw UnauthorizedException('Session expired - Please login again');
} else { } 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) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getStops', e);
throw Exception('Get stops error: $e'); throw Exception('Get stops error: $e');
} }
} }
@@ -210,12 +297,13 @@ class ScheduleService {
try { try {
final headers = await _authService.getAuthHeaders(); final headers = await _authService.getAuthHeaders();
final body = <String, dynamic>{};
if (actualPassedAt != null) body['actualPassedAt'] = actualPassedAt.toIso8601String();
final response = await http.post( final response = await http.post(
Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'), Uri.parse('$_apiBaseUrl/driver/schedules/$scheduleId/stops/$stopOrder/passed'),
headers: headers, headers: headers,
body: jsonEncode({ body: jsonEncode(body),
if (actualPassedAt != null) 'actualPassedAt': actualPassedAt.toIso8601String(),
}),
); );
if (response.statusCode == 401 || response.statusCode == 403) { if (response.statusCode == 401 || response.statusCode == 403) {
@@ -228,6 +316,7 @@ class ScheduleService {
} }
} catch (e) { } catch (e) {
if (e is UnauthorizedException) rethrow; if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.markScheduleStopPassed', e);
throw Exception('Mark stop passed error: $e'); throw Exception('Mark stop passed error: $e');
} }
} }
+8 -1
View File
@@ -67,11 +67,18 @@ class RouteCard extends StatelessWidget {
// Ignore status update errors here; navigation should still open. // Ignore status update errors here; navigation should still open.
} }
Navigator.of(context).push( try {
await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => NavigationScreen(route: route), 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), borderRadius: BorderRadius.circular(20),
splashFactory: InkSparkle.splashFactory, splashFactory: InkSparkle.splashFactory,
+28 -2
View File
@@ -1,6 +1,21 @@
version: '3.9' version: '3.9'
services: 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: auth_api:
image: ghcr.io/sven491/xtransit/auth_api:latest image: ghcr.io/sven491/xtransit/auth_api:latest
container_name: auth_api container_name: auth_api
@@ -14,7 +29,11 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_AUTH} PG_USER: ${POSTGRES_USER_AUTH}
PG_PASS: ${POSTGRES_PASSWORD_AUTH} PG_PASS: ${POSTGRES_PASSWORD_AUTH}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_BYPASS: ${DEV_BYPASS:-0} DEV_BYPASS: ${DEV_BYPASS:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
employee_api: employee_api:
@@ -29,6 +48,9 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_EMPLOYEE} PG_USER: ${POSTGRES_USER_EMPLOYEE}
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE} PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
transit_api: transit_api:
@@ -44,8 +66,10 @@ services:
PG_DB: ${POSTGRES_DB} PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_TRANSIT} PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT} PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_MOCK: ${DEV_MOCK:-0} DEV_MOCK: ${DEV_MOCK:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped restart: unless-stopped
admin_ui: admin_ui:
@@ -56,7 +80,9 @@ services:
environment: environment:
AUTH_API: ${AUTH_API} AUTH_API: ${AUTH_API}
TRANSIT_API: ${TRANSIT_API} TRANSIT_API: ${TRANSIT_API}
ERROR_LOG_API: ${ERROR_LOG_API}
depends_on: depends_on:
- auth_api - auth_api
- transit_api - transit_api
- error_log_api
restart: unless-stopped restart: unless-stopped
+42 -2
View File
@@ -6,6 +6,44 @@ require('dotenv').config();
const app = express(); const app = express();
app.use(express.json()); 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 // Employee onboarding
app.post('/createemployee', async (req, res) => { app.post('/createemployee', async (req, res) => {
try { try {
@@ -20,8 +58,9 @@ app.post('/createemployee', async (req, res) => {
return res.status(201).json({ employee: firstname, code: newUser.usercoden }); return res.status(201).json({ employee: firstname, code: newUser.usercoden });
} }
catch (err) { catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err); console.error(err);
res.status(500).json({ error: 'internal_error' }); 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'}) return res.status(200).json({endpoint: 'reachable'})
} }
catch (err) { catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(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 # 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:** **Current Status:**
- [What is already done] - `transit_api/index.js` now materializes recurring schedules into concrete occurrences for `/schedules/overview`.
- [What is in progress] - `transit.schedule_stop_progress` is now occurrence-aware via `scheduled_passed_date`; the API backfills old rows and writes/reads stop progress per date.
- [What still needs to happen] - 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:** **Important Context:**
- [Audience, tone, constraints, preferences] - The schedule template lives in the database as a row with `weekdays`; recurring events are not stored as separate rows.
- [Key files, links, notes, or source material] - The backend should be the source of truth for date-specific materialization and live progress/status.
- [Deadline or success criteria] - 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:** **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:** **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:** **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:** **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:** **How to Respond:**
- Start with a short summary of your understanding. - Start by summarizing the current state in one short paragraph.
- Continue from here instead of restarting from scratch. - Continue from the existing implementation instead of re-discovering the schedule model.
- If anything critical is missing, ask only the minimum questions needed. - 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_PASS=your_password
PG_DB=your_database PG_DB=your_database
JWT_SECRET=your_jwt_secret JWT_SECRET=your_jwt_secret
ADMIN_USER_CODES=12345,67890
PORT=5001 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 ### 4. API starten
```bash ```bash
@@ -96,10 +97,16 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
- Haalt een datum-gebonden overzicht op van geplande diensten - Haalt een datum-gebonden overzicht op van geplande diensten
- Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays` - Herhaalde diensten worden naar de geselecteerde datum gematerialiseerd via `weekdays`
- Incidentele diensten worden alleen meegenomen op hun exacte datum - Incidentele diensten worden alleen meegenomen op hun exacte datum
- Filters: `driverId`/`chauffeurId`, `stopId`/`busStopId`, `lineId`, `status`
- Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }` - Response: `{ "date": "2026-05-06", "summary": {...}, "schedules": [...] }`
De legacy endpoint **GET** `/schedules` blijft beschikbaar voor bestaande clients. 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 ### Navigation
**POST** `/navigation/route` **POST** `/navigation/route`
+913 -79
View File
File diff suppressed because it is too large Load Diff