diff --git a/.dockerignore b/.dockerignore index baa65a3..77da024 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,7 +10,29 @@ build coverage .vscode .idea + +# Sensitive documentation - NEVER include in Docker builds ADMIN_DEPLOYMENT_SUMMARY.md CI_CD_PORTAINER_GUIDE.md DEPLOYMENT.md -GETTING_STARTED.md \ No newline at end of file +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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4bdf535..6f40b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,25 @@ admin_ui/dist/ 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 +scripts/private/ +scripts/*.local.* +scripts/.env # Runtime files *.log *.sqlite3 -\ \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 0e0de3b..471912b 100644 --- a/README.md +++ b/README.md @@ -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 - `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` - 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' + + +``` + +### 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. --- diff --git a/SECURITY_PRIVATE_FILES.md b/SECURITY_PRIVATE_FILES.md new file mode 100644 index 0000000..abb82a2 --- /dev/null +++ b/SECURITY_PRIVATE_FILES.md @@ -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 + ``` diff --git a/admin_ui/.dockerignore b/admin_ui/.dockerignore new file mode 100644 index 0000000..eced0c5 --- /dev/null +++ b/admin_ui/.dockerignore @@ -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/ diff --git a/admin_ui/SCHEDULE_API.md b/admin_ui/SCHEDULE_API.md new file mode 100644 index 0000000..4f9ff46 --- /dev/null +++ b/admin_ui/SCHEDULE_API.md @@ -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' + + +``` + +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 + diff --git a/admin_ui/src/App.jsx b/admin_ui/src/App.jsx index a943b36..db570c9 100644 --- a/admin_ui/src/App.jsx +++ b/admin_ui/src/App.jsx @@ -4,6 +4,7 @@ import StopsManager from './components/StopsManager' import LinkStop from './components/LinkStop' import BusLinesManager from './components/BusLinesManager' import FleetManager from './components/FleetManager' +import ScheduleMaker from './components/ScheduleMaker' export default function App() { const [token, setToken] = useState(localStorage.getItem('token')) @@ -44,6 +45,7 @@ export default function App() { + ) diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx index 0751c26..07642ec 100644 --- a/admin_ui/src/components/BusLinesManager.jsx +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -12,6 +12,7 @@ export default function BusLinesManager({ token, user }) { const [description, setDescription] = useState('') const [message, setMessage] = useState(null) const [isAdmin] = useState(Boolean(user?.userCode)) + const [deleteConfirm, setDeleteConfirm] = useState(null) const loadBusLines = async () => { 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 (
@@ -76,6 +90,15 @@ export default function BusLinesManager({ token, user }) {
{line.startStop} → {line.endStop}
{line.estimatedDurationMinutes} min
{line.description ?
{line.description}
: null} + {isAdmin && ( + + )} ))}
@@ -106,6 +129,43 @@ export default function BusLinesManager({ token, user }) { ) :
You must be admin to create bus lines.
} {message &&
{message}
} + + {deleteConfirm && ( +
+
+

Delete Bus Line?

+

Lijn {deleteConfirm.lineNumber} ({deleteConfirm.startStop} → {deleteConfirm.endStop})

+

This will also delete all stops on this line. Cannot be undone.

+
+ + +
+
+
+ )}
) } + +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)', +} diff --git a/admin_ui/src/components/LinkStop.jsx b/admin_ui/src/components/LinkStop.jsx index 0d93fdb..fdcfcf4 100644 --- a/admin_ui/src/components/LinkStop.jsx +++ b/admin_ui/src/components/LinkStop.jsx @@ -11,6 +11,7 @@ export default function LinkStop({ token, user }) { const [message, setMessage] = useState(null) const [stops, setStops] = useState([]) const [draggedItem, setDraggedItem] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) 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 (
@@ -215,6 +229,9 @@ export default function LinkStop({ token, user }) {
+ {isAdmin && ( + + )}
))} @@ -268,6 +285,43 @@ export default function LinkStop({ token, user }) {
You must be admin to link stops.
)} {message &&
{message}
} + + {deleteConfirm && ( +
+
+

Remove Stop from Line?

+

{deleteConfirm.name}

+

This will remove the stop from the route and re-order the remaining stops.

+
+ + +
+
+
+ )}
) } + +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)', +} diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx new file mode 100644 index 0000000..6c99546 --- /dev/null +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -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 ( +
+
+
+

Diensten Inplannen

+

Maak diensten aan en wijs voertuigen en lijnen toe.

+
+ +
+ + {isAdmin && ( +
+
+ + +
+
+ setSelectedDate(e.target.value)} + /> + setStartTime(e.target.value)} + placeholder="Start time" + /> + setEndTime(e.target.value)} + placeholder="End time" + /> + setDriverId(e.target.value)} + placeholder="Driver ID (optional)" + /> +
+
+ +
+
+ )} + +

Timetable

+
+ + + + + + + + + {timeSlots.map((slot, idx) => ( + + + + + ))} + +
TijdvakLijnen en Voertuigen
{slot} +
+ {schedulesBySlot[slot]?.map(schedule => ( +
+
+ Lijn {schedule.lineNumber} + {formatTime(schedule.startTime)} +
+
+
{schedule.busName}
+
{schedule.licensePlate}
+
+ {isAdmin && ( + + )} +
+ ))} +
+
+
+ + {message &&
{message}
} + + {deleteConfirm && ( +
+
+

Delete Service?

+

+ Lijn {deleteConfirm.lineNumber} - {deleteConfirm.busName} ({deleteConfirm.licensePlate}) +

+

+ {new Date(deleteConfirm.startTime).toLocaleString()} +

+
+ + +
+
+
+ )} +
+ ) +} + +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)', +} diff --git a/admin_ui/src/components/StopsManager.jsx b/admin_ui/src/components/StopsManager.jsx index 164e845..aec54d3 100644 --- a/admin_ui/src/components/StopsManager.jsx +++ b/admin_ui/src/components/StopsManager.jsx @@ -9,6 +9,7 @@ export default function StopsManager({ token, user }) { const [lon, setLon] = useState('') const [message, setMessage] = useState(null) const [isAdmin] = useState(Boolean(user?.userCode)) + const [deleteConfirm, setDeleteConfirm] = useState(null) const loadStops = async () => { 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 (
@@ -61,6 +75,15 @@ export default function StopsManager({ token, user }) {
#{stop.id} {stop.name}
{stop.latitude}, {stop.longitude}
+ {isAdmin && ( + + )}
))}
@@ -78,7 +101,43 @@ export default function StopsManager({ token, user }) { )} {message &&
{message}
} -

Note: To view stops per line, use the Bus Line Stops tool below.

+ + {deleteConfirm && ( +
+
+

Delete Stop?

+

{deleteConfirm.name}

+

This cannot be undone.

+
+ + +
+
+
+ )}
) } + +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)', +} diff --git a/admin_ui/src/components/TimetableViewer.jsx b/admin_ui/src/components/TimetableViewer.jsx new file mode 100644 index 0000000..4174609 --- /dev/null +++ b/admin_ui/src/components/TimetableViewer.jsx @@ -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 ( +
+

Public Transit Timetable

+ +
+ setSelectedDate(e.target.value)} + style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }} + /> + +
+ + {loading &&

Loading schedules...

} + {error &&

{error}

} + + + + + + + + + + + + {timeSlots.map((slot, idx) => { + const slotSchedules = schedulesBySlot[slot] || [] + if (slotSchedules.length === 0) return null + return slotSchedules.map((schedule, sidx) => ( + + + + + + + )) + })} + +
TimeLineVehicleStatus
+ {sidx === 0 ? slot : ''} + + Line {schedule.lineNumber} + + {schedule.busName} ({schedule.licensePlate}) + + + {schedule.status} + +
+ + {schedules.length === 0 && !loading && ( +

+ No schedules found for the selected date and line. +

+ )} +
+ ) +} + +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 +} diff --git a/admin_ui/src/styles.css b/admin_ui/src/styles.css index 4b4e19a..593a786 100644 --- a/admin_ui/src/styles.css +++ b/admin_ui/src/styles.css @@ -138,6 +138,17 @@ button.secondary { 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 { margin-top: 10px; color: #047857; @@ -254,3 +265,112 @@ button.secondary { max-width: 540px; 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; +} + diff --git a/auth_api/.dockerignore b/auth_api/.dockerignore new file mode 100644 index 0000000..bd82a2d --- /dev/null +++ b/auth_api/.dockerignore @@ -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/ diff --git a/employee_api/.dockerignore b/employee_api/.dockerignore new file mode 100644 index 0000000..bd82a2d --- /dev/null +++ b/employee_api/.dockerignore @@ -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/ diff --git a/transit_api/.dockerignore b/transit_api/.dockerignore new file mode 100644 index 0000000..bd82a2d --- /dev/null +++ b/transit_api/.dockerignore @@ -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/ diff --git a/transit_api/index.js b/transit_api/index.js index b25ab27..05a4bbb 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -16,10 +16,12 @@ const _mock = { routeStops: [], busLines: [], fleetBuses: [], + schedules: [], nextStopId: 1, nextRouteStopId: 1, nextBusLineId: 1, nextFleetBusId: 1, + nextScheduleId: 1, }; 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 // ============================================ @@ -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; app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));