@@ -10,3 +10,7 @@ build
|
|||||||
coverage
|
coverage
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.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
|
# 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
|
## Bestanden
|
||||||
|
|
||||||
- **auth_api/Dockerfile** - Image voor Authentication API (port 5000)
|
- **auth_api/Dockerfile** - Image voor Authentication API (port 5000)
|
||||||
- **employee_api/Dockerfile** - Image voor Employee API (port 4000)
|
- **employee_api/Dockerfile** - Image voor Employee API (port 4000)
|
||||||
- **transit_api/Dockerfile** - Image voor Transit API (port 5001)
|
- **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
|
- **docker-compose.yml** - Orchestratie van alle services
|
||||||
- **.dockerignore** - Bestanden die niet in Docker images opgenomen worden
|
- **.dockerignore** - Bestanden die niet in Docker images opgenomen worden
|
||||||
- **.env.example** - Template voor environment variabelen
|
- **.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
|
## Vereisten
|
||||||
|
|
||||||
@@ -28,11 +32,14 @@ cp .env.example .env
|
|||||||
### 2. Services starten
|
### 2. Services starten
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start alle services (database + alle APIs)
|
# Start alle services (APIs + Admin UI)
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
# Of build en start tegelijk (als je code hebt aangepast)
|
# Of build en start tegelijk (als je code hebt aangepast)
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Quick start met script
|
||||||
|
./scripts/deploy.sh start
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Logs bekijken
|
### 3. Logs bekijken
|
||||||
@@ -43,23 +50,37 @@ docker-compose logs -f
|
|||||||
|
|
||||||
# Logs van specifieke service
|
# Logs van specifieke service
|
||||||
docker-compose logs -f auth_api
|
docker-compose logs -f auth_api
|
||||||
docker-compose logs -f employee_api
|
|
||||||
docker-compose logs -f transit_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
|
## Beschikbare Services
|
||||||
|
|
||||||
| Service | Port | Database | Beschrijving |
|
| Service | Port | Beschrijving |
|
||||||
|---------|------|----------|--------------|
|
|---------|------|--------------|
|
||||||
| auth_api | 5000 | PostgreSQL | Authentication endpoints |
|
| admin_ui | 5174 | Admin Control Center (React + Nginx) |
|
||||||
| employee_api | 4000 | PostgreSQL | Employee management endpoints |
|
| auth_api | 5000 | Authentication endpoints |
|
||||||
| transit_api | 5001 | PostgreSQL | Transit schedule endpoints |
|
| employee_api | 4000 | Employee management endpoints |
|
||||||
| db | 5432 | - | PostgreSQL database server |
|
| transit_api | 5001 | Transit management endpoints |
|
||||||
|
|
||||||
## Commando's
|
## 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
|
```bash
|
||||||
# Start services
|
# Start services
|
||||||
@@ -80,6 +101,7 @@ docker-compose down -v
|
|||||||
```bash
|
```bash
|
||||||
# Herstart specifieke service
|
# Herstart specifieke service
|
||||||
docker-compose restart auth_api
|
docker-compose restart auth_api
|
||||||
|
docker-compose restart admin_ui
|
||||||
|
|
||||||
# Build specifieke image
|
# Build specifieke image
|
||||||
docker-compose build auth_api
|
docker-compose build auth_api
|
||||||
@@ -88,17 +110,17 @@ docker-compose build auth_api
|
|||||||
docker-compose logs -f transit_api
|
docker-compose logs -f transit_api
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database commando's
|
### Admin UI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verbinding met database
|
# Bekijk Admin UI
|
||||||
docker-compose exec db psql -U transit_user -d transit_db
|
http://localhost:5174
|
||||||
|
|
||||||
# Database backup
|
# Logs
|
||||||
docker-compose exec db pg_dump -U transit_user transit_db > backup.sql
|
docker-compose logs -f admin_ui
|
||||||
|
|
||||||
# Database restore
|
# Shell in container
|
||||||
docker-compose exec -T db psql -U transit_user transit_db < backup.sql
|
docker-compose exec admin_ui sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -129,6 +151,54 @@ docker-compose logs auth_api
|
|||||||
# Controleer database connection string
|
# 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
|
## Development
|
||||||
|
|
||||||
### Wijzigingen doorgevoerd in code
|
### 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();
|
const app = express();
|
||||||
app.use(express.json());
|
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;
|
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;
|
const PORT = process.env.PORT || 5000;
|
||||||
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
|
app.listen(PORT, () => console.log(`Auth API listening on ${PORT}`));
|
||||||
|
|
||||||
|
|||||||
@@ -56,18 +56,65 @@ class RouteLeg {
|
|||||||
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
|
String getDistanceKm() => (distance / 1000).toStringAsFixed(1);
|
||||||
String getDurationMinutes() => (duration ~/ 60).toString();
|
String getDurationMinutes() => (duration ~/ 60).toString();
|
||||||
|
|
||||||
factory RouteLeg.fromJson(Map<String, dynamic> json) {
|
factory RouteLeg.fromCoordinates(
|
||||||
final coords = json['geometry']['coordinates'] as List;
|
List<dynamic> coordinates, {
|
||||||
|
double distance = 0.0,
|
||||||
|
int duration = 0,
|
||||||
|
String summary = 'Route',
|
||||||
|
}) {
|
||||||
return RouteLeg(
|
return RouteLeg(
|
||||||
points: coords
|
points: coordinates
|
||||||
.map((coord) => NavigationPoint(
|
.map((coord) {
|
||||||
latitude: coord[1] as double,
|
if (coord is List && coord.length >= 2) {
|
||||||
longitude: coord[0] as double,
|
return NavigationPoint(
|
||||||
))
|
latitude: (coord[1] as num).toDouble(),
|
||||||
|
longitude: (coord[0] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.whereType<NavigationPoint>()
|
||||||
.toList(),
|
.toList(),
|
||||||
distance: (json['distance'] as num).toDouble(),
|
distance: distance,
|
||||||
duration: (json['duration'] as num).toInt(),
|
duration: duration,
|
||||||
summary: json['summary'] as String? ?? 'Route',
|
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) {
|
factory NavigationRoute.fromJson(Map<String, dynamic> json) {
|
||||||
final routes = json['routes'] as List;
|
final routes = json['routes'] as List?;
|
||||||
if (routes.isEmpty) {
|
if (routes == null || routes.isEmpty) {
|
||||||
throw Exception('No route found');
|
throw Exception('No route found in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
final routeData = routes[0] as Map<String, dynamic>;
|
final routeData = routes[0] as Map<String, dynamic>?;
|
||||||
final legs = (routeData['legs'] as List)
|
if (routeData == null) {
|
||||||
.map((leg) => RouteLeg.fromJson(leg as Map<String, dynamic>))
|
throw Exception('Invalid route data');
|
||||||
.toList();
|
}
|
||||||
|
|
||||||
|
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(
|
return NavigationRoute(
|
||||||
start: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
|
start: start,
|
||||||
end: NavigationPoint(latitude: 0, longitude: 0), // TODO: Set from request
|
end: end,
|
||||||
legs: legs,
|
legs: [leg],
|
||||||
totalDistance: (routeData['distance'] as num).toDouble(),
|
totalDistance: distance,
|
||||||
totalDuration: (routeData['duration'] as num).toInt(),
|
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 {
|
Future<void> _selectDate() async {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final picked = await showDatePicker(
|
final picked = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _selectedDate,
|
initialDate: _selectedDate,
|
||||||
@@ -36,7 +37,7 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Theme(
|
return Theme(
|
||||||
data: Theme.of(context).copyWith(
|
data: Theme.of(context).copyWith(
|
||||||
dialogBackgroundColor: Colors.white,
|
dialogBackgroundColor: colorScheme.surface,
|
||||||
),
|
),
|
||||||
child: child!,
|
child: child!,
|
||||||
);
|
);
|
||||||
@@ -53,236 +54,210 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final dateStr =
|
final dateStr =
|
||||||
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
|
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
|
||||||
final isToday = DateTime(
|
final today = DateTime.now();
|
||||||
_selectedDate.year,
|
final isToday = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day) ==
|
||||||
_selectedDate.month,
|
DateTime(today.year, today.month, today.day);
|
||||||
_selectedDate.day,
|
|
||||||
) ==
|
|
||||||
DateTime(
|
|
||||||
DateTime.now().year,
|
|
||||||
DateTime.now().month,
|
|
||||||
DateTime.now().day,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Your Schedule'),
|
title: const Text('Today on the Road'),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Container(
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
// Date selector
|
gradient: LinearGradient(
|
||||||
Container(
|
colors: Theme.of(context).brightness == Brightness.dark
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
? const [Color(0xFF0B1220), Color(0xFF111827)]
|
||||||
child: Row(
|
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
begin: Alignment.topCenter,
|
||||||
children: [
|
end: Alignment.bottomCenter,
|
||||||
Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
children: [
|
||||||
isToday ? 'Today' : 'Scheduled Date',
|
Container(
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
color: Colors.grey[600],
|
padding: const EdgeInsets.all(18),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
),
|
gradient: const LinearGradient(
|
||||||
Text(
|
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||||
dateStr,
|
begin: Alignment.topLeft,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
end: Alignment.bottomRight,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
borderRadius: BorderRadius.circular(24),
|
||||||
onPressed: _selectDate,
|
boxShadow: [
|
||||||
icon: const Icon(Icons.calendar_today),
|
BoxShadow(
|
||||||
label: const Text('Change'),
|
color: Colors.black.withOpacity(0.16),
|
||||||
style: OutlinedButton.styleFrom(
|
blurRadius: 22,
|
||||||
padding: const EdgeInsets.symmetric(
|
offset: const Offset(0, 10),
|
||||||
horizontal: 16,
|
),
|
||||||
vertical: 12,
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
const Divider(),
|
child: FutureBuilder<DailySchedule>(
|
||||||
|
future: _scheduleFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
// Routes list
|
if (snapshot.hasError) {
|
||||||
Expanded(
|
return Center(
|
||||||
child: FutureBuilder<DailySchedule>(
|
child: Padding(
|
||||||
future: _scheduleFuture,
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
builder: (context, snapshot) {
|
child: Column(
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
return const Center(
|
children: [
|
||||||
child: CircularProgressIndicator(),
|
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) {
|
if (!snapshot.hasData || snapshot.data!.routes.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
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: [
|
children: [
|
||||||
Icon(
|
if (inProgressRoutes.isNotEmpty) ...[
|
||||||
Icons.error_outline,
|
_sectionHeader('In Progress', colorScheme.tertiary),
|
||||||
size: 64,
|
...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||||
color: Colors.red[300],
|
],
|
||||||
),
|
if (scheduledRoutes.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
_sectionHeader('Scheduled', colorScheme.primary),
|
||||||
Text(
|
...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||||
'Failed to load schedule',
|
],
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
if (completedRoutes.isNotEmpty) ...[
|
||||||
),
|
_sectionHeader('Completed', colorScheme.secondary),
|
||||||
|
...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})),
|
||||||
|
],
|
||||||
const SizedBox(height: 8),
|
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,
|
Widget _sectionHeader(String title, Color color) {
|
||||||
color: Colors.grey[300],
|
return Padding(
|
||||||
),
|
padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8),
|
||||||
const SizedBox(height: 16),
|
child: Text(
|
||||||
Text(
|
title,
|
||||||
'No routes scheduled',
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
color: color,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
throw Exception('User code must be a number');
|
throw Exception('User code must be a number');
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _authService.login(
|
await _authService.login(
|
||||||
userCode: parsedUserCode,
|
userCode: parsedUserCode,
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
);
|
);
|
||||||
@@ -85,105 +85,214 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
body: Container(
|
||||||
title: const Text('Bus Terminal Login'),
|
decoration: BoxDecoration(
|
||||||
elevation: 0,
|
gradient: LinearGradient(
|
||||||
),
|
colors: Theme.of(context).brightness == Brightness.dark
|
||||||
body: SingleChildScrollView(
|
? const [Color(0xFF0B1220), Color(0xFF111827), Color(0xFF1D4ED8)]
|
||||||
child: Padding(
|
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0), Color(0xFFBAE6FD)],
|
||||||
padding: const EdgeInsets.all(24.0),
|
begin: Alignment.topLeft,
|
||||||
child: Form(
|
end: Alignment.bottomRight,
|
||||||
key: _formKey,
|
),
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: SafeArea(
|
||||||
children: [
|
child: SingleChildScrollView(
|
||||||
const SizedBox(height: 32),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
// Title
|
child: Form(
|
||||||
Text(
|
key: _formKey,
|
||||||
'Driver Login',
|
child: Column(
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
),
|
Container(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.all(20),
|
||||||
Text(
|
decoration: BoxDecoration(
|
||||||
'Enter your credentials to continue',
|
gradient: const LinearGradient(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
|
||||||
color: Colors.grey[600],
|
begin: Alignment.topLeft,
|
||||||
),
|
end: Alignment.bottomRight,
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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:convert';
|
||||||
import 'dart:ffi';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
|||||||
@@ -47,21 +47,25 @@ class NavigationService {
|
|||||||
final response = await http.get(Uri.parse(url));
|
final response = await http.get(Uri.parse(url));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
if (data['code'] != 'Ok') {
|
throw Exception('Empty response from routing service');
|
||||||
throw Exception('Route not found: ${data['message']}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final route = NavigationRoute.fromJson(data);
|
if (data['code'] != 'Ok') {
|
||||||
|
throw Exception('Route not found: ${data['message'] ?? 'Unknown error'}');
|
||||||
|
}
|
||||||
|
|
||||||
// Set start and end points properly
|
final routes = data['routes'] as List?;
|
||||||
return NavigationRoute(
|
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,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
legs: route.legs,
|
|
||||||
totalDistance: route.totalDistance,
|
|
||||||
totalDuration: route.totalDuration,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Failed to get route: ${response.statusCode}');
|
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)
|
/// Get multiple routes (alternatives)
|
||||||
Future<List<NavigationRoute>> getMultipleRoutes({
|
Future<List<NavigationRoute>> getMultipleRoutes({
|
||||||
required NavigationPoint start,
|
required NavigationPoint start,
|
||||||
@@ -86,26 +228,30 @@ class NavigationService {
|
|||||||
final response = await http.get(Uri.parse(url));
|
final response = await http.get(Uri.parse(url));
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
final data = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
|
if (data == null) {
|
||||||
if (data['code'] != 'Ok') {
|
throw Exception('Empty response from routing service');
|
||||||
throw Exception('Routes not found: ${data['message']}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
.map((routeData) {
|
||||||
return NavigationRoute(
|
if (routeData is! Map<String, dynamic>) return null;
|
||||||
|
return NavigationRoute.fromOsrmRouteData(
|
||||||
|
routeData,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
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();
|
.toList();
|
||||||
|
|
||||||
return routes;
|
return routes;
|
||||||
@@ -125,4 +271,37 @@ class NavigationService {
|
|||||||
LatLng(p2.latitude, p2.longitude),
|
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 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../models/schedule.dart';
|
import '../models/schedule.dart';
|
||||||
|
import '../models/navigation.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
||||||
/// Schedule/Route Service
|
/// Schedule/Route Service
|
||||||
@@ -82,4 +83,42 @@ class ScheduleService {
|
|||||||
throw Exception('Status update error: $e');
|
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
|
/// Material 3 Theme configuratie voor bus_terminal
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// TODO: Implement Material 3 theme for bus drivers
|
static const Color _navy = Color(0xFF0F172A);
|
||||||
// - Large touch targets
|
static const Color _surface = Color(0xFFF8FAFC);
|
||||||
// - High contrast
|
static const Color _surfaceDark = Color(0xFF111827);
|
||||||
// - Clear visual hierarchy
|
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() {
|
static ThemeData lightTheme() {
|
||||||
|
final colorScheme = _buildLightScheme();
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
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() {
|
static ThemeData darkTheme() {
|
||||||
|
final colorScheme = _buildDarkScheme();
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -54,13 +56,30 @@ class _CustomInputFieldState extends State<CustomInputField> {
|
|||||||
validator: widget.validator,
|
validator: widget.validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: widget.hint,
|
hintText: widget.hint,
|
||||||
hintStyle: Theme.of(context).textTheme.bodyMedium,
|
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 16, // Larger vertical padding for touch targets
|
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(
|
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
|
suffixIcon: widget.obscureText
|
||||||
? GestureDetector(
|
? GestureDetector(
|
||||||
@@ -70,6 +89,7 @@ class _CustomInputFieldState extends State<CustomInputField> {
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
_obscureText ? Icons.visibility_off : Icons.visibility,
|
_obscureText ? Icons.visibility_off : Icons.visibility,
|
||||||
size: 28, // Larger icon for accessibility
|
size: 28, // Larger icon for accessibility
|
||||||
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class RouteCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final statusColor = _getStatusColor(route.status);
|
final statusColor = _getStatusColor(route.status);
|
||||||
final startTimeStr = _formatTime(route.startTime);
|
final startTimeStr = _formatTime(route.startTime);
|
||||||
|
|
||||||
@@ -63,6 +64,9 @@ class RouteCard extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
elevation: 0,
|
||||||
|
color: colorScheme.surface,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -77,7 +81,11 @@ class RouteCard extends StatelessWidget {
|
|||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue,
|
gradient: LinearGradient(
|
||||||
|
colors: [colorScheme.primary, colorScheme.tertiary],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -94,7 +102,7 @@ class RouteCard extends StatelessWidget {
|
|||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withOpacity(0.2),
|
color: statusColor.withOpacity(0.14),
|
||||||
border: Border.all(color: statusColor),
|
border: Border.all(color: statusColor),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
@@ -134,7 +142,7 @@ class RouteCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
Icons.arrow_forward,
|
Icons.arrow_forward,
|
||||||
color: Colors.grey[400],
|
color: colorScheme.outline,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -181,7 +189,7 @@ class RouteCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
route.busType.licensePlate,
|
route.busType.licensePlate,
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
color: Colors.grey[600],
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -199,14 +207,14 @@ class RouteCard extends StatelessWidget {
|
|||||||
style:
|
style:
|
||||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.blue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${route.busLine.estimatedDuration} min duration',
|
'${route.busLine.estimatedDuration} min duration',
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
color: Colors.grey[600],
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
PG_DB: ${POSTGRES_DB}
|
PG_DB: ${POSTGRES_DB}
|
||||||
PG_USER: ${POSTGRES_USER_AUTH}
|
PG_USER: ${POSTGRES_USER_AUTH}
|
||||||
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
|
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
|
||||||
|
DEV_BYPASS: ${DEV_BYPASS:-0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
employee_api:
|
employee_api:
|
||||||
@@ -43,4 +44,19 @@ services:
|
|||||||
PG_DB: ${POSTGRES_DB}
|
PG_DB: ${POSTGRES_DB}
|
||||||
PG_USER: ${POSTGRES_USER_TRANSIT}
|
PG_USER: ${POSTGRES_USER_TRANSIT}
|
||||||
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
|
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
|
||||||
|
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
|
||||||
|
DEV_MOCK: ${DEV_MOCK:-0}
|
||||||
|
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
|
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_PASS=your_password
|
||||||
PG_DB=your_database
|
PG_DB=your_database
|
||||||
JWT_SECRET=your_jwt_secret
|
JWT_SECRET=your_jwt_secret
|
||||||
|
ADMIN_USER_CODES=12345,67890
|
||||||
PORT=5001
|
PORT=5001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,6 +169,32 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
|
|||||||
- Body: `{ "status": "in_progress" }`
|
- Body: `{ "status": "in_progress" }`
|
||||||
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
|
- 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
|
## Database Schema
|
||||||
|
|
||||||
### bus_types
|
### bus_types
|
||||||
@@ -183,6 +210,18 @@ Alle endpoints vereisen een `Authorization: Bearer {token}` header.
|
|||||||
- `end_stop` - End stop name
|
- `end_stop` - End stop name
|
||||||
- `estimated_duration_minutes` - Expected route duration
|
- `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
|
### routes
|
||||||
- `id` - Primary key
|
- `id` - Primary key
|
||||||
- `user_id` - Chauffeur (references workers.users)
|
- `user_id` - Chauffeur (references workers.users)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const pool = new Pool({
|
|||||||
user: process.env.PG_USER,
|
user: process.env.PG_USER,
|
||||||
password: process.env.PG_PASS,
|
password: process.env.PG_PASS,
|
||||||
database: process.env.PG_DB,
|
database: process.env.PG_DB,
|
||||||
|
// Ensure unqualified table names resolve to transit schema first.
|
||||||
|
options: '-c search_path=transit,public',
|
||||||
max: 10,
|
max: 10,
|
||||||
idleTimeoutMillis: 30000
|
idleTimeoutMillis: 30000
|
||||||
});
|
});
|
||||||
|
|||||||
+402
-9
@@ -9,6 +9,37 @@ const app = express();
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(cors());
|
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
|
// 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) => {
|
app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { busLineId } = req.params;
|
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(`
|
const result = await db.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
rs.id,
|
||||||
stop_order,
|
rs.stop_order,
|
||||||
stop_name,
|
s.name as stop_name,
|
||||||
latitude,
|
s.latitude,
|
||||||
longitude,
|
s.longitude,
|
||||||
estimated_arrival_minutes
|
rs.estimated_arrival_minutes
|
||||||
FROM route_stops
|
FROM transit.route_stops rs
|
||||||
WHERE bus_line_id = $1
|
JOIN transit.stops s ON s.id = rs.stop_id
|
||||||
ORDER BY stop_order ASC
|
WHERE rs.bus_line_id = $1
|
||||||
|
ORDER BY rs.stop_order ASC
|
||||||
`, [busLineId]);
|
`, [busLineId]);
|
||||||
|
|
||||||
const stops = result.rows.map(row => ({
|
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
|
// NAVIGATION - Save calculated route
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user