@@ -10,3 +10,7 @@ build
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
ADMIN_DEPLOYMENT_SUMMARY.md
|
||||
CI_CD_PORTAINER_GUIDE.md
|
||||
DEPLOYMENT.md
|
||||
GETTING_STARTED.md
|
||||
+17
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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;"]
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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;'
|
||||
@@ -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>
|
||||
Generated
+1992
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
window.__ENV__ = {
|
||||
VITE_AUTH_API: 'http://localhost:5000',
|
||||
VITE_TRANSIT_API: 'http://localhost:5001'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
@@ -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}`));
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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
|
||||
Executable
+51
@@ -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}"
|
||||
Executable
+70
@@ -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
|
||||
Executable
+78
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user