@@ -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 && (
+
+ )}
+
+ Timetable
+
+
+
+
+ | Tijdvak |
+ Lijnen en Voertuigen |
+
+
+
+ {timeSlots.map((slot, idx) => (
+
+ | {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}
}
+
+
+
+
+ | Time |
+ Line |
+ Vehicle |
+ Status |
+
+
+
+ {timeSlots.map((slot, idx) => {
+ const slotSchedules = schedulesBySlot[slot] || []
+ if (slotSchedules.length === 0) return null
+ return slotSchedules.map((schedule, sidx) => (
+
+ |
+ {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}`));