@@ -0,0 +1,47 @@
|
||||
# Root level sensitive docs - NEVER include
|
||||
/ADMIN_DEPLOYMENT_SUMMARY.md
|
||||
/CI_CD_PORTAINER_GUIDE.md
|
||||
/DEPLOYMENT.md
|
||||
/GETTING_STARTED.md
|
||||
/INTERNAL_*.md
|
||||
/SETUP_*.md
|
||||
/DEPLOY_*.md
|
||||
|
||||
# Custom instruction files (private)
|
||||
/.instructions.md
|
||||
/.prompt.md
|
||||
/.agent.md
|
||||
/SKILL.md
|
||||
/copilot-instructions.md
|
||||
/AGENTS.md
|
||||
|
||||
# Private scripts and configs
|
||||
/scripts/
|
||||
|
||||
# Node modules and logs
|
||||
node_modules
|
||||
npm-debug.log
|
||||
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Development files
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
.env
|
||||
.local
|
||||
|
||||
# Build files
|
||||
build/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Frontend specific
|
||||
.turbo
|
||||
.next
|
||||
out/
|
||||
@@ -0,0 +1,187 @@
|
||||
# Schedule Maker & Public Timetable API
|
||||
|
||||
## Features
|
||||
|
||||
The Transit API now includes a comprehensive schedule management system with both admin and public endpoints.
|
||||
|
||||
### Admin Endpoints (requires authentication + admin privileges)
|
||||
|
||||
#### Get all schedules
|
||||
```
|
||||
GET /admin/schedules
|
||||
Response: { schedules: [...] }
|
||||
```
|
||||
|
||||
#### Create a schedule
|
||||
```
|
||||
POST /admin/schedules
|
||||
Body: {
|
||||
busLineId: number,
|
||||
busId: number,
|
||||
driverId: number (optional),
|
||||
startTime: "2026-05-06T08:00:00",
|
||||
endTime: "2026-05-06T17:00:00"
|
||||
}
|
||||
Response: { schedule: {...} }
|
||||
```
|
||||
|
||||
#### Delete a schedule
|
||||
```
|
||||
DELETE /admin/schedules/:scheduleId
|
||||
Response: { success: true, schedule: {...} }
|
||||
```
|
||||
|
||||
### Public Endpoints (no authentication required)
|
||||
|
||||
#### Get schedules (with optional filters)
|
||||
```
|
||||
GET /schedules?date=2026-05-06&lineId=1
|
||||
Query params:
|
||||
- date: YYYY-MM-DD (optional)
|
||||
- lineId: number (optional)
|
||||
Response: { schedules: [...] }
|
||||
```
|
||||
|
||||
#### Get schedule detail
|
||||
```
|
||||
GET /schedules/:scheduleId
|
||||
Response: { schedule: {...} }
|
||||
```
|
||||
|
||||
#### Get stops for a schedule's line
|
||||
```
|
||||
GET /schedules/:scheduleId/stops
|
||||
Response: { stops: [...] }
|
||||
Shows all stops on the bus line with order, location, and ETA.
|
||||
```
|
||||
|
||||
## Admin UI Features
|
||||
|
||||
The ScheduleMaker component provides:
|
||||
|
||||
1. **Service Creation Form**
|
||||
- Select bus line, vehicle, date, and time
|
||||
- Optional driver ID assignment
|
||||
- Submit to create schedule
|
||||
- Admin-only access
|
||||
|
||||
2. **Timetable View**
|
||||
- 24-hour time slots (hourly grouping)
|
||||
- Services displayed in corresponding time slots
|
||||
- Shows line number, vehicle name, and license plate
|
||||
- Delete button for each service (admin only)
|
||||
- Color-coded schedule cards with gradient styling
|
||||
|
||||
3. **Drag-and-Drop Ready**
|
||||
- Schedule cards are draggable (extensible for drag-to-reorder)
|
||||
- Follows existing UI/UX patterns from LinkStop component
|
||||
|
||||
4. **Confirmation Modals**
|
||||
- Delete confirmation before removing schedules
|
||||
- Shows service details in confirmation
|
||||
- Prevents accidental deletions
|
||||
|
||||
## Public Timetable Viewer
|
||||
|
||||
The TimetableViewer component provides a public-facing schedule display:
|
||||
|
||||
```jsx
|
||||
import TimetableViewer from './components/TimetableViewer'
|
||||
|
||||
<TimetableViewer apiBaseUrl="http://api.transit.local:5001" />
|
||||
```
|
||||
|
||||
Features:
|
||||
- Filter by date and bus line
|
||||
- Real-time loading state
|
||||
- Error handling
|
||||
- Status badges (planned/active)
|
||||
- Responsive table layout
|
||||
- No authentication required
|
||||
|
||||
## Development Mode
|
||||
|
||||
With `DEV_MOCK=1`, all schedule operations work in-memory without a database.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
ADMIN_USER_CODES=42 DEV_MOCK=1 JWT_SECRET=devsecret npm --prefix ./transit_api start
|
||||
```
|
||||
|
||||
## Database Schema (Production)
|
||||
|
||||
```sql
|
||||
CREATE TABLE transit.schedules (
|
||||
id BIGINT PRIMARY KEY,
|
||||
bus_line_id BIGINT REFERENCES transit.bus_lines(id),
|
||||
bus_id BIGINT REFERENCES fleet.bus(id),
|
||||
driver_id INTEGER,
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'planned',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Styling & UX
|
||||
|
||||
The schedule manager uses:
|
||||
|
||||
### Admin Timetable Styling
|
||||
- Responsive table with hourly time slots
|
||||
- Gradient schedule cards (blue/indigo theme)
|
||||
- Hover effects and smooth transitions
|
||||
- Grab cursor on draggable items
|
||||
- Danger buttons for delete actions (red)
|
||||
- Confirmation modals with semi-transparent overlay
|
||||
|
||||
### Public Timetable Styling
|
||||
- Clean, simple table layout
|
||||
- Status badges (green for active, gray for planned)
|
||||
- Mobile-responsive with scrolling
|
||||
- Consistent with existing design language
|
||||
|
||||
## Integration Points
|
||||
|
||||
1. **Admin Dashboard**: ScheduleMaker appears at the bottom of Admin Control Center
|
||||
2. **Public API**: Available via public `/schedules` endpoints without authentication
|
||||
3. **Flutter App**: TimetableViewer can be embedded or used as reference for mobile implementation
|
||||
4. **Frontend Flexibility**: TimetableViewer component is reusable for any public-facing website
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Create a schedule (Admin)
|
||||
```bash
|
||||
curl -X POST http://localhost:5001/admin/schedules \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"busLineId": 1,
|
||||
"busId": 5,
|
||||
"driverId": 101,
|
||||
"startTime": "2026-05-06T08:00:00",
|
||||
"endTime": "2026-05-06T17:00:00"
|
||||
}'
|
||||
```
|
||||
|
||||
### Get today's schedules (Public)
|
||||
```bash
|
||||
curl http://localhost:5001/schedules?date=$(date +%Y-%m-%d)
|
||||
```
|
||||
|
||||
### Get schedules for specific line (Public)
|
||||
```bash
|
||||
curl http://localhost:5001/schedules?lineId=3
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Bulk schedule import from CSV
|
||||
- Recurring schedule templates (weekly, daily patterns)
|
||||
- Driver shift assignment with time tracking
|
||||
- Real-time schedule updates and notifications
|
||||
- Schedule conflict detection
|
||||
- Integration with GPS tracking for actual vs scheduled times
|
||||
- Historical schedule analytics
|
||||
|
||||
@@ -4,6 +4,7 @@ import StopsManager from './components/StopsManager'
|
||||
import LinkStop from './components/LinkStop'
|
||||
import 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)',
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
|
||||
export default function ScheduleMaker({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
const [schedules, setSchedules] = useState([])
|
||||
const [busLines, setBusLines] = useState([])
|
||||
const [buses, setBuses] = useState([])
|
||||
const [message, setMessage] = useState(null)
|
||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||
|
||||
// Form state
|
||||
const [selectedLine, setSelectedLine] = useState('')
|
||||
const [selectedBus, setSelectedBus] = useState('')
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||
const [startTime, setStartTime] = useState('08:00')
|
||||
const [endTime, setEndTime] = useState('17:00')
|
||||
const [driverId, setDriverId] = useState('')
|
||||
|
||||
const loadSchedules = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/schedules')
|
||||
setSchedules(res.data.schedules || [])
|
||||
} catch (err) {
|
||||
setMessage('Error loading schedules: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
const loadBusLines = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/bus-lines')
|
||||
setBusLines(res.data.busLines || [])
|
||||
} catch (err) {
|
||||
setMessage('Error loading bus lines: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
const loadBuses = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/fleet/buses')
|
||||
setBuses(res.data.buses || [])
|
||||
} catch (err) {
|
||||
setMessage('Error loading buses: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSchedules()
|
||||
loadBusLines()
|
||||
loadBuses()
|
||||
}, [])
|
||||
|
||||
const createSchedule = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
|
||||
if (!selectedLine || !selectedBus || !startTime || !endTime) {
|
||||
setMessage('Alle velden zijn verplicht')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startDateTime = `${selectedDate}T${startTime}:00`
|
||||
const endDateTime = `${selectedDate}T${endTime}:00`
|
||||
|
||||
const res = await client.post('/admin/schedules', {
|
||||
busLineId: Number(selectedLine),
|
||||
busId: Number(selectedBus),
|
||||
driverId: driverId ? Number(driverId) : null,
|
||||
startTime: startDateTime,
|
||||
endTime: endDateTime,
|
||||
})
|
||||
|
||||
setMessage('Dienst aangemaakt: #' + res.data.schedule.id)
|
||||
setSelectedLine('')
|
||||
setSelectedBus('')
|
||||
setStartTime('08:00')
|
||||
setEndTime('17:00')
|
||||
setDriverId('')
|
||||
await loadSchedules()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
setMessage('Error: ' + errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSchedule = async (scheduleId, lineNumber, busName) => {
|
||||
setMessage(null)
|
||||
try {
|
||||
await client.delete(`/admin/schedules/${scheduleId}`)
|
||||
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
|
||||
setDeleteConfirm(null)
|
||||
await loadSchedules()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
setMessage('Error: ' + errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// Group schedules by date and time
|
||||
const timeSlots = generateTimeSlots()
|
||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Diensten Inplannen</h2>
|
||||
<p>Maak diensten aan en wijs voertuigen en lijnen toe.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadSchedules}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={createSchedule} className="stack-form">
|
||||
<div className="form-row">
|
||||
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
|
||||
<option value="">Kies lijn</option>
|
||||
{busLines.map(line => (
|
||||
<option key={line.id} value={line.id}>
|
||||
Lijn {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
|
||||
<option value="">Kies voertuig</option>
|
||||
{buses.map(bus => (
|
||||
<option key={bus.id} value={bus.id}>
|
||||
{bus.name} ({bus.licensePlate})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={e => setStartTime(e.target.value)}
|
||||
placeholder="Start time"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={e => setEndTime(e.target.value)}
|
||||
placeholder="End time"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={driverId}
|
||||
onChange={e => setDriverId(e.target.value)}
|
||||
placeholder="Driver ID (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<button type="submit">Create Service</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>Timetable</h3>
|
||||
<div className="timetable-container">
|
||||
<table className="timetable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tijdvak</th>
|
||||
<th>Lijnen en Voertuigen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((slot, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="time-cell">{slot}</td>
|
||||
<td className="schedule-cell">
|
||||
<div className="schedule-items">
|
||||
{schedulesBySlot[slot]?.map(schedule => (
|
||||
<article
|
||||
key={schedule.id}
|
||||
className="schedule-card"
|
||||
draggable
|
||||
>
|
||||
<div className="schedule-header">
|
||||
<strong>Lijn {schedule.lineNumber}</strong>
|
||||
<span className="schedule-time">{formatTime(schedule.startTime)}</span>
|
||||
</div>
|
||||
<div className="schedule-details">
|
||||
<div>{schedule.busName}</div>
|
||||
<div className="schedule-plate">{schedule.licensePlate}</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
className="danger"
|
||||
style={{ marginTop: '0.5rem', width: '100%', fontSize: '0.85rem', padding: '0.4rem' }}
|
||||
onClick={() => setDeleteConfirm(schedule)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
|
||||
{deleteConfirm && (
|
||||
<div style={overlay}>
|
||||
<div style={modal}>
|
||||
<h3>Delete Service?</h3>
|
||||
<p>
|
||||
<strong>Lijn {deleteConfirm.lineNumber}</strong> - {deleteConfirm.busName} ({deleteConfirm.licensePlate})
|
||||
</p>
|
||||
<p style={{ color: '#999', fontSize: '0.9rem' }}>
|
||||
{new Date(deleteConfirm.startTime).toLocaleString()}
|
||||
</p>
|
||||
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||
<button
|
||||
className="danger"
|
||||
onClick={() =>
|
||||
deleteSchedule(deleteConfirm.id, deleteConfirm.lineNumber, deleteConfirm.busName)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function generateTimeSlots() {
|
||||
const slots = []
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||
const grouped = {}
|
||||
slots.forEach(slot => {
|
||||
grouped[slot] = []
|
||||
})
|
||||
|
||||
schedules.forEach(schedule => {
|
||||
const startHour = new Date(schedule.startTime).getHours()
|
||||
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
||||
if (grouped[slot]) {
|
||||
grouped[slot].push(schedule)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
function formatTime(timeString) {
|
||||
const date = new Date(timeString)
|
||||
return date.toLocaleTimeString('en-NL', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const overlay = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backgroundColor: '#fff',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default function StopsManager({ token, user }) {
|
||||
const [lon, setLon] = useState('')
|
||||
const [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)',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) {
|
||||
const [schedules, setSchedules] = useState([])
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0])
|
||||
const [selectedLineId, setSelectedLineId] = useState('')
|
||||
const [lines, setLines] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Fetch available bus lines
|
||||
useEffect(() => {
|
||||
const fetchLines = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBaseUrl}/admin/bus-lines`)
|
||||
const data = await res.json()
|
||||
setLines(data.busLines || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load lines:', err)
|
||||
}
|
||||
}
|
||||
fetchLines()
|
||||
}, [apiBaseUrl])
|
||||
|
||||
// Fetch schedules based on date and line filter
|
||||
useEffect(() => {
|
||||
const fetchSchedules = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
let url = `${apiBaseUrl}/schedules?date=${selectedDate}`
|
||||
if (selectedLineId) {
|
||||
url += `&lineId=${selectedLineId}`
|
||||
}
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
setSchedules(data.schedules || [])
|
||||
} catch (err) {
|
||||
setError('Failed to load schedules: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchSchedules()
|
||||
}, [selectedDate, selectedLineId, apiBaseUrl])
|
||||
|
||||
const timeSlots = generateTimeSlots()
|
||||
const schedulesBySlot = groupSchedulesByTimeSlot(schedules, timeSlots)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1>Public Transit Timetable</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={e => setSelectedDate(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc' }}
|
||||
/>
|
||||
<select
|
||||
value={selectedLineId}
|
||||
onChange={e => setSelectedLineId(e.target.value)}
|
||||
style={{ padding: '8px', borderRadius: '6px', border: '1px solid #ccc', minWidth: '200px' }}
|
||||
>
|
||||
<option value="">All lines</option>
|
||||
{lines.map(line => (
|
||||
<option key={line.id} value={line.id}>
|
||||
Line {line.lineNumber} ({line.startStop} → {line.endStop})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && <p>Loading schedules...</p>}
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }}>
|
||||
<thead style={{ backgroundColor: '#f1f5f9' }}>
|
||||
<tr>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Time</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Line</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Vehicle</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '2px solid #ddd' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timeSlots.map((slot, idx) => {
|
||||
const slotSchedules = schedulesBySlot[slot] || []
|
||||
if (slotSchedules.length === 0) return null
|
||||
return slotSchedules.map((schedule, sidx) => (
|
||||
<tr key={`${idx}-${sidx}`} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{sidx === 0 ? slot : ''}
|
||||
</td>
|
||||
<td style={{ padding: '12px', fontWeight: 'bold', color: '#4f46e5' }}>
|
||||
Line {schedule.lineNumber}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{schedule.busName} ({schedule.licensePlate})
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6',
|
||||
color: schedule.status === 'active' ? '#166534' : '#374151',
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
>
|
||||
{schedule.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{schedules.length === 0 && !loading && (
|
||||
<p style={{ textAlign: 'center', color: '#999', marginTop: '40px' }}>
|
||||
No schedules found for the selected date and line.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function generateTimeSlots() {
|
||||
const slots = []
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
slots.push(`${String(hour).padStart(2, '0')}:00-${String((hour + 1) % 24).padStart(2, '0')}:00`)
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
function groupSchedulesByTimeSlot(schedules, slots) {
|
||||
const grouped = {}
|
||||
slots.forEach(slot => {
|
||||
grouped[slot] = []
|
||||
})
|
||||
|
||||
schedules.forEach(schedule => {
|
||||
const startHour = new Date(schedule.startTime).getHours()
|
||||
const slot = `${String(startHour).padStart(2, '0')}:00-${String((startHour + 1) % 24).padStart(2, '0')}:00`
|
||||
if (grouped[slot]) {
|
||||
grouped[slot].push(schedule)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
}
|
||||
@@ -138,6 +138,17 @@ button.secondary {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user