+23
-1
@@ -10,7 +10,29 @@ build
|
|||||||
coverage
|
coverage
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
|
# Sensitive documentation - NEVER include in Docker builds
|
||||||
ADMIN_DEPLOYMENT_SUMMARY.md
|
ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
CI_CD_PORTAINER_GUIDE.md
|
CI_CD_PORTAINER_GUIDE.md
|
||||||
DEPLOYMENT.md
|
DEPLOYMENT.md
|
||||||
GETTING_STARTED.md
|
GETTING_STARTED.md
|
||||||
|
INTERNAL_*.md
|
||||||
|
SETUP_*.md
|
||||||
|
DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
.instructions.md
|
||||||
|
.prompt.md
|
||||||
|
.agent.md
|
||||||
|
SKILL.md
|
||||||
|
copilot-instructions.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts
|
||||||
|
scripts/private/
|
||||||
|
scripts/*.local.*
|
||||||
|
|
||||||
|
# Local files
|
||||||
|
*.local
|
||||||
|
.local
|
||||||
|
.vscode-workspace
|
||||||
+18
-1
@@ -10,8 +10,25 @@ admin_ui/dist/
|
|||||||
ADMIN_DEPLOYMENT_SUMMARY.md
|
ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
CI_CD_PORTAINER_GUIDE.md
|
CI_CD_PORTAINER_GUIDE.md
|
||||||
DEPLOYMENT.md
|
DEPLOYMENT.md
|
||||||
|
GETTING_STARTED.md
|
||||||
|
INTERNAL_*.md
|
||||||
|
SETUP_*.md
|
||||||
|
DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
.instructions.md
|
||||||
|
.prompt.md
|
||||||
|
.agent.md
|
||||||
|
SKILL.md
|
||||||
|
copilot-instructions.md
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts
|
||||||
|
scripts/private/
|
||||||
|
scripts/*.local.*
|
||||||
|
scripts/.env
|
||||||
|
|
||||||
# Runtime files
|
# Runtime files
|
||||||
*.log
|
*.log
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
\
|
.DS_Store
|
||||||
@@ -12,6 +12,24 @@ Belangrijk: deze README bevat geen wachtwoorden of andere gevoelige gegevens. Sl
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security & Private Files
|
||||||
|
|
||||||
|
**⚠️ Important:** This repository excludes private documentation and setup instructions from Git and Docker builds. See [SECURITY_PRIVATE_FILES.md](SECURITY_PRIVATE_FILES.md) for details on:
|
||||||
|
|
||||||
|
- What files are kept private and why
|
||||||
|
- How `.gitignore` and `.dockerignore` protect sensitive information
|
||||||
|
- Best practices for handling deployment documentation
|
||||||
|
- Guidelines for adding new private files
|
||||||
|
|
||||||
|
**Private files are excluded** (not in git/docker):
|
||||||
|
- `GETTING_STARTED.md` - Internal setup guide
|
||||||
|
- `ADMIN_DEPLOYMENT_SUMMARY.md` - Deployment status
|
||||||
|
- `CI_CD_PORTAINER_GUIDE.md` - Infrastructure docs
|
||||||
|
- `DEPLOYMENT.md` - Deployment procedures
|
||||||
|
- Custom instruction files (`.instructions.md`, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overzicht van inhoud
|
## Overzicht van inhoud
|
||||||
|
|
||||||
- `auth_api/` — source voor auth service
|
- `auth_api/` — source voor auth service
|
||||||
@@ -233,6 +251,116 @@ Maar voor je production/Portainer setup volg je je bestaande beheersbeleid en ge
|
|||||||
- CI workflow: `.github/workflows/docker-build.yml`
|
- CI workflow: `.github/workflows/docker-build.yml`
|
||||||
- Extra docs: `DOCKER_README.md`, `CI_CD_PORTAINER_GUIDE.md`
|
- Extra docs: `DOCKER_README.md`, `CI_CD_PORTAINER_GUIDE.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Control Center
|
||||||
|
|
||||||
|
The project includes a **modern React admin dashboard** for fleet and schedule management.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Fleet Management**: Create and manage buses with seat capacity and license plates
|
||||||
|
- **Transit Lines**: Define bus routes with start/end stops and duration
|
||||||
|
- **Route Management**: Link stops to bus lines with drag-and-drop reordering
|
||||||
|
- **Schedule Maker**: Create schedules by assigning vehicles and crew to bus lines with time slots
|
||||||
|
- **Timetable View**: 24-hour timetable showing scheduled services
|
||||||
|
- **Delete Operations**: Remove stops, lines, and schedules with confirmation modals
|
||||||
|
|
||||||
|
### Admin UI Location
|
||||||
|
|
||||||
|
```
|
||||||
|
admin_ui/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── StopsManager.jsx # Create and manage transit stops
|
||||||
|
│ │ ├── BusLinesManager.jsx # Manage bus lines and routes
|
||||||
|
│ │ ├── FleetManager.jsx # Manage fleet vehicles
|
||||||
|
│ │ ├── LinkStop.jsx # Drag-drop route stop assignment
|
||||||
|
│ │ ├── ScheduleMaker.jsx # Create and manage schedules
|
||||||
|
│ │ ├── TimetableViewer.jsx # Public timetable (reusable)
|
||||||
|
│ │ └── Login.jsx # Admin authentication
|
||||||
|
│ ├── App.jsx # Main app component
|
||||||
|
│ ├── api.js # API client with runtime config
|
||||||
|
│ └── styles.css # Responsive UI styling
|
||||||
|
├── docker-entrypoint.sh # Runtime env config
|
||||||
|
└── Dockerfile # Multi-stage build
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building the Admin UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin_ui
|
||||||
|
npm install
|
||||||
|
npm run build # Production build
|
||||||
|
npm run dev # Development (Vite)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
The admin UI is included in `docker-compose.yml` as service `admin_ui` on port 5174.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up admin_ui
|
||||||
|
# Access at http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Schedule API
|
||||||
|
|
||||||
|
The Transit API exposes public (unauthenticated) schedule endpoints:
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `GET /schedules?date=YYYY-MM-DD&lineId=ID` — Get schedules for a date/line
|
||||||
|
- `GET /schedules/:scheduleId` — Get schedule details
|
||||||
|
- `GET /schedules/:scheduleId/stops` — Get stops on a schedule's line
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5001/schedules?date=2026-05-06
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimetableViewer Component:**
|
||||||
|
```jsx
|
||||||
|
import TimetableViewer from './components/TimetableViewer'
|
||||||
|
|
||||||
|
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schedule Maker Documentation
|
||||||
|
|
||||||
|
See [admin_ui/SCHEDULE_API.md](admin_ui/SCHEDULE_API.md) for detailed API documentation, usage examples, and UI features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Admin dashboard uses simple JWT-based authentication:
|
||||||
|
|
||||||
|
1. **Login** with user code (e.g., `42`)
|
||||||
|
2. Receive JWT token from `auth_api`
|
||||||
|
3. Use token for admin operations (`/admin/*` endpoints)
|
||||||
|
|
||||||
|
Admin authorization is controlled by `ADMIN_USER_CODES` environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_USER_CODES=42,101,205 # Comma-separated admin user codes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development vs Production
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
Use `DEV_MOCK=1` to work without a database (in-memory mock storage):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Mode
|
||||||
|
|
||||||
|
Set up real PostgreSQL database and provide credentials via environment variables.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Security: Private Files Exclusion
|
||||||
|
|
||||||
|
This document explains which files are kept private and why.
|
||||||
|
|
||||||
|
## Files Excluded from Git & Docker
|
||||||
|
|
||||||
|
### Deployment Documentation (Private)
|
||||||
|
- `ADMIN_DEPLOYMENT_SUMMARY.md` - Internal deployment status and setup details
|
||||||
|
- `CI_CD_PORTAINER_GUIDE.md` - Private CI/CD and Portainer configuration
|
||||||
|
- `DEPLOYMENT.md` - Deployment procedures and configuration guidance
|
||||||
|
- `GETTING_STARTED.md` - Internal setup instructions
|
||||||
|
- `INTERNAL_*.md` - Any file prefixed with INTERNAL_ (pattern-based exclusion)
|
||||||
|
- `SETUP_*.md` - Any file prefixed with SETUP_ (pattern-based exclusion)
|
||||||
|
- `DEPLOY_*.md` - Any file prefixed with DEPLOY_ (pattern-based exclusion)
|
||||||
|
|
||||||
|
### Custom Instruction Files (Private)
|
||||||
|
- `.instructions.md` - GitHub Copilot custom instructions
|
||||||
|
- `.prompt.md` - Custom prompt templates
|
||||||
|
- `.agent.md` - Custom agent definitions
|
||||||
|
- `SKILL.md` - Custom skill definitions
|
||||||
|
- `copilot-instructions.md` - Extended Copilot instructions
|
||||||
|
- `AGENTS.md` - Agent configuration
|
||||||
|
|
||||||
|
These are kept private to prevent exposure of:
|
||||||
|
- Development workflow details
|
||||||
|
- Copilot customization strategies
|
||||||
|
- Internal coding guidelines and preferences
|
||||||
|
- Prompt engineering techniques
|
||||||
|
|
||||||
|
### Private Scripts
|
||||||
|
- `scripts/private/` - Directory for private deployment scripts
|
||||||
|
- `scripts/*.local.*` - Local-specific script configurations
|
||||||
|
|
||||||
|
## Files Included in Public Repo
|
||||||
|
|
||||||
|
### Public Documentation
|
||||||
|
- `README.md` - Main project documentation
|
||||||
|
- `DOCKER_README.md` - Docker usage documentation
|
||||||
|
- `admin_ui/README.md` - Admin UI documentation
|
||||||
|
- `admin_ui/SCHEDULE_API.md` - Public API documentation
|
||||||
|
- `transit_api/README.md` - API documentation
|
||||||
|
- `bus_terminal/README.md` - Flutter app documentation
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- `.env.example` - Environment variable template (no secrets)
|
||||||
|
- `.gitignore` - Git exclusion rules
|
||||||
|
- `.dockerignore` - Docker build exclusion rules
|
||||||
|
- `docker-compose.yml` - Service composition
|
||||||
|
- `Dockerfile` files - Container definitions
|
||||||
|
|
||||||
|
### Code & Configuration
|
||||||
|
- All source code in `auth_api/`, `transit_api/`, `employee_api/`, `admin_ui/`, `bus_terminal/`
|
||||||
|
- `package.json` files (no secrets, only dependencies)
|
||||||
|
- GitHub Actions workflows in `.github/workflows/`
|
||||||
|
|
||||||
|
## Environment Variables - NEVER Commit
|
||||||
|
|
||||||
|
Always keep the following OUT of git:
|
||||||
|
- `.env` - Local environment variables (in `.gitignore`)
|
||||||
|
- Database credentials
|
||||||
|
- JWT secrets
|
||||||
|
- API keys and tokens
|
||||||
|
- Private configuration files
|
||||||
|
|
||||||
|
Use `.env.example` as a template and create local `.env` files for development.
|
||||||
|
|
||||||
|
## Docker Build Security
|
||||||
|
|
||||||
|
All `.dockerignore` files (root and per-service) ensure that:
|
||||||
|
1. Sensitive documentation never enters Docker images
|
||||||
|
2. Private scripts and configs are excluded from builds
|
||||||
|
3. Only necessary source code and dependencies are included
|
||||||
|
4. Build artifacts don't leak internal information
|
||||||
|
|
||||||
|
## GitHub Actions Security
|
||||||
|
|
||||||
|
The CI/CD workflow in `.github/workflows/docker-build.yml`:
|
||||||
|
- Only builds from specific service directories (auth_api, transit_api, etc.)
|
||||||
|
- Respects `.dockerignore` rules
|
||||||
|
- Uses GitHub secrets for credentials (never hardcoded)
|
||||||
|
- Does not include workspace documentation in pushed images
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Before committing**: Check that `.env` is in `.gitignore` and local files are not staged
|
||||||
|
2. **Before building Docker**: Verify `.dockerignore` files exclude private docs
|
||||||
|
3. **Before pushing images**: Confirm no sensitive data is present (`docker inspect --format=...`)
|
||||||
|
4. **Deployment**: Always use environment variables or secrets managers, never embed credentials
|
||||||
|
5. **Documentation**: Keep public docs in `*.md` files, private docs in separately excluded files
|
||||||
|
|
||||||
|
## Adding New Private Files
|
||||||
|
|
||||||
|
To keep new private files out of the repo and Docker:
|
||||||
|
|
||||||
|
1. Add pattern to `.gitignore`:
|
||||||
|
```
|
||||||
|
MYFILE_*.md
|
||||||
|
new_private_thing.*
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add pattern to `.dockerignore` (root and per-service):
|
||||||
|
```
|
||||||
|
/MYFILE_*.md
|
||||||
|
/new_private_thing.*
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify with:
|
||||||
|
```bash
|
||||||
|
git check-ignore -v MYFILE_something.md
|
||||||
|
```
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Root level sensitive docs - NEVER include
|
||||||
|
/ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
|
/CI_CD_PORTAINER_GUIDE.md
|
||||||
|
/DEPLOYMENT.md
|
||||||
|
/GETTING_STARTED.md
|
||||||
|
/INTERNAL_*.md
|
||||||
|
/SETUP_*.md
|
||||||
|
/DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
/.instructions.md
|
||||||
|
/.prompt.md
|
||||||
|
/.agent.md
|
||||||
|
/SKILL.md
|
||||||
|
/copilot-instructions.md
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts and configs
|
||||||
|
/scripts/
|
||||||
|
|
||||||
|
# Node modules and logs
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
.local
|
||||||
|
|
||||||
|
# Build files
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Frontend specific
|
||||||
|
.turbo
|
||||||
|
.next
|
||||||
|
out/
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Schedule Maker & Public Timetable API
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
The Transit API now includes a comprehensive schedule management system with both admin and public endpoints.
|
||||||
|
|
||||||
|
### Admin Endpoints (requires authentication + admin privileges)
|
||||||
|
|
||||||
|
#### Get all schedules
|
||||||
|
```
|
||||||
|
GET /admin/schedules
|
||||||
|
Response: { schedules: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create a schedule
|
||||||
|
```
|
||||||
|
POST /admin/schedules
|
||||||
|
Body: {
|
||||||
|
busLineId: number,
|
||||||
|
busId: number,
|
||||||
|
driverId: number (optional),
|
||||||
|
startTime: "2026-05-06T08:00:00",
|
||||||
|
endTime: "2026-05-06T17:00:00"
|
||||||
|
}
|
||||||
|
Response: { schedule: {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Delete a schedule
|
||||||
|
```
|
||||||
|
DELETE /admin/schedules/:scheduleId
|
||||||
|
Response: { success: true, schedule: {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Public Endpoints (no authentication required)
|
||||||
|
|
||||||
|
#### Get schedules (with optional filters)
|
||||||
|
```
|
||||||
|
GET /schedules?date=2026-05-06&lineId=1
|
||||||
|
Query params:
|
||||||
|
- date: YYYY-MM-DD (optional)
|
||||||
|
- lineId: number (optional)
|
||||||
|
Response: { schedules: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get schedule detail
|
||||||
|
```
|
||||||
|
GET /schedules/:scheduleId
|
||||||
|
Response: { schedule: {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get stops for a schedule's line
|
||||||
|
```
|
||||||
|
GET /schedules/:scheduleId/stops
|
||||||
|
Response: { stops: [...] }
|
||||||
|
Shows all stops on the bus line with order, location, and ETA.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin UI Features
|
||||||
|
|
||||||
|
The ScheduleMaker component provides:
|
||||||
|
|
||||||
|
1. **Service Creation Form**
|
||||||
|
- Select bus line, vehicle, date, and time
|
||||||
|
- Optional driver ID assignment
|
||||||
|
- Submit to create schedule
|
||||||
|
- Admin-only access
|
||||||
|
|
||||||
|
2. **Timetable View**
|
||||||
|
- 24-hour time slots (hourly grouping)
|
||||||
|
- Services displayed in corresponding time slots
|
||||||
|
- Shows line number, vehicle name, and license plate
|
||||||
|
- Delete button for each service (admin only)
|
||||||
|
- Color-coded schedule cards with gradient styling
|
||||||
|
|
||||||
|
3. **Drag-and-Drop Ready**
|
||||||
|
- Schedule cards are draggable (extensible for drag-to-reorder)
|
||||||
|
- Follows existing UI/UX patterns from LinkStop component
|
||||||
|
|
||||||
|
4. **Confirmation Modals**
|
||||||
|
- Delete confirmation before removing schedules
|
||||||
|
- Shows service details in confirmation
|
||||||
|
- Prevents accidental deletions
|
||||||
|
|
||||||
|
## Public Timetable Viewer
|
||||||
|
|
||||||
|
The TimetableViewer component provides a public-facing schedule display:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import TimetableViewer from './components/TimetableViewer'
|
||||||
|
|
||||||
|
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Filter by date and bus line
|
||||||
|
- Real-time loading state
|
||||||
|
- Error handling
|
||||||
|
- Status badges (planned/active)
|
||||||
|
- Responsive table layout
|
||||||
|
- No authentication required
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
With `DEV_MOCK=1`, all schedule operations work in-memory without a database.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (Production)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE transit.schedules (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
bus_line_id BIGINT REFERENCES transit.bus_lines(id),
|
||||||
|
bus_id BIGINT REFERENCES fleet.bus(id),
|
||||||
|
driver_id INTEGER,
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
status VARCHAR(20) DEFAULT 'planned',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling & UX
|
||||||
|
|
||||||
|
The schedule manager uses:
|
||||||
|
|
||||||
|
### Admin Timetable Styling
|
||||||
|
- Responsive table with hourly time slots
|
||||||
|
- Gradient schedule cards (blue/indigo theme)
|
||||||
|
- Hover effects and smooth transitions
|
||||||
|
- Grab cursor on draggable items
|
||||||
|
- Danger buttons for delete actions (red)
|
||||||
|
- Confirmation modals with semi-transparent overlay
|
||||||
|
|
||||||
|
### Public Timetable Styling
|
||||||
|
- Clean, simple table layout
|
||||||
|
- Status badges (green for active, gray for planned)
|
||||||
|
- Mobile-responsive with scrolling
|
||||||
|
- Consistent with existing design language
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
1. **Admin Dashboard**: ScheduleMaker appears at the bottom of Admin Control Center
|
||||||
|
2. **Public API**: Available via public `/schedules` endpoints without authentication
|
||||||
|
3. **Flutter App**: TimetableViewer can be embedded or used as reference for mobile implementation
|
||||||
|
4. **Frontend Flexibility**: TimetableViewer component is reusable for any public-facing website
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Create a schedule (Admin)
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5001/admin/schedules \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"busLineId": 1,
|
||||||
|
"busId": 5,
|
||||||
|
"driverId": 101,
|
||||||
|
"startTime": "2026-05-06T08:00:00",
|
||||||
|
"endTime": "2026-05-06T17:00:00"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get today's schedules (Public)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get schedules for specific line (Public)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5001/schedules?lineId=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Bulk schedule import from CSV
|
||||||
|
- Recurring schedule templates (weekly, daily patterns)
|
||||||
|
- Driver shift assignment with time tracking
|
||||||
|
- Real-time schedule updates and notifications
|
||||||
|
- Schedule conflict detection
|
||||||
|
- Integration with GPS tracking for actual vs scheduled times
|
||||||
|
- Historical schedule analytics
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import StopsManager from './components/StopsManager'
|
|||||||
import LinkStop from './components/LinkStop'
|
import LinkStop from './components/LinkStop'
|
||||||
import BusLinesManager from './components/BusLinesManager'
|
import BusLinesManager from './components/BusLinesManager'
|
||||||
import FleetManager from './components/FleetManager'
|
import FleetManager from './components/FleetManager'
|
||||||
|
import ScheduleMaker from './components/ScheduleMaker'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||||
@@ -44,6 +45,7 @@ export default function App() {
|
|||||||
<BusLinesManager token={token} user={user} />
|
<BusLinesManager token={token} user={user} />
|
||||||
<FleetManager token={token} user={user} />
|
<FleetManager token={token} user={user} />
|
||||||
<LinkStop token={token} user={user} />
|
<LinkStop token={token} user={user} />
|
||||||
|
<ScheduleMaker token={token} user={user} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +60,19 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteBusLine = async (lineId, lineNumber) => {
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await client.delete(`/admin/bus-lines/${lineId}`)
|
||||||
|
setMessage(`Buslijn ${lineNumber} verwijderd`)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await loadBusLines()
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err.response?.data?.error || err.message
|
||||||
|
setMessage('Error: ' + errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
@@ -76,6 +90,15 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
<div>{line.startStop} → {line.endStop}</div>
|
<div>{line.startStop} → {line.endStop}</div>
|
||||||
<div>{line.estimatedDurationMinutes} min</div>
|
<div>{line.estimatedDurationMinutes} min</div>
|
||||||
{line.description ? <div className="muted">{line.description}</div> : null}
|
{line.description ? <div className="muted">{line.description}</div> : null}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => setDeleteConfirm(line)}
|
||||||
|
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -106,6 +129,43 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
) : <div>You must be admin to create bus lines.</div>}
|
) : <div>You must be admin to create bus lines.</div>}
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Delete Bus Line?</h3>
|
||||||
|
<p><strong>Lijn {deleteConfirm.lineNumber}</strong> ({deleteConfirm.startStop} → {deleteConfirm.endStop})</p>
|
||||||
|
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will also delete all stops on this line. Cannot be undone.</p>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button className="danger" onClick={() => deleteBusLine(deleteConfirm.id, deleteConfirm.lineNumber)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function LinkStop({ token, user }) {
|
|||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [stops, setStops] = useState([])
|
const [stops, setStops] = useState([])
|
||||||
const [draggedItem, setDraggedItem] = useState(null)
|
const [draggedItem, setDraggedItem] = useState(null)
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
const isAdmin = Boolean(user?.userCode)
|
const isAdmin = Boolean(user?.userCode)
|
||||||
|
|
||||||
@@ -145,6 +146,19 @@ export default function LinkStop({ token, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteRouteStop = async (routeStopId, stopName) => {
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await client.delete(`/admin/bus-lines/${busLineId}/stops/${routeStopId}`)
|
||||||
|
setMessage(`Halte "${stopName}" verwijderd uit de lijn`)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await loadStopsForLine()
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err.response?.data?.error || err.message
|
||||||
|
setMessage('Error: ' + errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
@@ -215,6 +229,9 @@ export default function LinkStop({ token, user }) {
|
|||||||
<div className="route-stop-actions">
|
<div className="route-stop-actions">
|
||||||
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
|
<button type="button" className="secondary" onClick={() => moveStop(index, -1)} disabled={index === 0}>↑</button>
|
||||||
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
|
<button type="button" className="secondary" onClick={() => moveStop(index, 1)} disabled={index === stops.length - 1}>↓</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -268,6 +285,43 @@ export default function LinkStop({ token, user }) {
|
|||||||
<div>You must be admin to link stops.</div>
|
<div>You must be admin to link stops.</div>
|
||||||
)}
|
)}
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Remove Stop from Line?</h3>
|
||||||
|
<p><strong>{deleteConfirm.name}</strong></p>
|
||||||
|
<p style={{ color: '#999', fontSize: '0.9rem' }}>This will remove the stop from the route and re-order the remaining stops.</p>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button className="danger" onClick={() => deleteRouteStop(deleteConfirm.id, deleteConfirm.name)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { transitClient } from '../api'
|
||||||
|
|
||||||
|
export default function ScheduleMaker({ token, user }) {
|
||||||
|
const client = transitClient(token)
|
||||||
|
const [schedules, setSchedules] = useState([])
|
||||||
|
const [busLines, setBusLines] = useState([])
|
||||||
|
const [buses, setBuses] = useState([])
|
||||||
|
const [message, setMessage] = useState(null)
|
||||||
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [selectedLine, setSelectedLine] = useState('')
|
||||||
|
const [selectedBus, setSelectedBus] = useState('')
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||||
|
const [startTime, setStartTime] = useState('08:00')
|
||||||
|
const [endTime, setEndTime] = useState('17:00')
|
||||||
|
const [driverId, setDriverId] = useState('')
|
||||||
|
|
||||||
|
const loadSchedules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/admin/schedules')
|
||||||
|
setSchedules(res.data.schedules || [])
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBusLines = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/admin/bus-lines')
|
||||||
|
setBusLines(res.data.busLines || [])
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBuses = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/admin/fleet/buses')
|
||||||
|
setBuses(res.data.buses || [])
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSchedules()
|
||||||
|
loadBusLines()
|
||||||
|
loadBuses()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createSchedule = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
if (!selectedLine || !selectedBus || !startTime || !endTime) {
|
||||||
|
setMessage('Alle velden zijn verplicht')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startDateTime = `${selectedDate}T${startTime}:00`
|
||||||
|
const endDateTime = `${selectedDate}T${endTime}:00`
|
||||||
|
|
||||||
|
const res = await client.post('/admin/schedules', {
|
||||||
|
busLineId: Number(selectedLine),
|
||||||
|
busId: Number(selectedBus),
|
||||||
|
driverId: driverId ? Number(driverId) : null,
|
||||||
|
startTime: startDateTime,
|
||||||
|
endTime: endDateTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
setMessage('Dienst aangemaakt: #' + res.data.schedule.id)
|
||||||
|
setSelectedLine('')
|
||||||
|
setSelectedBus('')
|
||||||
|
setStartTime('08:00')
|
||||||
|
setEndTime('17:00')
|
||||||
|
setDriverId('')
|
||||||
|
await loadSchedules()
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err.response?.data?.error || err.message
|
||||||
|
setMessage('Error: ' + errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSchedule = async (scheduleId, lineNumber, busName) => {
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await client.delete(`/admin/schedules/${scheduleId}`)
|
||||||
|
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await loadSchedules()
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err.response?.data?.error || err.message
|
||||||
|
setMessage('Error: ' + errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group schedules by date and time
|
||||||
|
const timeSlots = generateTimeSlots()
|
||||||
|
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Diensten Inplannen</h2>
|
||||||
|
<p>Maak diensten aan en wijs voertuigen en lijnen toe.</p>
|
||||||
|
</div>
|
||||||
|
<button className="secondary" onClick={loadSchedules}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<form onSubmit={createSchedule} className="stack-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
|
||||||
|
<option value="">Kies lijn</option>
|
||||||
|
{busLines.map(line => (
|
||||||
|
<option key={line.id} value={line.id}>
|
||||||
|
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
|
||||||
|
<option value="">Kies voertuig</option>
|
||||||
|
{buses.map(bus => (
|
||||||
|
<option key={bus.id} value={bus.id}>
|
||||||
|
{bus.name} ({bus.licensePlate})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={e => setSelectedDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={e => setStartTime(e.target.value)}
|
||||||
|
placeholder="Start time"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={e => setEndTime(e.target.value)}
|
||||||
|
placeholder="End time"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={driverId}
|
||||||
|
onChange={e => setDriverId(e.target.value)}
|
||||||
|
placeholder="Driver ID (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<button type="submit">Create Service</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
|
||||||
|
<div className="timetable-container">
|
||||||
|
<table className="timetable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tijdvak</th>
|
||||||
|
<th>Lijnen en Voertuigen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timeSlots.map((slot, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td className="time-cell">{slot}</td>
|
||||||
|
<td className="schedule-cell">
|
||||||
|
<div className="schedule-items">
|
||||||
|
{schedulesBySlot[slot]?.map(schedule => (
|
||||||
|
<article
|
||||||
|
key={schedule.id}
|
||||||
|
className="schedule-card"
|
||||||
|
draggable
|
||||||
|
>
|
||||||
|
<div className="schedule-header">
|
||||||
|
<strong>Lijn {schedule.lineNumber}</strong>
|
||||||
|
<span className="schedule-time">{formatTime(schedule.startTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-details">
|
||||||
|
<div>{schedule.busName}</div>
|
||||||
|
<div className="schedule-plate">{schedule.licensePlate}</div>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
|
||||||
|
onClick={() => setDeleteConfirm(schedule)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Delete Service?</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#999', fontSize: '0.9rem' }}>
|
||||||
|
{new Date(deleteConfirm.startTime).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() =>
|
||||||
|
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTimeSlots() {
|
||||||
|
const slots = []
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||||
|
const grouped = {}
|
||||||
|
slots.forEach(slot => {
|
||||||
|
grouped[slot] = []
|
||||||
|
})
|
||||||
|
|
||||||
|
schedules.forEach(schedule => {
|
||||||
|
const startHour = new Date(schedule.startTime).getHours()
|
||||||
|
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
||||||
|
if (grouped[slot]) {
|
||||||
|
grouped[slot].push(schedule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timeString) {
|
||||||
|
const date = new Date(timeString)
|
||||||
|
return date.toLocaleTimeString('en-NL', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export default function StopsManager({ token, user }) {
|
|||||||
const [lon, setLon] = useState('')
|
const [lon, setLon] = useState('')
|
||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
const loadStops = async () => {
|
const loadStops = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +48,19 @@ export default function StopsManager({ token, user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteStop = async (stopId, stopName) => {
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await client.delete(`/admin/stops/${stopId}`)
|
||||||
|
setMessage(`Halte "${stopName}" verwijderd`)
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await loadStops()
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err.response?.data?.error || err.message
|
||||||
|
setMessage('Error: ' + errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
@@ -61,6 +75,15 @@ export default function StopsManager({ token, user }) {
|
|||||||
<article className="mini-card" key={stop.id}>
|
<article className="mini-card" key={stop.id}>
|
||||||
<strong>#{stop.id} {stop.name}</strong>
|
<strong>#{stop.id} {stop.name}</strong>
|
||||||
<div>{stop.latitude}, {stop.longitude}</div>
|
<div>{stop.latitude}, {stop.longitude}</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => setDeleteConfirm(stop)}
|
||||||
|
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +101,43 @@ export default function StopsManager({ token, user }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
<p>Note: To view stops per line, use the Bus Line Stops tool below.</p>
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Delete Stop?</h3>
|
||||||
|
<p><strong>{deleteConfirm.name}</strong></p>
|
||||||
|
<p style={{ color: '#999', fontSize: '0.9rem' }}>This cannot be undone.</p>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button className="danger" onClick={() => deleteStop(deleteConfirm.id, deleteConfirm.name)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overlay = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
|
||||||
|
const [schedules, setSchedules] = useState([])
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||||
|
const [selectedLineId, setSelectedLineId] = useState('')
|
||||||
|
const [lines, setLines] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
// Fetch available bus lines
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLines = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
|
||||||
|
const data = await res.json()
|
||||||
|
setLines(data.busLines || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load lines:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLines()
|
||||||
|
}, [apiBaseUrl])
|
||||||
|
|
||||||
|
// Fetch schedules based on date and line filter
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSchedules = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
|
||||||
|
if (selectedLineId) {
|
||||||
|
url += `&lineId=${selectedLineId}`
|
||||||
|
}
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
setSchedules(data.schedules || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load schedules: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSchedules()
|
||||||
|
}, [selectedDate, selectedLineId, apiBaseUrl])
|
||||||
|
|
||||||
|
const timeSlots = generateTimeSlots()
|
||||||
|
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h1>Public Transit Timetable</h1>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={e => setSelectedDate(e.target.value)}
|
||||||
|
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={selectedLineId}
|
||||||
|
onChange={e => setSelectedLineId(e.target.value)}
|
||||||
|
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">All lines</option>
|
||||||
|
{lines.map(line => (
|
||||||
|
<option key={line.id} value={line.id}>
|
||||||
|
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p>Loading schedules...</p>}
|
||||||
|
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
|
||||||
|
<thead style={{ backgroundColor: '#f1f5f9' }}>
|
||||||
|
<tr>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{timeSlots.map((slot, idx) => {
|
||||||
|
const slotSchedules = schedulesBySlot[slot] || []
|
||||||
|
if (slotSchedules.length === 0) return null
|
||||||
|
return slotSchedules.map((schedule, sidx) => (
|
||||||
|
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{sidx === 0 ? slot : ''}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
|
||||||
|
Line {schedule.lineNumber}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{schedule.busName} ({schedule.licensePlate})
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
|
||||||
|
color: schedule.status === 'active' ? '#166534' : '#374151',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{schedule.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{schedules.length === 0 && !loading && (
|
||||||
|
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
|
||||||
|
No schedules found for the selected date and line.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTimeSlots() {
|
||||||
|
const slots = []
|
||||||
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
|
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||||
|
const grouped = {}
|
||||||
|
slots.forEach(slot => {
|
||||||
|
grouped[slot] = []
|
||||||
|
})
|
||||||
|
|
||||||
|
schedules.forEach(schedule => {
|
||||||
|
const startHour = new Date(schedule.startTime).getHours()
|
||||||
|
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
||||||
|
if (grouped[slot]) {
|
||||||
|
grouped[slot].push(schedule)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
@@ -138,6 +138,17 @@ button.secondary {
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border-color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
box-shadow: 0 10px 24px rgba(220, 38, 38, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: #047857;
|
color: #047857;
|
||||||
@@ -254,3 +265,112 @@ button.secondary {
|
|||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
margin: 60px auto;
|
margin: 60px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ Timetable Styles ============ */
|
||||||
|
.timetable-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable thead {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-bottom: 2px solid rgba(148, 163, 184, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable tbody tr:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4f46e5;
|
||||||
|
width: 140px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-cell {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 60px;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(14, 165, 233, 0.06));
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 180px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-header strong {
|
||||||
|
color: #4f46e5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-time {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-details {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-plate {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: monospace;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Root level sensitive docs - NEVER include
|
||||||
|
/ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
|
/CI_CD_PORTAINER_GUIDE.md
|
||||||
|
/DEPLOYMENT.md
|
||||||
|
/GETTING_STARTED.md
|
||||||
|
/INTERNAL_*.md
|
||||||
|
/SETUP_*.md
|
||||||
|
/DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
/.instructions.md
|
||||||
|
/.prompt.md
|
||||||
|
/.agent.md
|
||||||
|
/SKILL.md
|
||||||
|
/copilot-instructions.md
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts and configs
|
||||||
|
/scripts/
|
||||||
|
|
||||||
|
# Node modules and logs
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
.local
|
||||||
|
|
||||||
|
# Build files not needed in runtime
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Root level sensitive docs - NEVER include
|
||||||
|
/ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
|
/CI_CD_PORTAINER_GUIDE.md
|
||||||
|
/DEPLOYMENT.md
|
||||||
|
/GETTING_STARTED.md
|
||||||
|
/INTERNAL_*.md
|
||||||
|
/SETUP_*.md
|
||||||
|
/DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
/.instructions.md
|
||||||
|
/.prompt.md
|
||||||
|
/.agent.md
|
||||||
|
/SKILL.md
|
||||||
|
/copilot-instructions.md
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts and configs
|
||||||
|
/scripts/
|
||||||
|
|
||||||
|
# Node modules and logs
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
.local
|
||||||
|
|
||||||
|
# Build files not needed in runtime
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Root level sensitive docs - NEVER include
|
||||||
|
/ADMIN_DEPLOYMENT_SUMMARY.md
|
||||||
|
/CI_CD_PORTAINER_GUIDE.md
|
||||||
|
/DEPLOYMENT.md
|
||||||
|
/GETTING_STARTED.md
|
||||||
|
/INTERNAL_*.md
|
||||||
|
/SETUP_*.md
|
||||||
|
/DEPLOY_*.md
|
||||||
|
|
||||||
|
# Custom instruction files (private)
|
||||||
|
/.instructions.md
|
||||||
|
/.prompt.md
|
||||||
|
/.agent.md
|
||||||
|
/SKILL.md
|
||||||
|
/copilot-instructions.md
|
||||||
|
/AGENTS.md
|
||||||
|
|
||||||
|
# Private scripts and configs
|
||||||
|
/scripts/
|
||||||
|
|
||||||
|
# Node modules and logs
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
|
||||||
|
# Git files
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.env
|
||||||
|
.local
|
||||||
|
|
||||||
|
# Build files not needed in runtime
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
@@ -16,10 +16,12 @@ const _mock = {
|
|||||||
routeStops: [],
|
routeStops: [],
|
||||||
busLines: [],
|
busLines: [],
|
||||||
fleetBuses: [],
|
fleetBuses: [],
|
||||||
|
schedules: [],
|
||||||
nextStopId: 1,
|
nextStopId: 1,
|
||||||
nextRouteStopId: 1,
|
nextRouteStopId: 1,
|
||||||
nextBusLineId: 1,
|
nextBusLineId: 1,
|
||||||
nextFleetBusId: 1,
|
nextFleetBusId: 1,
|
||||||
|
nextScheduleId: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminUserCodes = new Set(
|
const adminUserCodes = new Set(
|
||||||
@@ -682,6 +684,161 @@ app.post('/admin/bus-lines/:busLineId/stops/reorder', authMiddleware, adminMiddl
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Delete a stop
|
||||||
|
// ============================================
|
||||||
|
app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { stopId } = req.params;
|
||||||
|
|
||||||
|
if (!stopId || !Number.isFinite(Number(stopId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_stop_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const index = _mock.stops.findIndex(s => Number(s.id) === Number(stopId));
|
||||||
|
if (index === -1) {
|
||||||
|
return res.status(404).json({ error: 'stop_not_found' });
|
||||||
|
}
|
||||||
|
const deletedStop = _mock.stops.splice(index, 1)[0];
|
||||||
|
return res.json({ success: true, stop: deletedStop });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`DELETE FROM transit.stops WHERE id = $1 RETURNING id, name`,
|
||||||
|
[stopId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'stop_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, stop: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
if (err.code === '23503') {
|
||||||
|
return res.status(409).json({ error: 'foreign_key_violation', message: 'This stop is referenced by route_stops' });
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Delete a bus line
|
||||||
|
// ============================================
|
||||||
|
app.delete('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
const { busLineId } = req.params;
|
||||||
|
|
||||||
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bus_line_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const lineIndex = _mock.busLines.findIndex(bl => Number(bl.id) === Number(busLineId));
|
||||||
|
if (lineIndex === -1) {
|
||||||
|
return res.status(404).json({ error: 'bus_line_not_found' });
|
||||||
|
}
|
||||||
|
const deletedLine = _mock.busLines.splice(lineIndex, 1)[0];
|
||||||
|
// Also delete route_stops for this line
|
||||||
|
_mock.routeStops = _mock.routeStops.filter(rs => String(rs.busLineId) !== String(busLineId));
|
||||||
|
return res.json({ success: true, busLine: deletedLine });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Delete associated route_stops first
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM transit.route_stops WHERE bus_line_id = $1`,
|
||||||
|
[busLineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete the bus line
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM transit.bus_lines WHERE id = $1 RETURNING id, line_number`,
|
||||||
|
[busLineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'bus_line_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true, busLine: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Delete a route_stop from bus line
|
||||||
|
// ============================================
|
||||||
|
app.delete('/admin/bus-lines/:busLineId/stops/:routeStopId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
const client = await db.pool.connect();
|
||||||
|
try {
|
||||||
|
const { busLineId, routeStopId } = req.params;
|
||||||
|
|
||||||
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bus_line_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!routeStopId || !Number.isFinite(Number(routeStopId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_route_stop_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const index = _mock.routeStops.findIndex(
|
||||||
|
rs => Number(rs.id) === Number(routeStopId) && String(rs.busLineId) === String(busLineId)
|
||||||
|
);
|
||||||
|
if (index === -1) {
|
||||||
|
return res.status(404).json({ error: 'route_stop_not_found' });
|
||||||
|
}
|
||||||
|
const deletedRouteStop = _mock.routeStops.splice(index, 1)[0];
|
||||||
|
return res.json({ success: true, routeStop: deletedRouteStop });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Find and delete the route_stop
|
||||||
|
const result = await client.query(
|
||||||
|
`DELETE FROM transit.route_stops
|
||||||
|
WHERE id = $1 AND bus_line_id = $2
|
||||||
|
RETURNING id, stop_order`,
|
||||||
|
[routeStopId, busLineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'route_stop_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedOrder = result.rows[0].stop_order;
|
||||||
|
|
||||||
|
// Re-order remaining stops
|
||||||
|
await client.query(
|
||||||
|
`UPDATE transit.route_stops
|
||||||
|
SET stop_order = stop_order - 1, updated_at = NOW()
|
||||||
|
WHERE bus_line_id = $1 AND stop_order > $2`,
|
||||||
|
[busLineId, deletedOrder]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ success: true, routeStop: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// NAVIGATION - Save calculated route
|
// NAVIGATION - Save calculated route
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -839,6 +996,320 @@ app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Manage schedules (timetable)
|
||||||
|
// ============================================
|
||||||
|
app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
return res.json({ schedules: _mock.schedules || [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.bus_line_id,
|
||||||
|
s.bus_id,
|
||||||
|
s.driver_id,
|
||||||
|
s.start_time,
|
||||||
|
s.end_time,
|
||||||
|
s.status,
|
||||||
|
s.created_at,
|
||||||
|
bl.line_number,
|
||||||
|
b.name as bus_name,
|
||||||
|
b.license_plate
|
||||||
|
FROM transit.schedules s
|
||||||
|
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
||||||
|
LEFT JOIN fleet.bus b ON s.bus_id = b.id
|
||||||
|
ORDER BY s.start_time DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
schedules: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
busLineId: row.bus_line_id,
|
||||||
|
busId: row.bus_id,
|
||||||
|
driverId: row.driver_id,
|
||||||
|
startTime: row.start_time,
|
||||||
|
endTime: row.end_time,
|
||||||
|
status: row.status,
|
||||||
|
lineNumber: row.line_number,
|
||||||
|
busName: row.bus_name,
|
||||||
|
licensePlate: row.license_plate,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { busLineId, busId, driverId, startTime, endTime } = req.body;
|
||||||
|
|
||||||
|
if (!busLineId || !busId || !startTime || !endTime) {
|
||||||
|
return res.status(400).json({ error: 'missing_required_fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
if (!_mock.schedules) _mock.schedules = [];
|
||||||
|
const schedule = {
|
||||||
|
id: (_mock.nextScheduleId || 1),
|
||||||
|
busLineId,
|
||||||
|
busId,
|
||||||
|
driverId: driverId || null,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status: 'planned',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
_mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1);
|
||||||
|
_mock.schedules.push(schedule);
|
||||||
|
return res.status(201).json({ schedule });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
WITH next_id AS (
|
||||||
|
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
|
||||||
|
)
|
||||||
|
INSERT INTO transit.schedules (id, bus_line_id, bus_id, driver_id, start_time, end_time, status)
|
||||||
|
SELECT next_id.id, $1, $2, $3, $4, $5, 'planned'
|
||||||
|
FROM next_id
|
||||||
|
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
|
||||||
|
`, [busLineId, busId, driverId || null, startTime, endTime]);
|
||||||
|
|
||||||
|
const schedule = result.rows[0];
|
||||||
|
res.status(201).json({
|
||||||
|
schedule: {
|
||||||
|
id: schedule.id,
|
||||||
|
busLineId: schedule.bus_line_id,
|
||||||
|
busId: schedule.bus_id,
|
||||||
|
driverId: schedule.driver_id,
|
||||||
|
startTime: schedule.start_time,
|
||||||
|
endTime: schedule.end_time,
|
||||||
|
status: schedule.status,
|
||||||
|
createdAt: schedule.created_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { scheduleId } = req.params;
|
||||||
|
|
||||||
|
if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_schedule_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
if (!_mock.schedules) _mock.schedules = [];
|
||||||
|
const index = _mock.schedules.findIndex(s => Number(s.id) === Number(scheduleId));
|
||||||
|
if (index === -1) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
const deleted = _mock.schedules.splice(index, 1)[0];
|
||||||
|
return res.json({ success: true, schedule: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`DELETE FROM transit.schedules WHERE id = $1 RETURNING id`,
|
||||||
|
[scheduleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, schedule: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PUBLIC - Get schedules (for public timetable)
|
||||||
|
// ============================================
|
||||||
|
app.get('/schedules', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, lineId } = req.query;
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
return res.json({ schedules: _mock.schedules || [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.bus_line_id,
|
||||||
|
s.bus_id,
|
||||||
|
s.start_time,
|
||||||
|
s.end_time,
|
||||||
|
s.status,
|
||||||
|
bl.line_number,
|
||||||
|
b.name as bus_name
|
||||||
|
FROM transit.schedules s
|
||||||
|
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
||||||
|
LEFT JOIN fleet.bus b ON s.bus_id = b.id
|
||||||
|
WHERE s.status IN ('planned', 'active')
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
query += ` AND DATE(s.start_time) = $${params.length + 1}`;
|
||||||
|
params.push(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineId) {
|
||||||
|
query += ` AND s.bus_line_id = $${params.length + 1}`;
|
||||||
|
params.push(lineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY s.start_time ASC`;
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
schedules: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
busLineId: row.bus_line_id,
|
||||||
|
busId: row.bus_id,
|
||||||
|
startTime: row.start_time,
|
||||||
|
endTime: row.end_time,
|
||||||
|
status: row.status,
|
||||||
|
lineNumber: row.line_number,
|
||||||
|
busName: row.bus_name,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/schedules/:scheduleId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { scheduleId } = req.params;
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
|
||||||
|
if (!schedule) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
return res.json({ schedule });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.bus_line_id,
|
||||||
|
s.bus_id,
|
||||||
|
s.start_time,
|
||||||
|
s.end_time,
|
||||||
|
s.status,
|
||||||
|
bl.line_number,
|
||||||
|
bl.start_stop,
|
||||||
|
bl.end_stop,
|
||||||
|
b.name as bus_name,
|
||||||
|
b.license_plate
|
||||||
|
FROM transit.schedules s
|
||||||
|
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id
|
||||||
|
LEFT JOIN fleet.bus b ON s.bus_id = b.id
|
||||||
|
WHERE s.id = $1
|
||||||
|
`, [scheduleId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0];
|
||||||
|
res.json({
|
||||||
|
schedule: {
|
||||||
|
id: row.id,
|
||||||
|
busLineId: row.bus_line_id,
|
||||||
|
busId: row.bus_id,
|
||||||
|
startTime: row.start_time,
|
||||||
|
endTime: row.end_time,
|
||||||
|
status: row.status,
|
||||||
|
lineNumber: row.line_number,
|
||||||
|
startStop: row.start_stop,
|
||||||
|
endStop: row.end_stop,
|
||||||
|
busName: row.bus_name,
|
||||||
|
licensePlate: row.license_plate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PUBLIC - Get stops for a schedule's line
|
||||||
|
// ============================================
|
||||||
|
app.get('/schedules/:scheduleId/stops', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { scheduleId } = req.params;
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const schedule = (_mock.schedules || []).find(s => Number(s.id) === Number(scheduleId));
|
||||||
|
if (!schedule) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
const routeStops = (_mock.routeStops || [])
|
||||||
|
.filter(rs => Number(rs.busLineId) === Number(schedule.busLineId))
|
||||||
|
.sort((a, b) => a.stopOrder - b.stopOrder);
|
||||||
|
return res.json({ stops: routeStops });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleResult = await db.query(
|
||||||
|
`SELECT bus_line_id FROM transit.schedules WHERE id = $1`,
|
||||||
|
[scheduleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scheduleResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'schedule_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const busLineId = scheduleResult.rows[0].bus_line_id;
|
||||||
|
|
||||||
|
const stopsResult = await db.query(`
|
||||||
|
SELECT
|
||||||
|
rs.id,
|
||||||
|
rs.stop_order,
|
||||||
|
s.name,
|
||||||
|
s.latitude,
|
||||||
|
s.longitude,
|
||||||
|
rs.estimated_arrival_minutes
|
||||||
|
FROM transit.route_stops rs
|
||||||
|
JOIN transit.stops s ON s.id = rs.stop_id
|
||||||
|
WHERE rs.bus_line_id = $1
|
||||||
|
ORDER BY rs.stop_order ASC
|
||||||
|
`, [busLineId]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
stops: stopsResult.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
order: row.stop_order,
|
||||||
|
name: row.name,
|
||||||
|
latitude: parseFloat(row.latitude),
|
||||||
|
longitude: parseFloat(row.longitude),
|
||||||
|
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5001;
|
const PORT = process.env.PORT || 5001;
|
||||||
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
|
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user