create admin_ui

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 10:55:31 +02:00
parent 2845f442a3
commit 8cf3fe98cb
40 changed files with 5534 additions and 693 deletions
+4
View File
@@ -10,3 +10,7 @@ build
coverage
.vscode
.idea
ADMIN_DEPLOYMENT_SUMMARY.md
CI_CD_PORTAINER_GUIDE.md
DEPLOYMENT.md
GETTING_STARTED.md
+17
View File
@@ -0,0 +1,17 @@
.env
/*.env
transit_api/.env
# Local build / node_modules
admin_ui/node_modules/
admin_ui/dist/
# Sensitive docs to keep private
ADMIN_DEPLOYMENT_SUMMARY.md
CI_CD_PORTAINER_GUIDE.md
DEPLOYMENT.md
# Runtime files
*.log
*.sqlite3
\
+88 -18
View File
@@ -1,15 +1,19 @@
# Docker Setup voor Transit Project APIs
Dit project bevat Docker-configuratie voor alle 3 APIs en PostgreSQL database.
Dit project bevat Docker-configuratie voor alle APIs, Admin Control Center en PostgreSQL database.
## Bestanden
- **auth_api/Dockerfile** - Image voor Authentication API (port 5000)
- **employee_api/Dockerfile** - Image voor Employee API (port 4000)
- **transit_api/Dockerfile** - Image voor Transit API (port 5001)
- **admin_ui/Dockerfile** - Image voor Admin Control Center (port 5174)
- **docker-compose.yml** - Orchestratie van alle services
- **.dockerignore** - Bestanden die niet in Docker images opgenomen worden
- **.env.example** - Template voor environment variabelen
- **DEPLOYMENT.md** - Gedetailleerde deployment guide
- **scripts/deploy.sh** - Deployment helper script
- **scripts/build-and-push.sh** - Build en push script voor registry
## Vereisten
@@ -28,11 +32,14 @@ cp .env.example .env
### 2. Services starten
```bash
# Start alle services (database + alle APIs)
# Start alle services (APIs + Admin UI)
docker-compose up -d
# Of build en start tegelijk (als je code hebt aangepast)
docker-compose up -d --build
# Quick start met script
./scripts/deploy.sh start
```
### 3. Logs bekijken
@@ -43,23 +50,37 @@ docker-compose logs -f
# Logs van specifieke service
docker-compose logs -f auth_api
docker-compose logs -f employee_api
docker-compose logs -f transit_api
docker-compose logs -f db
docker-compose logs -f admin_ui
# Via script
./scripts/deploy.sh logs admin_ui
```
## Beschikbare Services
| Service | Port | Database | Beschrijving |
|---------|------|----------|--------------|
| auth_api | 5000 | PostgreSQL | Authentication endpoints |
| employee_api | 4000 | PostgreSQL | Employee management endpoints |
| transit_api | 5001 | PostgreSQL | Transit schedule endpoints |
| db | 5432 | - | PostgreSQL database server |
| Service | Port | Beschrijving |
|---------|------|--------------|
| admin_ui | 5174 | Admin Control Center (React + Nginx) |
| auth_api | 5000 | Authentication endpoints |
| employee_api | 4000 | Employee management endpoints |
| transit_api | 5001 | Transit management endpoints |
## Commando's
### Services beheren
### Services beheren (Quick way)
```bash
# Via helper script
./scripts/deploy.sh start # Start alle services
./scripts/deploy.sh stop # Stop alle services
./scripts/deploy.sh restart # Herstart alle services
./scripts/deploy.sh status # Show status
./scripts/deploy.sh logs # Bekijk logs admin_ui
./scripts/deploy.sh logs transit_api # Bekijk logs specifieke service
```
### Services beheren (Docker compose)
```bash
# Start services
@@ -80,6 +101,7 @@ docker-compose down -v
```bash
# Herstart specifieke service
docker-compose restart auth_api
docker-compose restart admin_ui
# Build specifieke image
docker-compose build auth_api
@@ -88,17 +110,17 @@ docker-compose build auth_api
docker-compose logs -f transit_api
```
### Database commando's
### Admin UI
```bash
# Verbinding met database
docker-compose exec db psql -U transit_user -d transit_db
# Bekijk Admin UI
http://localhost:5174
# Database backup
docker-compose exec db pg_dump -U transit_user transit_db > backup.sql
# Logs
docker-compose logs -f admin_ui
# Database restore
docker-compose exec -T db psql -U transit_user transit_db < backup.sql
# Shell in container
docker-compose exec admin_ui sh
```
## Troubleshooting
@@ -129,6 +151,54 @@ docker-compose logs auth_api
# Controleer database connection string
```
### Admin UI niet bereikbaar
```bash
# Check Admin UI container
docker-compose logs admin_ui
# Zorg ervoor dat poort 5174 niet in gebruik is
lsof -i :5174
```
## Admin Control Center
De Admin UI is een modern React + Vite applicatie die alle admin functies biedt.
### Features
- **Authenticatie**: JWT-based login met dev token helper
- **Stops Management**: Create en beheer transit stops met geo-coördinaten
- **Bus Lines**: Define bus routes met start/end stops en reistijd
- **Fleet Management**: Monitor fleet vehicles
- **Stop Linking**: Assign stops aan bus lines met volgorde en ETA
### Toegang
```
URL: http://localhost:5174
```
### Deployment Configuratie
Environment variabelen in docker-compose.yml:
- `VITE_AUTH_API`: URL naar auth_api (standaard: http://auth_api:5000)
- `VITE_TRANSIT_API`: URL naar transit_api (standaard: http://transit_api:5001)
Voor externe toegang, update de URLs naar je deployment domain.
### Build en Deployment
```bash
# Build Admin UI image
docker-compose build admin_ui
# Push naar registry
./scripts/build-and-push.sh ghcr.io/your-org/xtransit latest
# Deploy met updated image
docker-compose up -d
```
## Development
### Wijzigingen doorgevoerd in code
+160
View File
@@ -0,0 +1,160 @@
# Quick Start Guide - XTransit Admin Control Center
## 🚀 5-Minute Setup
### Step 1: Initial Setup
```bash
# From the project root
./scripts/init.sh
```
This will:
- Check Docker and Docker Compose installation
- Create `.env` file from template
- Build all Docker images
- Prompt you to configure database credentials
### Step 2: Configure Database Connection
Edit `.env` with your database details:
```env
POSTGRES_HOST=your-db-host.com
POSTGRES_USER_AUTH=transitAUTH
POSTGRES_PASSWORD_AUTH=your_password
POSTGRES_USER_TRANSIT=transitAPI
POSTGRES_PASSWORD_TRANSIT=your_password
JWT_SECRET=your-secure-secret
ADMIN_USER_CODES=42
```
### Step 3: Start Services
```bash
./scripts/deploy.sh start
```
### Step 4: Access Admin UI
```
http://localhost:5174
```
Click "Get Dev Token" to login (development only).
## 📱 Admin UI Features
### Stops Management
- Create new transit stops
- Set geolocation (latitude, longitude)
- View all stops
- Link stops to bus lines
### Bus Lines
- Create new bus routes
- Define start and end stops
- Set estimated travel time
- Assign stops to lines with order
### Fleet
- View fleet vehicles (read-only)
- See vehicle details
## 🐳 Docker Services
| Port | Service | URL |
|------|---------|-----|
| 5174 | Admin UI | http://localhost:5174 |
| 5000 | Auth API | http://localhost:5000 |
| 5001 | Transit API | http://localhost:5001 |
| 4000 | Employee API | http://localhost:4000 |
## 🔧 Common Commands
```bash
# View service status
./scripts/deploy.sh status
# Restart all services
./scripts/deploy.sh restart
# View logs
./scripts/deploy.sh logs admin_ui
./scripts/deploy.sh logs transit_api
# Stop services
./scripts/deploy.sh stop
# Clean up containers and volumes
./scripts/deploy.sh clean
```
## 🔐 Authentication
### Development (Local Testing)
- Click "Get Dev Token" button in login screen
- Uses hardcoded user ID 42 for testing
### Production
- Implement real authentication system
- Update auth_api with your authentication provider
- Remove dev token button from code
- Configure proper admin user codes in ADMIN_USER_CODES
## 📝 Configuration Files
- **docker-compose.yml** - All service definitions
- **.env** - Database and API credentials
- **DEPLOYMENT.md** - Comprehensive deployment guide
- **DOCKER_README.md** - Docker-specific documentation
## ⚠️ Troubleshooting
### "Address already in use"
```bash
# Find and stop the process using the port
lsof -i :5174 # Find process on port 5174
kill -9 <PID> # Kill the process
```
### "Cannot connect to database"
```bash
# Check database credentials in .env
# Verify database is accessible from your machine
# Ensure database user exists and has proper permissions
```
### "Admin UI not loading"
```bash
# Check container is running
docker-compose ps admin_ui
# View logs
docker-compose logs -f admin_ui
# Restart service
docker-compose restart admin_ui
```
### "Permission denied" errors
The transit API user needs proper database permissions:
```sql
-- Grant permissions to transitAPI user
GRANT CONNECT ON DATABASE "XTransit" TO transitAPI;
GRANT USAGE ON SCHEMA public TO transitAPI;
GRANT SELECT, INSERT, UPDATE ON all tables IN SCHEMA public TO transitAPI;
```
## 📚 For More Information
- **DEPLOYMENT.md** - Full deployment guide for production
- **DOCKER_README.md** - Docker and container information
- **admin_ui/README.md** - Admin UI specific details
## 🎯 Next Steps
1. ✅ Run initial setup script
2. ✅ Configure database credentials
3. ✅ Start services
4. ✅ Access Admin UI at http://localhost:5174
5. ✅ Create test stops and bus lines
6. ✅ Test the admin interface
For production deployment, see **DEPLOYMENT.md** for security and configuration best practices.
+14
View File
@@ -0,0 +1,14 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --production=false --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+186
View File
@@ -0,0 +1,186 @@
# Transit Admin Control Center
Modern React + Vite web application for comprehensive transit system administration.
## Features
✅ **Stop Management** - Create and manage transit stops with geolocation
✅ **Bus Lines** - Define routes with start/end stops and estimated durations
✅ **Fleet Management** - Monitor and view fleet vehicles
✅ **Stop Linking** - Assign stops to bus lines with order and ETA
✅ **JWT Authentication** - Secure authentication with role-based access
✅ **Production Ready** - Docker containerized with runtime env configuration
## Local Development
### Prerequisites
- Node.js 18+
- npm or yarn
### Setup
```bash
cd admin_ui
npm install
```
### Environment Configuration
Create a `.env` file in the `admin_ui` directory:
```env
VITE_AUTH_API=http://localhost:5000
VITE_TRANSIT_API=http://localhost:5001
```
### Run Development Server
```bash
npm run dev
```
The app will be available at `http://localhost:5173`
### Build for Production
```bash
npm run build
```
This creates a production bundle in the `dist/` directory.
## Docker Deployment
### Build Docker Image
```bash
docker build -t xtransit/admin_ui:latest .
```
### Run with Docker
```bash
docker run -p 5174:80 \
-e VITE_AUTH_API=http://localhost:5000 \
-e VITE_TRANSIT_API=http://localhost:5001 \
xtransit/admin_ui:latest
```
### Using Docker Compose
The admin UI is integrated into the main `docker-compose.yml` at the project root.
From the project root:
```bash
# Start all services including admin UI
docker-compose up -d
# Access the admin UI
http://localhost:5174
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_AUTH_API` | Auth API URL (internal: http://auth_api:5000) | http://localhost:5000 |
| `VITE_TRANSIT_API` | Transit API URL (internal: http://transit_api:5001) | http://localhost:5001 |
### Runtime Configuration
When running in Docker, environment variables are injected at runtime via `docker-entrypoint.sh` which creates `/usr/share/nginx/html/env-config.js`. This allows configuration without rebuilding the image.
## Architecture
### Technology Stack
- **Frontend**: React 18.2 + Vite 5.0
- **HTTP Client**: Axios 1.4
- **Server**: Nginx (Alpine Linux)
- **Build**: Multi-stage Docker build for optimized image size
### Component Structure
- `App.jsx` - Main app router, session management
- `Login.jsx` - Authentication with dev token helper
- `StopsManager.jsx` - Create/list transit stops
- `BusLinesManager.jsx` - Create/list bus routes
- `FleetManager.jsx` - View fleet vehicles
- `LinkStop.jsx` - Link stops to bus lines
### API Integration
The app communicates with:
- **Auth API** (/login, /me, /dev/token)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
All requests include JWT Bearer token for authentication.
## Authentication
### Login Flow
1. User clicks "Get Dev Token" button (development only)
2. Client calls POST `/dev/token`
3. Receives JWT token with userId and userCode
4. JWT is stored in localStorage
5. All API requests include `Authorization: Bearer <token>`
### 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.
### Production Setup
1. Remove dev token button from Login.jsx
2. Implement real authentication system (OAuth/SAML)
3. Configure proper JWT generation in auth_api
4. Set strong `JWT_SECRET` environment variable
5. Configure `ADMIN_USER_CODES` with actual admin user IDs
## Troubleshooting
### Issue: "Admin UI not loading"
- Verify container is running: `docker ps`
- Check logs: `docker-compose logs admin_ui`
- Clear browser cache (Ctrl+Shift+R)
### Issue: "API connection refused"
- Ensure auth_api and transit_api are running
- Check environment variables are set correctly
- Verify network connectivity between containers
### Issue: "Permission denied" on admin operations
- Verify JWT token includes valid `userCode`
- Check `ADMIN_USER_CODES` environment variable
- Ensure database user has appropriate permissions
## Deployment
See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide.
### Production Checklist
- [ ] Remove development authentication helpers
- [ ] Set strong JWT_SECRET
- [ ] Configure proper ADMIN_USER_CODES
- [ ] Enable HTTPS/SSL
- [ ] Configure CORS for production domain
- [ ] Set up monitoring and logging
- [ ] Configure database backups
- [ ] Document admin procedures
## Support
For issues or questions:
1. Check service logs: `docker-compose logs admin_ui`
2. Verify API connectivity: Visit API URLs directly
3. Check browser console for client-side errors
4. Review DEPLOYMENT.md for troubleshooting
## License
Part of the XTransit project
+9
View File
@@ -0,0 +1,9 @@
version: '3.8'
services:
admin-ui:
build: .
ports:
- "5174:80"
environment:
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
# Create runtime env-config.js for the static app
cat > /usr/share/nginx/html/env-config.js <<'EOF'
window.__ENV__ = {
VITE_AUTH_API: "${VITE_AUTH_API:-http://localhost:5000}",
VITE_TRANSIT_API: "${VITE_TRANSIT_API:-http://localhost:5001}"
}
EOF
exec nginx -g 'daemon off;'
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Transit Admin</title>
</head>
<body>
<div id="root"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1992
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "transit-admin-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"axios": "1.4.0"
},
"devDependencies": {
"vite": "5.0.0",
"@vitejs/plugin-react": "5.0.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
window.__ENV__ = {
VITE_AUTH_API: 'http://localhost:5000',
VITE_TRANSIT_API: 'http://localhost:5001'
}
+50
View File
@@ -0,0 +1,50 @@
import React, { useState, useEffect } from 'react'
import Login from './components/Login'
import StopsManager from './components/StopsManager'
import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
useEffect(() => {
if (token) localStorage.setItem('token', token)
else localStorage.removeItem('token')
}, [token])
useEffect(() => {
if (user) localStorage.setItem('user', JSON.stringify(user))
else localStorage.removeItem('user')
}, [user])
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
return (
<div className="app">
<header className="topbar">
<div>
<p className="eyebrow">Transit operations</p>
<h1>Admin control center</h1>
</div>
<div className="userbox">
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
</div>
</header>
<div className="hero">
<div>
<h2>Fleet, lijnen en stops</h2>
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
</div>
</div>
<main>
<StopsManager token={token} user={user} />
<BusLinesManager token={token} user={user} />
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
</main>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import axios from 'axios'
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
const AUTH_API = RUNTIME.VITE_AUTH_API || import.meta.env.VITE_AUTH_API || 'http://localhost:5000'
const TRANSIT_API = RUNTIME.VITE_TRANSIT_API || import.meta.env.VITE_TRANSIT_API || 'http://localhost:5001'
export const authClient = axios.create({ baseURL: AUTH_API })
export const transitClient = (token) => axios.create({
baseURL: TRANSIT_API,
headers: { Authorization: `Bearer ${token}` }
})
@@ -0,0 +1,90 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function BusLinesManager({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [lineNumber, setLineNumber] = useState('')
const [startStop, setStartStop] = useState('')
const [endStop, setEndStop] = useState('')
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadBusLines()
}, [])
const createBusLine = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/bus-lines', {
lineNumber: Number(lineNumber),
startStop,
endStop,
estimatedDurationMinutes: Number(estimatedDurationMinutes),
description,
})
setLineNumber('')
setStartStop('')
setEndStop('')
setEstimatedDurationMinutes('')
setDescription('')
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
await loadBusLines()
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Buslijnen</h2>
<p>Maak en bewerk buslijnen.</p>
</div>
<button className="secondary" onClick={loadBusLines}>Refresh</button>
</div>
<div className="list-grid">
{busLines.map(line => (
<article className="mini-card" key={line.id}>
<strong>#{line.id} Lijn {line.lineNumber}</strong>
<div>{line.startStop} → {line.endStop}</div>
<div>{line.estimatedDurationMinutes} min</div>
{line.description ? <div className="muted">{line.description}</div> : null}
</article>
))}
</div>
{isAdmin ? (
<form onSubmit={createBusLine} className="stack-form">
<div className="form-row">
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
<input placeholder="Start stop" value={startStop} onChange={e => setStartStop(e.target.value)} />
<input placeholder="End stop" value={endStop} onChange={e => setEndStop(e.target.value)} />
</div>
<div className="form-row">
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
<button type="submit">Create bus line</button>
</div>
</form>
) : <div>You must be admin to create bus lines.</div>}
{message && <div className="message">{message}</div>}
</section>
)
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function FleetManager({ token, user }) {
const client = transitClient(token)
const [buses, setBuses] = useState([])
const [name, setName] = useState('')
const [seatCapacity, setSeatCapacity] = useState('')
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode))
const loadBuses = async () => {
try {
const res = await client.get('/admin/fleet/buses')
setBuses(res.data.buses || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadBuses()
}, [])
const createBus = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/fleet/buses', {
name,
seatCapacity: Number(seatCapacity),
licensePlate,
})
setName('')
setSeatCapacity('')
setLicensePlate('')
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
await loadBuses()
} catch (err) {
const details = err.response?.data?.details || err.response?.data?.error || err.message
if (String(details).includes('permission denied') || err.response?.status === 403) {
setCanCreateBus(false)
setMessage('Fleet create is read-only on this database user.')
return
}
setMessage('Error: ' + details)
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Fleet</h2>
<p>Beheer voertuigen voor de vloot.</p>
</div>
<button className="secondary" onClick={loadBuses}>Refresh</button>
</div>
<div className="list-grid">
{buses.map(bus => (
<article className="mini-card" key={bus.id}>
<strong>#{bus.id} {bus.name}</strong>
<div>Seats: {bus.seatCapacity}</div>
<div>Plate: {bus.licensePlate}</div>
</article>
))}
</div>
{isAdmin && canCreateBus ? (
<form onSubmit={createBus} className="stack-form">
<div className="form-row">
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
<button type="submit">Create bus</button>
</div>
</form>
) : (
<div className="muted">Fleet create is read-only on this database user.</div>
)}
{message && <div className="message">{message}</div>}
</section>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function LinkStop({ token, user }) {
const client = transitClient(token)
const [busLines, setBusLines] = useState([])
const [busLineId, setBusLineId] = useState('')
const [stopId, setStopId] = useState('')
const [order, setOrder] = useState('')
const [eta, setEta] = useState('')
const [message, setMessage] = useState(null)
const [stops, setStops] = useState([])
const isAdmin = Boolean(user?.userCode)
const loadBusLines = async () => {
try {
const res = await client.get('/admin/bus-lines')
setBusLines(res.data.busLines || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
const loadStopsForLine = async (lineId = busLineId) => {
if (!lineId) return
setMessage(null)
try {
const res = await client.get(`/bus-lines/${lineId}/stops`)
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadBusLines()
}, [])
const link = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
stopId: parseInt(stopId,10),
stopOrder: parseInt(order,10),
estimatedArrivalMinutes: eta ? parseInt(eta,10) : null
})
setMessage('Linked: ' + res.data.routeStop.id)
await loadStopsForLine()
} catch (err) {
if (err.response?.status === 403) setMessage('Forbidden: not admin')
else setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Stops per lijn</h2>
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
</div>
<button className="secondary" onClick={() => loadStopsForLine()}>Refresh</button>
</div>
<div className="form-row">
<input
placeholder="Bus line ID"
value={busLineId}
onChange={e => {
setBusLineId(e.target.value)
}}
/>
<button type="button" onClick={() => loadStopsForLine()}>Load Stops</button>
</div>
{busLines.length > 0 && (
<div className="muted" style={{ marginBottom: 10 }}>
Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')}
</div>
)}
<ul>
{stops.map(s => (
<li key={s.id}>{s.order}. {s.name} ({s.latitude}, {s.longitude})</li>
))}
</ul>
<h3>Link a Stop to Line</h3>
{isAdmin ? (
<form onSubmit={link} className="form-row">
<input placeholder="Stop ID" value={stopId} onChange={e=>setStopId(e.target.value)} />
<input placeholder="Stop Order" value={order} onChange={e=>setOrder(e.target.value)} />
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
<button type="submit">Link Stop</button>
</form>
) : (
<div>You must be admin to link stops.</div>
)}
{message && <div className="message">{message}</div>}
</section>
)
}
+61
View File
@@ -0,0 +1,61 @@
import React, { useState } from 'react'
import { authClient } from '../api'
export default function Login({ onLogin }) {
const [userCode, setUserCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(false)
const submit = async (e) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
const res = await authClient.post('/login', { userCode, password })
const token = res.data.token
// fetch /me to get payload
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
onLogin(token, me.data.user)
} catch (err) {
setError(err.response?.data?.error || 'login_failed')
} finally { setLoading(false) }
}
const devToken = async () => {
setError(null)
setLoading(true)
try {
const res = await authClient.post('/dev/token', { userId: 1, userCode: 42 })
const token = res.data.token
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
onLogin(token, me.data.user)
} catch (err) {
setError(err.response?.data?.error || 'dev_token_failed')
} finally { setLoading(false) }
}
return (
<div className="login">
<h2>Admin Login</h2>
<form onSubmit={submit}>
<label>
User Code
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
</label>
<label>
Password
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
</label>
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
{error && <div className="error">{error}</div>}
</form>
<p>Be sure `ADMIN_USER_CODES` contains your `userCode` for admin actions.</p>
<hr />
<div>
<button onClick={devToken} disabled={loading}>Get Dev Token (if available)</button>
<div style={{fontSize:12, marginTop:6}}>Uses `/dev/token` on the auth API for quick local testing.</div>
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react'
import { transitClient } from '../api'
export default function StopsManager({ token, user }) {
const client = transitClient(token)
const [stops, setStops] = useState([])
const [name, setName] = useState('')
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const loadStops = async () => {
try {
const res = await client.get('/admin/stops')
setStops(res.data.stops || [])
} catch (err) {
setMessage('Error: ' + (err.response?.data?.error || err.message))
}
}
useEffect(() => {
loadStops()
}, [])
const createStop = async (e) => {
e.preventDefault()
setMessage(null)
try {
const res = await client.post('/admin/stops', {
name,
latitude: parseFloat(lat),
longitude: parseFloat(lon)
})
setMessage('Stop created: #' + res.data.stop.id)
setName(''); setLat(''); setLon('')
await loadStops()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
if (err.response?.status === 401) {
setMessage('Unauthorized: invalid token')
} else if (err.response?.status === 403) {
setMessage('Forbidden: you are not an admin')
} else {
setMessage('Error: ' + errMsg)
}
}
}
return (
<section className="card">
<div className="section-header">
<div>
<h2>Stops</h2>
<p>Maak herbruikbare haltes aan.</p>
</div>
<button className="secondary" onClick={loadStops}>Refresh</button>
</div>
<div className="list-grid">
{stops.map(stop => (
<article className="mini-card" key={stop.id}>
<strong>#{stop.id} {stop.name}</strong>
<div>{stop.latitude}, {stop.longitude}</div>
</article>
))}
</div>
{isAdmin ? (
<form onSubmit={createStop} className="stack-form">
<div className="form-row">
<input placeholder="Name" value={name} onChange={e=>setName(e.target.value)} />
<input placeholder="Latitude" value={lat} onChange={e=>setLat(e.target.value)} />
<input placeholder="Longitude" value={lon} onChange={e=>setLon(e.target.value)} />
<button type="submit">Create Stop</button>
</div>
</form>
) : (
<div>You must be admin to create stops.</div>
)}
{message && <div className="message">{message}</div>}
<p>Note: To view stops per line, use the Bus Line Stops tool below.</p>
</section>
)
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+174
View File
@@ -0,0 +1,174 @@
body {
margin: 0;
color: #0f172a;
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
font-family: Inter, system-ui, Arial, sans-serif;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 28px 18px 48px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.topbar h1 {
margin: 4px 0 0;
font-size: 2.2rem;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #4f46e5;
font-size: 0.75rem;
font-weight: 700;
}
.userbox {
display: flex;
gap: 12px;
align-items: center;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 10px 14px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
}
.hero {
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(37, 99, 235, 0.88));
color: white;
border-radius: 24px;
padding: 24px 26px;
margin-bottom: 22px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.hero h2 {
margin: 0 0 6px;
font-size: 1.5rem;
}
.hero p,
.card p {
margin: 0;
color: inherit;
}
main {
display: grid;
gap: 18px;
}
.card,
.login {
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 22px;
padding: 18px;
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.07);
backdrop-filter: blur(14px);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 10px;
margin: 10px 0;
flex-wrap: wrap;
}
.stack-form {
margin-top: 10px;
}
input,
select,
button {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.45);
padding: 11px 12px;
font: inherit;
}
input,
select {
background: white;
min-width: 180px;
flex: 1;
}
button {
background: #111827;
color: white;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16);
}
button.secondary {
background: white;
color: #0f172a;
}
.message {
margin-top: 10px;
color: #047857;
font-weight: 600;
}
.error {
color: #b91c1c;
margin-top: 10px;
}
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
margin: 10px 0 16px;
}
.mini-card {
background: rgba(248, 250, 252, 0.96);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 16px;
padding: 12px 14px;
}
.mini-card strong {
display: block;
margin-bottom: 6px;
}
.muted {
color: #64748b;
font-size: 0.95rem;
}
.login {
max-width: 540px;
margin: 60px auto;
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
})
+22
View File
@@ -7,6 +7,17 @@ require('dotenv').config();
const app = express();
app.use(express.json());
// Simple CORS middleware for browser-based admin UI testing.
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
});
const JWT_SECRET = process.env.JWT_SECRET;
@@ -44,6 +55,17 @@ app.get('/me', async (req, res) => {
}
});
// Development helper: issue a token without DB checks when DEV_BYPASS=1
if (process.env.DEV_BYPASS === '1') {
app.post('/dev/token', (req, res) => {
const { userId, userCode } = req.body || {};
if (!userId || !userCode) return res.status(400).json({ error: 'userId and userCode required' });
const payload = { userId: Number(userId), userCode: Number(userCode) };
const token = jwt.sign(payload, JWT_SECRET || 'devsecret', { expiresIn: '7d' });
res.json({ token, user: payload });
});
}
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
+104 -22
View File
@@ -56,18 +56,65 @@ class RouteLeg {
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
String getDurationMinutes() => (duration ~/ 60).toString();
factory RouteLeg.fromJson(Map<String, dynamic> json) {
final coords = json['geometry']['coordinates'] as List;
factory RouteLeg.fromCoordinates(
List<dynamic> coordinates, {
double distance = 0.0,
int duration = 0,
String summary = 'Route',
}) {
return RouteLeg(
points: coords
.map((coord) => NavigationPoint(
latitude: coord[1] as double,
longitude: coord[0] as double,
))
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: (json['distance'] as num).toDouble(),
duration: (json['duration'] as num).toInt(),
summary: json['summary'] as String? ?? 'Route',
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromPoints({
required List<NavigationPoint> points,
required double distance,
required int duration,
String summary = 'Route',
}) {
return RouteLeg(
points: points,
distance: distance,
duration: duration,
summary: summary,
);
}
factory RouteLeg.fromJson(Map<String, dynamic> json) {
final geometry = json['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
return RouteLeg(
points: coordinates
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList(),
distance: (json['distance'] as num?)?.toDouble() ?? 0.0,
duration: (json['duration'] as num?)?.toInt() ?? 0,
summary: (json['summary'] as String?) ?? 'Route',
);
}
}
@@ -108,22 +155,57 @@ class NavigationRoute {
}
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
final routes = json['routes'] as List;
if (routes.isEmpty) {
throw Exception('No route found');
final routes = json['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes[0] as Map<String, dynamic>;
final legs = (routeData['legs'] as List)
.map((leg) => RouteLeg.fromJson(leg as Map<String, dynamic>))
.toList();
final routeData = routes[0] as Map<String, dynamic>?;
if (routeData == null) {
throw Exception('Invalid route data');
}
return NavigationRoute.fromOsrmRouteData(
routeData,
start: NavigationPoint(latitude: 0, longitude: 0),
end: NavigationPoint(latitude: 0, longitude: 0),
);
}
factory NavigationRoute.fromOsrmRouteData(
Map<String, dynamic> routeData, {
required NavigationPoint start,
required NavigationPoint end,
}) {
final geometry = routeData['geometry'] as Map<String, dynamic>?;
final coordinates = geometry?['coordinates'] as List? ?? [];
final distance = (routeData['distance'] as num?)?.toDouble() ?? 0.0;
final duration = (routeData['duration'] as num?)?.toInt() ?? 0;
final leg = RouteLeg.fromCoordinates(
coordinates,
distance: distance,
duration: duration,
summary: (routeData['summary'] as String?) ?? 'Route',
);
return NavigationRoute(
start: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
end: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
legs: legs,
totalDistance: (routeData['distance'] as num).toDouble(),
totalDuration: (routeData['duration'] as num).toInt(),
start: start,
end: end,
legs: [leg],
totalDistance: distance,
totalDuration: duration,
);
}
}
/// Result of an optimized trip calculation with reordered stops.
class OptimizedRouteResult {
final NavigationRoute route;
final List<NavigationPoint> orderedStops;
OptimizedRouteResult({
required this.route,
required this.orderedStops,
});
}
@@ -28,6 +28,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
}
Future<void> _selectDate() async {
final colorScheme = Theme.of(context).colorScheme;
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
@@ -36,7 +37,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
dialogBackgroundColor: Colors.white,
dialogBackgroundColor: colorScheme.surface,
),
child: child!,
);
@@ -53,236 +54,210 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
@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')}';
final isToday = DateTime(
_selectedDate.year,
_selectedDate.month,
_selectedDate.day,
) ==
DateTime(
DateTime.now().year,
DateTime.now().month,
DateTime.now().day,
);
final today = DateTime.now();
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
DateTime(today.year, today.month, today.day);
return Scaffold(
appBar: AppBar(
title: const Text('Your Schedule'),
title: const Text('Today on the Road'),
elevation: 0,
),
body: Column(
children: [
// Date selector
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Today' : 'Scheduled Date',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey[600],
),
),
Text(
dateStr,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: const Text('Change'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Vandaag' : 'Geplande datum',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white.withOpacity(0.78),
),
),
const SizedBox(height: 6),
Text(
dateStr,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Routes, stops en status in één overzicht',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.84),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: SizedBox(
height: 48,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today, size: 18),
label: const Text('Wijzig'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withOpacity(0.28)),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
),
),
],
],
),
),
),
const Divider(),
Expanded(
child: FutureBuilder<DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Routes list
Expanded(
child: FutureBuilder<DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
'No routes scheduled',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Check back later for your schedule',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
final routes = snapshot.data!.routes;
final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList();
final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList();
final completedRoutes = routes.where((r) => r.status == 'completed').toList();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
padding: const EdgeInsets.only(bottom: 16),
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Failed to load schedule',
style: Theme.of(context).textTheme.titleMedium,
),
if (inProgressRoutes.isNotEmpty) ...[
_sectionHeader('In Progress', colorScheme.tertiary),
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (scheduledRoutes.isNotEmpty) ...[
_sectionHeader('Scheduled', colorScheme.primary),
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
if (completedRoutes.isNotEmpty) ...[
_sectionHeader('Completed', colorScheme.secondary),
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})),
],
const SizedBox(height: 8),
Text(
snapshot.error.toString(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _refreshSchedule,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.calendar_today,
size: 64,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Text(
'No routes scheduled',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Check back later for your schedule',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
],
),
);
}
final schedule = snapshot.data!;
final routes = schedule.routes;
// Separate routes by status
final scheduledRoutes = routes
.where((r) => r.status == 'scheduled')
.toList();
final inProgressRoutes = routes
.where((r) => r.status == 'in_progress')
.toList();
final completedRoutes = routes
.where((r) => r.status == 'completed')
.toList();
return RefreshIndicator(
onRefresh: _refreshSchedule,
child: ListView(
children: [
// In Progress section
if (inProgressRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'In Progress',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
),
...inProgressRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
// Scheduled section
if (scheduledRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'Scheduled',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
...scheduledRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
// Completed section
if (completedRoutes.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(
left: 16,
top: 24,
bottom: 8,
),
child: Text(
'Completed',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
...completedRoutes.map((route) => RouteCard(
route: route,
onTap: () {},
)),
],
const SizedBox(height: 16),
],
),
);
},
},
),
),
],
),
),
);
}
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,
),
),
],
),
);
}
+202 -93
View File
@@ -40,7 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
throw Exception('User code must be a number');
}
final response = await _authService.login(
await _authService.login(
userCode: parsedUserCode,
password: _passwordController.text,
);
@@ -85,105 +85,214 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Bus Terminal Login'),
elevation: 0,
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
// Title
Text(
'Driver Login',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'Enter your credentials to continue',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 48),
// Error message
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red[50],
border: Border.all(color: Colors.red[300]!),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_errorMessage!,
style: TextStyle(
color: Colors.red[700],
fontSize: 14,
),
),
),
if (_errorMessage != null) const SizedBox(height: 24),
// User Code Field
CustomInputField(
label: 'User Code',
hint: 'Enter your user code',
controller: _userCodeController,
validator: _validateUserCode,
),
const SizedBox(height: 24),
// Password Field
CustomInputField(
label: 'Password',
hint: 'Enter your password',
controller: _passwordController,
obscureText: true,
validator: _validatePassword,
),
const SizedBox(height: 40),
// Login Button - Large for touch accessibility
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56), // Large touch target
disabledBackgroundColor: Colors.grey[400],
),
child: _isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text(
'Login',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: Theme.of(context).brightness == Brightness.dark
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.18),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.16),
shape: BoxShape.circle,
),
child: const Icon(
Icons.directions_bus,
color: Colors.white,
size: 30,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Bus Terminal',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Driver access dashboard',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.white.withOpacity(0.82),
),
),
],
),
),
],
),
),
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.92),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: colorScheme.outline.withOpacity(0.14)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 22,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Driver Login',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'Sign in to view your route, stops and live navigation.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.error.withOpacity(0.12),
border: Border.all(color: colorScheme.error.withOpacity(0.35)),
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 10),
Expanded(
child: Text(
_errorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
),
),
const SizedBox(height: 20),
],
CustomInputField(
label: 'User Code',
hint: 'Enter your user code',
controller: _userCodeController,
validator: _validateUserCode,
),
const SizedBox(height: 20),
CustomInputField(
label: 'Password',
hint: 'Enter your password',
controller: _passwordController,
obscureText: true,
validator: _validatePassword,
),
const SizedBox(height: 28),
FilledButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.onPrimary),
),
)
: const Text('Login'),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_statusPill(Icons.shield_outlined, 'Secure access'),
const SizedBox(width: 10),
_statusPill(Icons.route_outlined, 'Live route data'),
],
),
],
),
),
],
),
],
),
),
),
),
),
);
}
Widget _statusPill(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withOpacity(0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:ffi';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
+202 -23
View File
@@ -47,21 +47,25 @@ class NavigationService {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message']}');
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
final route = NavigationRoute.fromJson(data);
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
}
// Set start and end points properly
return NavigationRoute(
final routes = data['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes.first as Map<String, dynamic>;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: start,
end: end,
legs: route.legs,
totalDistance: route.totalDistance,
totalDuration: route.totalDuration,
);
} else {
throw Exception('Failed to get route: ${response.statusCode}');
@@ -71,6 +75,144 @@ class NavigationService {
}
}
/// Get a route that passes through the provided stops in the given order.
Future<NavigationRoute> getRouteThroughStops({
required List<NavigationPoint> stops,
}) async {
try {
if (stops.length < 2) {
throw Exception('At least two stops are required');
}
final coordinates = stops
.map((stop) => '${stop.longitude},${stop.latitude}')
.join(';');
final url =
'$_osrmBaseUrl/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last';
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to get route: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
if (data['code'] != 'Ok') {
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
}
final routes = data['routes'] as List?;
if (routes == null || routes.isEmpty) {
throw Exception('No route found in response');
}
final routeData = routes.first as Map<String, dynamic>;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: stops.first,
end: stops.last,
);
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Get an optimized trip through multiple stops.
/// Keeps the first and last stops fixed and lets OSRM order the intermediates.
Future<OptimizedRouteResult> getOptimizedRouteThroughStops({
required List<NavigationPoint> stops,
}) async {
try {
if (stops.length < 2) {
throw Exception('At least two stops are required');
}
final coordinates = stops
.map((stop) => '${stop.longitude},${stop.latitude}')
.join(';');
final url =
'https://router.project-osrm.org/trip/v1/car/$coordinates?overview=full&steps=true&geometries=geojson&source=first&destination=last&roundtrip=false';
final response = await http.get(Uri.parse(url));
if (response.statusCode != 200) {
throw Exception('Failed to get optimized route: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
if (data['code'] != 'Ok') {
throw Exception('Optimized route not found: ${data['message'] ?? 'Unknown error'}');
}
final trips = data['trips'] as List?;
if (trips == null || trips.isEmpty) {
throw Exception('No optimized trip found');
}
final trip = trips.first as Map<String, dynamic>;
final geometry = trip['geometry'] as Map<String, dynamic>?;
final coordinatesData = geometry?['coordinates'] as List? ?? [];
final points = coordinatesData
.map((coord) {
if (coord is List && coord.length >= 2) {
return NavigationPoint(
latitude: (coord[1] as num).toDouble(),
longitude: (coord[0] as num).toDouble(),
);
}
return null;
})
.whereType<NavigationPoint>()
.toList();
final waypointItems = (data['waypoints'] as List?) ?? [];
final orderedPairs = <MapEntry<int, NavigationPoint>>[];
for (var i = 0; i < waypointItems.length && i < stops.length; i++) {
final waypoint = waypointItems[i];
if (waypoint is! Map<String, dynamic>) continue;
final index = waypoint['waypoint_index'] as num?;
if (index == null) continue;
orderedPairs.add(MapEntry(index.toInt(), stops[i]));
}
orderedPairs.sort((a, b) => a.key.compareTo(b.key));
final orderedStops = orderedPairs.isNotEmpty
? orderedPairs.map((entry) => entry.value).toList()
: stops;
final route = NavigationRoute(
start: orderedStops.first,
end: orderedStops.last,
legs: [
RouteLeg.fromPoints(
points: points,
distance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
duration: (trip['duration'] as num?)?.toInt() ?? 0,
summary: 'Optimized trip',
),
],
totalDistance: (trip['distance'] as num?)?.toDouble() ?? 0.0,
totalDuration: (trip['duration'] as num?)?.toInt() ?? 0,
);
return OptimizedRouteResult(
route: route,
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
);
} catch (e) {
throw Exception('Navigation error: $e');
}
}
/// Get multiple routes (alternatives)
Future<List<NavigationRoute>> getMultipleRoutes({
required NavigationPoint start,
@@ -86,26 +228,30 @@ class NavigationService {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
if (data['code'] != 'Ok') {
throw Exception('Routes not found: ${data['message']}');
final data = jsonDecode(response.body) as Map<String, dynamic>?;
if (data == null) {
throw Exception('Empty response from routing service');
}
final routes = (data['routes'] as List)
if (data['code'] != 'Ok') {
throw Exception('Routes not found: ${data['message'] ?? 'Unknown error'}');
}
final routesData = data['routes'] as List?;
if (routesData == null || routesData.isEmpty) {
throw Exception('No routes found in response');
}
final routes = routesData
.map((routeData) {
return NavigationRoute(
if (routeData is! Map<String, dynamic>) return null;
return NavigationRoute.fromOsrmRouteData(
routeData,
start: start,
end: end,
legs: (routeData['legs'] as List)
.map((leg) =>
RouteLeg.fromJson(leg as Map<String, dynamic>))
.toList(),
totalDistance:
(routeData['distance'] as num).toDouble(),
totalDuration: (routeData['duration'] as num).toInt(),
);
})
.whereType<NavigationRoute>()
.toList();
return routes;
@@ -125,4 +271,37 @@ class NavigationService {
LatLng(p2.latitude, p2.longitude),
);
}
/// Geocode a place name to coordinates using Nominatim (OpenStreetMap)
Future<NavigationPoint> geocodePlace(String name) async {
try {
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
'q': name,
'format': 'json',
'limit': '1',
});
final response = await http.get(uri, headers: {
'User-Agent': 'bus-terminal-app',
});
if (response.statusCode != 200) {
throw Exception('Geocoding failed: ${response.statusCode}');
}
final list = jsonDecode(response.body) as List<dynamic>?;
if (list == null || list.isEmpty) {
throw Exception('No geocoding result for "$name"');
}
final item = list[0] as Map<String, dynamic>;
final lat = double.parse(item['lat'] as String);
final lon = double.parse(item['lon'] as String);
final display = item['display_name'] as String? ?? name;
return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) {
throw Exception('Geocode error: $e');
}
}
}
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import '../models/navigation.dart';
import 'auth_service.dart';
/// Schedule/Route Service
@@ -82,4 +83,42 @@ class ScheduleService {
throw Exception('Status update error: $e');
}
}
/// Get stops for a bus line
Future<List<NavigationPoint>> getStops(int busLineId) async {
try {
final headers = await _authService.getAuthHeaders();
final response = await http.get(
Uri.parse('$_apiBaseUrl/bus-lines/$busLineId/stops'),
headers: headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final stops = (data['stops'] as List?)
?.map((s) => s as Map<String, dynamic>)
.map((s) {
final lat = (s['latitude'] ?? s['lat']) as num?;
final lon = (s['longitude'] ?? s['lon']) as num?;
final name = (s['name'] ?? s['stop_name'] ?? s['stopName']) as String?;
if (lat == null || lon == null) return null;
return NavigationPoint(
latitude: lat.toDouble(),
longitude: lon.toDouble(),
name: name,
);
})
.whereType<NavigationPoint>()
.toList() ?? [];
return stops;
} else if (response.statusCode == 401) {
throw Exception('Unauthorized - Please login again');
} else {
throw Exception('Failed to load stops: ${response.statusCode}');
}
} catch (e) {
throw Exception('Get stops error: $e');
}
}
}
+186 -6
View File
@@ -2,23 +2,203 @@ import 'package:flutter/material.dart';
/// Material 3 Theme configuratie voor bus_terminal
class AppTheme {
// TODO: Implement Material 3 theme for bus drivers
// - Large touch targets
// - High contrast
// - Clear visual hierarchy
static const Color _navy = Color(0xFF0F172A);
static const Color _surface = Color(0xFFF8FAFC);
static const Color _surfaceDark = Color(0xFF111827);
static const Color _amber = Color(0xFFF59E0B);
static const Color _cyan = Color(0xFF38BDF8);
static const Color _success = Color(0xFF22C55E);
static const Color _error = Color(0xFFEF4444);
static TextTheme _textTheme(ColorScheme colorScheme) {
return TextTheme(
displayLarge: const TextStyle(fontSize: 46, fontWeight: FontWeight.w800, letterSpacing: -1.2),
displayMedium: const TextStyle(fontSize: 38, fontWeight: FontWeight.w800, letterSpacing: -0.9),
headlineLarge: const TextStyle(fontSize: 30, fontWeight: FontWeight.w800, letterSpacing: -0.4),
headlineMedium: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.3),
titleLarge: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
titleMedium: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
titleSmall: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
bodyLarge: TextStyle(fontSize: 16, height: 1.45, color: colorScheme.onSurface),
bodyMedium: TextStyle(fontSize: 14, height: 1.45, color: colorScheme.onSurfaceVariant),
bodySmall: TextStyle(fontSize: 12, height: 1.35, color: colorScheme.onSurfaceVariant),
labelLarge: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
labelMedium: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
labelSmall: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.25),
);
}
static ColorScheme _buildLightScheme() {
return ColorScheme.fromSeed(
seedColor: _cyan,
brightness: Brightness.light,
primary: _navy,
secondary: _amber,
tertiary: _cyan,
surface: _surface,
onSurface: _navy,
error: _error,
).copyWith(
surfaceContainerHighest: const Color(0xFFE2E8F0),
outline: const Color(0xFF94A3B8),
);
}
static ColorScheme _buildDarkScheme() {
return ColorScheme.fromSeed(
seedColor: _cyan,
brightness: Brightness.dark,
primary: _cyan,
secondary: _amber,
tertiary: _success,
surface: _surfaceDark,
onSurface: Colors.white,
error: _error,
).copyWith(
surfaceContainerHighest: const Color(0xFF1F2937),
outline: const Color(0xFF334155),
);
}
static ThemeData lightTheme() {
final colorScheme = _buildLightScheme();
return ThemeData(
useMaterial3: true,
// TODO: Configure Material 3 theme
brightness: Brightness.light,
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFFF1F5F9),
textTheme: _textTheme(colorScheme),
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: colorScheme.onSurface),
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
margin: EdgeInsets.zero,
),
dividerTheme: DividerThemeData(color: colorScheme.outline.withOpacity(0.18), thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant.withOpacity(0.8)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.secondary,
foregroundColor: colorScheme.onSecondary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
minimumSize: const Size.fromHeight(52),
side: BorderSide(color: colorScheme.outline.withOpacity(0.35)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
chipTheme: ChipThemeData(
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(color: colorScheme.onSurface, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.primary.withOpacity(0.12),
),
);
}
static ThemeData darkTheme() {
final colorScheme = _buildDarkScheme();
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
// TODO: Configure Material 3 dark theme
colorScheme: colorScheme,
scaffoldBackgroundColor: const Color(0xFF0B1220),
textTheme: _textTheme(colorScheme).apply(bodyColor: Colors.white, displayColor: Colors.white),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF0B1220),
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
titleTextStyle: _textTheme(colorScheme).titleLarge?.copyWith(color: Colors.white),
),
cardTheme: CardThemeData(
color: const Color(0xFF111827),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(color: Color(0xFF243244), thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF111827),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF334155))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)),
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.error)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
hintStyle: const TextStyle(color: Color(0xFF94A3B8)),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.black,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.secondary,
foregroundColor: Colors.black,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
side: const BorderSide(color: Color(0xFF334155)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
chipTheme: ChipThemeData(
backgroundColor: const Color(0xFF1E293B),
labelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.primary.withOpacity(0.16),
),
);
}
}
@@ -37,6 +37,8 @@ class _CustomInputFieldState extends State<CustomInputField> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -54,13 +56,30 @@ class _CustomInputFieldState extends State<CustomInputField> {
validator: widget.validator,
decoration: InputDecoration(
hintText: widget.hint,
hintStyle: Theme.of(context).textTheme.bodyMedium,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16, // Larger vertical padding for touch targets
),
prefixIcon: Icon(
widget.obscureText ? Icons.lock_outline : Icons.badge_outlined,
color: colorScheme.primary,
),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.outline.withOpacity(0.25)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
suffixIcon: widget.obscureText
? GestureDetector(
@@ -70,6 +89,7 @@ class _CustomInputFieldState extends State<CustomInputField> {
child: Icon(
_obscureText ? Icons.visibility_off : Icons.visibility,
size: 28, // Larger icon for accessibility
color: colorScheme.primary,
),
)
: null,
+14 -6
View File
@@ -49,6 +49,7 @@ class RouteCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final statusColor = _getStatusColor(route.status);
final startTimeStr = _formatTime(route.startTime);
@@ -63,6 +64,9 @@ class RouteCard extends StatelessWidget {
},
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 0,
color: colorScheme.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -77,7 +81,11 @@ class RouteCard extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.blue,
gradient: LinearGradient(
colors: [colorScheme.primary, colorScheme.tertiary],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
),
child: Text(
@@ -94,7 +102,7 @@ class RouteCard extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.2),
color: statusColor.withOpacity(0.14),
border: Border.all(color: statusColor),
borderRadius: BorderRadius.circular(20),
),
@@ -134,7 +142,7 @@ class RouteCard extends StatelessWidget {
),
Icon(
Icons.arrow_forward,
color: Colors.grey[400],
color: colorScheme.outline,
),
Expanded(
child: Column(
@@ -181,7 +189,7 @@ class RouteCard extends StatelessWidget {
Text(
route.busType.licensePlate,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -199,14 +207,14 @@ class RouteCard extends StatelessWidget {
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.blue,
color: colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${route.busLine.estimatedDuration} min duration',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey[600],
color: colorScheme.onSurfaceVariant,
),
),
],
+16
View File
@@ -14,6 +14,7 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_AUTH}
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
DEV_BYPASS: ${DEV_BYPASS:-0}
restart: unless-stopped
employee_api:
@@ -43,4 +44,19 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_MOCK: ${DEV_MOCK:-0}
restart: unless-stopped
admin_ui:
image: ghcr.io/sven491/xtransit/admin_ui:latest
container_name: admin_ui
ports:
- "5174:80"
environment:
VITE_AUTH_API: http://auth_api:5000
VITE_TRANSIT_API: http://transit_api:5001
depends_on:
- auth_api
- transit_api
restart: unless-stopped
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Build and push Docker images to registry
# Usage: ./scripts/build-and-push.sh [registry-url]
set -e
REGISTRY=${1:-ghcr.io/sven491/xtransit}
VERSION=${2:-latest}
echo "🔨 Building Docker images..."
# Build auth_api
echo " Building auth_api..."
docker build -t ${REGISTRY}/auth_api:${VERSION} ./auth_api
# Build transit_api
echo " Building transit_api..."
docker build -t ${REGISTRY}/transit_api:${VERSION} ./transit_api
# Build employee_api
echo " Building employee_api..."
docker build -t ${REGISTRY}/employee_api:${VERSION} ./employee_api
# Build admin_ui
echo " Building admin_ui..."
docker build -t ${REGISTRY}/admin_ui:${VERSION} ./admin_ui
echo "✅ Build complete!"
echo ""
echo "🚀 Pushing images to registry: $REGISTRY"
docker push ${REGISTRY}/auth_api:${VERSION}
echo " ✓ Pushed auth_api:${VERSION}"
docker push ${REGISTRY}/transit_api:${VERSION}
echo " ✓ Pushed transit_api:${VERSION}"
docker push ${REGISTRY}/employee_api:${VERSION}
echo " ✓ Pushed employee_api:${VERSION}"
docker push ${REGISTRY}/admin_ui:${VERSION}
echo " ✓ Pushed admin_ui:${VERSION}"
echo ""
echo "✅ All images pushed successfully!"
echo ""
echo "Update docker-compose.yml with:"
echo " image: ${REGISTRY}/auth_api:${VERSION}"
echo " image: ${REGISTRY}/transit_api:${VERSION}"
echo " image: ${REGISTRY}/employee_api:${VERSION}"
echo " image: ${REGISTRY}/admin_ui:${VERSION}"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Quick deployment helper script
# Usage: ./scripts/deploy.sh [start|stop|restart|logs|status]
set -e
COMMAND=${1:-status}
case $COMMAND in
start)
echo "🚀 Starting XTransit services..."
docker-compose up -d
echo "✅ Services started!"
echo ""
echo "📋 Service URLs:"
echo " Admin UI: http://localhost:5174"
echo " Auth API: http://localhost:5000"
echo " Transit API: http://localhost:5001"
echo " Employee API: http://localhost:4000"
;;
stop)
echo "⏹️ Stopping XTransit services..."
docker-compose down
echo "✅ Services stopped!"
;;
restart)
echo "🔄 Restarting XTransit services..."
docker-compose restart
echo "✅ Services restarted!"
;;
logs)
SERVICE=${2:-admin_ui}
echo "📋 Logs for $SERVICE (last 100 lines):"
docker-compose logs --tail=100 -f $SERVICE
;;
status)
echo "📊 XTransit Services Status:"
docker-compose ps
;;
build)
echo "🔨 Building all services..."
docker-compose build
echo "✅ Build complete!"
;;
clean)
echo "🧹 Cleaning up containers and volumes..."
docker-compose down -v
echo "✅ Cleanup complete!"
;;
*)
echo "Usage: $0 [start|stop|restart|logs|status|build|clean]"
echo ""
echo "Commands:"
echo " start - Start all services"
echo " stop - Stop all services"
echo " restart - Restart all services"
echo " logs [svc] - View logs (optional service name)"
echo " status - Show service status"
echo " build - Build all Docker images locally"
echo " clean - Remove containers and volumes"
exit 1
;;
esac
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# XTransit Project - Initial Setup Script
# This script prepares the environment for local development or deployment
set -e
echo "🚀 XTransit Transit Project - Setup Script"
echo "=========================================="
echo ""
# Check prerequisites
echo "📋 Checking prerequisites..."
if ! command -v docker &> /dev/null; then
echo "❌ Docker is not installed. Please install Docker."
exit 1
fi
echo "✅ Docker found: $(docker --version)"
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose is not installed. Please install Docker Compose."
exit 1
fi
echo "✅ Docker Compose found: $(docker-compose --version)"
echo ""
echo "📝 Setting up configuration..."
# Create .env if it doesn't exist
if [ -f .env ]; then
echo "⚠️ .env file already exists, skipping creation"
else
echo "Creating .env from .env.example..."
cp .env.example .env
echo "✅ .env created. Please edit it with your database credentials."
echo ""
echo "Required values to set:"
echo " - POSTGRES_HOST: Your PostgreSQL server hostname"
echo " - POSTGRES_PORT: PostgreSQL port (default: 5432)"
echo " - POSTGRES_DB: Database name (default: XTransit)"
echo " - POSTGRES_USER_AUTH: Auth API database user"
echo " - POSTGRES_PASSWORD_AUTH: Auth API database password"
echo " - POSTGRES_USER_TRANSIT: Transit API database user"
echo " - POSTGRES_PASSWORD_TRANSIT: Transit API database password"
echo " - POSTGRES_USER_EMPLOYEE: Employee API database user"
echo " - POSTGRES_PASSWORD_EMPLOYEE: Employee API database password"
echo " - JWT_SECRET: JWT signing secret"
echo " - ADMIN_USER_CODES: Comma-separated admin user codes"
echo ""
echo "✏️ Edit .env file now? (y/n)"
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
${EDITOR:-nano} .env
fi
fi
echo ""
echo "📦 Building Docker images..."
docker-compose build
echo ""
echo "✅ Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Review and update .env with your database credentials"
echo " 2. Start services: ./scripts/deploy.sh start"
echo " 3. Access Admin UI: http://localhost:5174"
echo ""
echo "Available commands:"
echo " ./scripts/deploy.sh start - Start all services"
echo " ./scripts/deploy.sh stop - Stop all services"
echo " ./scripts/deploy.sh logs - View logs"
echo " ./scripts/deploy.sh status - Show service status"
echo ""
echo "Documentation:"
echo " - DEPLOYMENT.md - Comprehensive deployment guide"
echo " - DOCKER_README.md - Docker specific instructions"
echo " - admin_ui/README.md - Admin UI documentation"
+39
View File
@@ -42,6 +42,7 @@ PG_USER=your_user
PG_PASS=your_password
PG_DB=your_database
JWT_SECRET=your_jwt_secret
ADMIN_USER_CODES=12345,67890
PORT=5001
```
@@ -168,6 +169,32 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
- Body: `{ "status": "in_progress" }`
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
### Admin
Alle admin-endpoints vereisen naast een geldig JWT-token ook dat je `userCode` voorkomt in `ADMIN_USER_CODES`.
**POST** `/admin/stops`
- Maak een herbruikbare stop aan
- Body:
```json
{
"name": "Central Station",
"latitude": 52.5200,
"longitude": 13.4050
}
```
**POST** `/admin/bus-lines/:busLineId/stops`
- Koppel een bestaande stop aan een buslijn
- Body:
```json
{
"stopId": 1,
"stopOrder": 1,
"estimatedArrivalMinutes": 0
}
```
## Database Schema
### bus_types
@@ -183,6 +210,18 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
- `end_stop` - End stop name
- `estimated_duration_minutes` - Expected route duration
### stops
- `id` - Primary key
- `name` - Stop name
- `latitude`, `longitude` - Stop location
### route_stops
- `id` - Primary key
- `bus_line_id` - Which bus line the stop belongs to
- `stop_id` - Reusable stop reference
- `stop_order` - Stop sequence on that bus line
- `estimated_arrival_minutes` - Optional ETA per stop
### routes
- `id` - Primary key
- `user_id` - Chauffeur (references workers.users)
+2
View File
@@ -6,6 +6,8 @@ const pool = new Pool({
user: process.env.PG_USER,
password: process.env.PG_PASS,
database: process.env.PG_DB,
// Ensure unqualified table names resolve to transit schema first.
options: '-c search_path=transit,public',
max: 10,
idleTimeoutMillis: 30000
});
+403 -10
View File
@@ -9,6 +9,37 @@ const app = express();
app.use(express.json());
app.use(cors());
// Development mock storage when DEV_MOCK=1
const DEV_MOCK = process.env.DEV_MOCK === '1';
const _mock = {
stops: [],
routeStops: [],
busLines: [],
fleetBuses: [],
nextStopId: 1,
nextRouteStopId: 1,
nextBusLineId: 1,
nextFleetBusId: 1,
};
const adminUserCodes = new Set(
(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(code => code.trim())
.filter(Boolean)
.map(code => Number(code))
.filter(code => Number.isFinite(code))
);
const adminMiddleware = (req, res, next) => {
const userCode = Number(req.user?.userCode);
if (!Number.isFinite(userCode) || !adminUserCodes.has(userCode)) {
return res.status(403).json({ error: 'forbidden' });
}
next();
};
// ============================================
// ROUTES - Get daily schedule
// ============================================
@@ -141,18 +172,33 @@ app.get('/routes/:routeId', authMiddleware, async (req, res) => {
app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
try {
const { busLineId } = req.params;
if (DEV_MOCK) {
const stops = _mock.routeStops
.filter(rs => String(rs.busLineId) === String(busLineId))
.sort((a,b) => a.stopOrder - b.stopOrder)
.map(rs => ({
id: rs.id,
order: rs.stopOrder,
name: rs.name,
latitude: rs.latitude,
longitude: rs.longitude,
estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
}));
return res.json({ stops });
}
const result = await db.query(`
SELECT
id,
stop_order,
stop_name,
latitude,
longitude,
estimated_arrival_minutes
FROM route_stops
WHERE bus_line_id = $1
ORDER BY stop_order ASC
SELECT
rs.id,
rs.stop_order,
s.name as stop_name,
s.latitude,
s.longitude,
rs.estimated_arrival_minutes
FROM transit.route_stops rs
JOIN transit.stops s ON s.id = rs.stop_id
WHERE rs.bus_line_id = $1
ORDER BY rs.stop_order ASC
`, [busLineId]);
const stops = result.rows.map(row => ({
@@ -171,6 +217,353 @@ app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
}
});
// ============================================
// ADMIN - Create reusable stop
// ============================================
app.get('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
return res.json({ stops: _mock.stops.slice().sort((a, b) => a.id - b.id) });
}
const result = await db.query(`
SELECT id, name, latitude, longitude, created_at, updated_at
FROM transit.stops
ORDER BY id ASC
`);
res.json({
stops: result.rows.map(row => ({
id: row.id,
name: row.name,
latitude: parseFloat(row.latitude),
longitude: parseFloat(row.longitude),
createdAt: row.created_at,
updatedAt: row.updated_at,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.post('/admin/stops', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { name, latitude, longitude } = req.body;
if (!name || latitude === undefined || longitude === undefined) {
return res.status(400).json({ error: 'missing_required_fields' });
}
if (DEV_MOCK) {
const stop = {
id: _mock.nextStopId++,
name,
latitude: parseFloat(latitude),
longitude: parseFloat(longitude),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
_mock.stops.push(stop);
return res.status(201).json({ stop });
}
const result = await db.query(`
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.stops
)
INSERT INTO transit.stops (id, name, latitude, longitude)
SELECT next_id.id, $1, $2, $3
FROM next_id
RETURNING id, name, latitude, longitude, created_at, updated_at
`, [name, latitude, longitude]);
const stop = result.rows[0];
res.status(201).json({
stop: {
id: stop.id,
name: stop.name,
latitude: parseFloat(stop.latitude),
longitude: parseFloat(stop.longitude),
createdAt: stop.created_at,
updatedAt: stop.updated_at,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// ADMIN - Manage bus lines
// ============================================
app.get('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
return res.json({ busLines: _mock.busLines.slice().sort((a, b) => a.lineNumber - b.lineNumber) });
}
const result = await db.query(`
SELECT id, line_number, start_stop, end_stop, estimated_duration_minutes, description
FROM transit.bus_lines
ORDER BY line_number ASC, id ASC
`);
res.json({
busLines: result.rows.map(row => ({
id: row.id,
lineNumber: row.line_number,
startStop: row.start_stop,
endStop: row.end_stop,
estimatedDurationMinutes: row.estimated_duration_minutes,
description: row.description,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.post('/admin/bus-lines', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body;
if (lineNumber === undefined || !startStop || !endStop || estimatedDurationMinutes === undefined) {
return res.status(400).json({ error: 'missing_required_fields' });
}
if (DEV_MOCK) {
const busLine = {
id: _mock.nextBusLineId++,
lineNumber: Number(lineNumber),
startStop,
endStop,
estimatedDurationMinutes: Number(estimatedDurationMinutes),
description: description || '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
_mock.busLines.push(busLine);
return res.status(201).json({ busLine });
}
const result = await db.query(`
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.bus_lines
)
INSERT INTO transit.bus_lines (id, line_number, start_stop, end_stop, estimated_duration_minutes, description)
SELECT next_id.id, $1, $2, $3, $4, $5
FROM next_id
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
`, [lineNumber, startStop, endStop, estimatedDurationMinutes, description || null]);
const busLine = result.rows[0];
res.status(201).json({
busLine: {
id: busLine.id,
lineNumber: busLine.line_number,
startStop: busLine.start_stop,
endStop: busLine.end_stop,
estimatedDurationMinutes: busLine.estimated_duration_minutes,
description: busLine.description,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { busLineId } = req.params;
const { lineNumber, startStop, endStop, estimatedDurationMinutes, description } = req.body;
if (DEV_MOCK) {
const existing = _mock.busLines.find(line => String(line.id) === String(busLineId));
if (!existing) return res.status(404).json({ error: 'bus_line_not_found' });
if (lineNumber !== undefined) existing.lineNumber = Number(lineNumber);
if (startStop !== undefined) existing.startStop = startStop;
if (endStop !== undefined) existing.endStop = endStop;
if (estimatedDurationMinutes !== undefined) existing.estimatedDurationMinutes = Number(estimatedDurationMinutes);
if (description !== undefined) existing.description = description;
existing.updatedAt = new Date().toISOString();
return res.json({ busLine: existing });
}
const result = await db.query(`
UPDATE transit.bus_lines
SET
line_number = COALESCE($1, line_number),
start_stop = COALESCE($2, start_stop),
end_stop = COALESCE($3, end_stop),
estimated_duration_minutes = COALESCE($4, estimated_duration_minutes),
description = COALESCE($5, description)
WHERE id = $6
RETURNING id, line_number, start_stop, end_stop, estimated_duration_minutes, description
`, [lineNumber ?? null, startStop ?? null, endStop ?? null, estimatedDurationMinutes ?? null, description ?? null, busLineId]);
if (!result.rows[0]) return res.status(404).json({ error: 'bus_line_not_found' });
const busLine = result.rows[0];
res.json({
busLine: {
id: busLine.id,
lineNumber: busLine.line_number,
startStop: busLine.start_stop,
endStop: busLine.end_stop,
estimatedDurationMinutes: busLine.estimated_duration_minutes,
description: busLine.description,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// ADMIN - Manage fleet buses
// ============================================
app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (DEV_MOCK) {
return res.json({ buses: _mock.fleetBuses.slice().sort((a, b) => a.id - b.id) });
}
const result = await db.query(`
SELECT id, name, seat_capacity, license_plate
FROM fleet.bus
ORDER BY id ASC
`);
res.json({
buses: result.rows.map(row => ({
id: row.id,
name: row.name,
seatCapacity: row.seat_capacity,
licensePlate: row.license_plate,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { name, seatCapacity, licensePlate } = req.body;
if (!name || seatCapacity === undefined || !licensePlate) {
return res.status(400).json({ error: 'missing_required_fields' });
}
if (DEV_MOCK) {
const bus = {
id: _mock.nextFleetBusId++,
name,
seatCapacity: Number(seatCapacity),
licensePlate,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
_mock.fleetBuses.push(bus);
return res.status(201).json({ bus });
}
const result = await db.query(`
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM fleet.bus
)
INSERT INTO fleet.bus (id, name, seat_capacity, license_plate)
SELECT next_id.id, $1, $2, $3
FROM next_id
RETURNING id, name, seat_capacity, license_plate
`, [name, seatCapacity, licensePlate]);
const bus = result.rows[0];
res.status(201).json({
bus: {
id: bus.id,
name: bus.name,
seatCapacity: bus.seat_capacity,
licensePlate: bus.license_plate,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// ADMIN - Link stop to bus line
// ============================================
app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { busLineId } = req.params;
const { stopId, stopOrder, estimatedArrivalMinutes } = req.body;
if (!stopId || stopOrder === undefined) {
return res.status(400).json({ error: 'missing_required_fields' });
}
if (DEV_MOCK) {
const stop = _mock.stops.find(s => String(s.id) === String(stopId));
const routeStop = {
id: _mock.nextRouteStopId++,
busLineId: Number(busLineId),
stopId: Number(stopId),
stopOrder: Number(stopOrder),
estimatedArrivalMinutes: estimatedArrivalMinutes ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
name: stop ? stop.name : `stop-${stopId}`,
latitude: stop ? stop.latitude : 0,
longitude: stop ? stop.longitude : 0,
};
// remove existing with same busLineId & stopOrder
_mock.routeStops = _mock.routeStops.filter(rs => !(rs.busLineId === routeStop.busLineId && rs.stopOrder === routeStop.stopOrder));
_mock.routeStops.push(routeStop);
return res.status(201).json({ routeStop });
}
const result = await db.query(`
WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.route_stops
)
INSERT INTO transit.route_stops (id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes)
SELECT next_id.id, $1, $2, $3, $4
FROM next_id
ON CONFLICT (bus_line_id, stop_order) DO UPDATE SET
stop_id = EXCLUDED.stop_id,
estimated_arrival_minutes = EXCLUDED.estimated_arrival_minutes,
updated_at = NOW()
RETURNING id, bus_line_id, stop_id, stop_order, estimated_arrival_minutes, created_at, updated_at
`, [busLineId, stopId, stopOrder, estimatedArrivalMinutes ?? null]);
const link = result.rows[0];
res.status(201).json({
routeStop: {
id: link.id,
busLineId: link.bus_line_id,
stopId: link.stop_id,
stopOrder: link.stop_order,
estimatedArrivalMinutes: link.estimated_arrival_minutes,
createdAt: link.created_at,
updatedAt: link.updated_at,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'internal_error', details: err.message });
}
});
// ============================================
// NAVIGATION - Save calculated route
// ============================================