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