scheduling feature

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Sven laptop
2026-05-06 15:58:02 +02:00
parent c7874f2678
commit 68490f8fd5
17 changed files with 1855 additions and 3 deletions
+23 -1
View File
@@ -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
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
View File
@@ -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
\
.DS_Store
+128
View File
@@ -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'
<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.
---
+110
View File
@@ -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
```
+47
View File
@@ -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/
+187
View File
@@ -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
+2
View File
@@ -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() {
<BusLinesManager token={token} user={user} />
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} />
</main>
</div>
)
@@ -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 (
<section className="card">
<div className="section-header">
@@ -76,6 +90,15 @@ export default function BusLinesManager({ token, user }) {
<div>{line.startStop} → {line.endStop}</div>
<div>{line.estimatedDurationMinutes} min</div>
{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>
))}
</div>
@@ -106,6 +129,43 @@ export default function BusLinesManager({ token, user }) {
) : <div>You must be admin to create bus lines.</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>
)
}
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)',
}
+54
View File
@@ -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 (
<section className="card">
<div className="section-header">
@@ -215,6 +229,9 @@ export default function LinkStop({ token, user }) {
<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 === stops.length - 1}>↓</button>
{isAdmin && (
<button type="button" className="danger" onClick={() => setDeleteConfirm({ id: s.id, name: s.name })}>✕</button>
)}
</div>
</article>
))}
@@ -268,6 +285,43 @@ export default function LinkStop({ token, user }) {
<div>You must be admin to link stops.</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>
)
}
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)',
}
+293
View File
@@ -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)',
}
+60 -1
View File
@@ -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 (
<section className="card">
<div className="section-header">
@@ -61,6 +75,15 @@ export default function StopsManager({ token, user }) {
<article className="mini-card" key={stop.id}>
<strong>#{stop.id} {stop.name}</strong>
<div>{stop.latitude}, {stop.longitude}</div>
{isAdmin && (
<button
className="danger"
onClick={() => setDeleteConfirm(stop)}
style={{ marginTop: '0.5rem', width: '100%' }}
>
Delete
</button>
)}
</article>
))}
</div>
@@ -78,7 +101,43 @@ export default function StopsManager({ token, user }) {
)}
{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>
)
}
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)',
}
+153
View File
@@ -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
}
+120
View File
@@ -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;
}
+43
View File
@@ -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/
+43
View File
@@ -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/
+43
View File
@@ -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/
+471
View File
@@ -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}`));