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