diff --git a/admin_ui/src/components/BusLinesManager.jsx b/admin_ui/src/components/BusLinesManager.jsx index 07642ec..557f128 100644 --- a/admin_ui/src/components/BusLinesManager.jsx +++ b/admin_ui/src/components/BusLinesManager.jsx @@ -13,6 +13,7 @@ export default function BusLinesManager({ token, user }) { const [message, setMessage] = useState(null) const [isAdmin] = useState(Boolean(user?.userCode)) const [deleteConfirm, setDeleteConfirm] = useState(null) + const [editingLine, setEditingLine] = useState(null) const loadBusLines = async () => { try { @@ -41,10 +42,14 @@ export default function BusLinesManager({ token, user }) { e.preventDefault() setMessage(null) try { + // Map selected stop ids to names (server currently stores names) + const startStopName = stops.find(s => String(s.id) === String(startStop))?.name || startStop + const endStopName = stops.find(s => String(s.id) === String(endStop))?.name || endStop + const res = await client.post('/admin/bus-lines', { lineNumber: Number(lineNumber), - startStop, - endStop, + startStop: startStopName, + endStop: endStopName, estimatedDurationMinutes: Number(estimatedDurationMinutes), description, }) @@ -91,13 +96,29 @@ export default function BusLinesManager({ token, user }) {
{line.estimatedDurationMinutes} min
{line.description ?
{line.description}
: null} {isAdmin && ( - + <> + + + )} ))} @@ -110,13 +131,13 @@ export default function BusLinesManager({ token, user }) { @@ -130,6 +151,60 @@ export default function BusLinesManager({ token, user }) { {message &&
{message}
} + {editingLine && ( +
+
+

Edit Bus Line

+
{ + e.preventDefault() + setMessage(null) + try { + const startStopName = stops.find(s => String(s.id) === String(editingLine.startStop))?.name || editingLine.startStop + const endStopName = stops.find(s => String(s.id) === String(editingLine.endStop))?.name || editingLine.endStop + + await client.patch(`/admin/bus-lines/${editingLine.id}`, { + lineNumber: Number(editingLine.lineNumber), + startStop: startStopName, + endStop: endStopName, + estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes), + description: editingLine.description, + }) + + setMessage('Bus line updated') + setEditingLine(null) + await loadBusLines() + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + }} className="stack-form"> +
+ setEditingLine({...editingLine, lineNumber: e.target.value})} /> + + +
+
+ setEditingLine({...editingLine, estimatedDurationMinutes: e.target.value})} /> + setEditingLine({...editingLine, description: e.target.value})} /> +
+
+ + +
+
+
+
+ )} + {deleteConfirm && (
diff --git a/admin_ui/src/components/FleetManager.jsx b/admin_ui/src/components/FleetManager.jsx index ced9c68..da88cfe 100644 --- a/admin_ui/src/components/FleetManager.jsx +++ b/admin_ui/src/components/FleetManager.jsx @@ -10,6 +10,8 @@ export default function FleetManager({ token, user }) { const [message, setMessage] = useState(null) const [canCreateBus, setCanCreateBus] = useState(true) const [isAdmin] = useState(Boolean(user?.userCode)) + const [editingBus, setEditingBus] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) const loadBuses = async () => { try { @@ -65,6 +67,12 @@ export default function FleetManager({ token, user }) { #{bus.id} {bus.name}
Seats: {bus.seatCapacity}
Plate: {bus.licensePlate}
+ {isAdmin && ( + <> + + + + )} ))}
@@ -83,6 +91,62 @@ export default function FleetManager({ token, user }) { )} {message &&
{message}
} + + {editingBus && ( +
+
+

Edit Bus

+
{ + e.preventDefault() + setMessage(null) + try { + await client.patch(`/admin/fleet/buses/${editingBus.id}`, { + name: editingBus.name, + seatCapacity: Number(editingBus.seatCapacity), + licensePlate: editingBus.licensePlate, + }) + setMessage('Bus updated') + setEditingBus(null) + await loadBuses() + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + }} className="stack-form"> +
+ setEditingBus({...editingBus, name: e.target.value})} /> + setEditingBus({...editingBus, seatCapacity: e.target.value})} /> + setEditingBus({...editingBus, licensePlate: e.target.value})} /> +
+
+ + +
+
+
+
+ )} + + {deleteConfirm && ( +
+
+

Delete Bus?

+

{deleteConfirm.name}

+
+ + +
+
+
+ )} ) } diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index 42c2c0f..6e4b5de 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -15,6 +15,7 @@ export default function ScheduleMaker({ token, user }) { const [isAdmin] = useState(Boolean(user?.userCode)) const [deleteConfirm, setDeleteConfirm] = useState(null) const [expandedSchedule, setExpandedSchedule] = useState(null) + const [routeStopsByLine, setRouteStopsByLine] = useState({}) // Form state const [selectedLine, setSelectedLine] = useState('') @@ -138,6 +139,18 @@ export default function ScheduleMaker({ token, user }) { return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) } + // Fetch route stops for a given line and cache them in state + const loadRouteStops = async (busLineId) => { + if (!busLineId) return + try { + const res = await client.get(`/admin/bus-lines/${busLineId}/stops`) + const routeStops = res.data.routeStops || [] + setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops })) + } catch (err) { + // ignore silently, message already used elsewhere + } + } + const getCandidateWindow = () => { const line = busLines.find(l => String(l.id) === String(selectedLine)) if (!line || !selectedDate || !startTime) return null @@ -385,25 +398,67 @@ export default function ScheduleMaker({ token, user }) { {expandedSchedule === schedule.id && (
- {schedule.departureTimes && schedule.departureTimes.length > 0 ? ( - - - {schedule.departureTimes.map(stop => ( - - - - - - ))} - -
- {stop.order}. - {stop.stopName} - {formatTime(stop.departureTime)} -
- ) : ( -

Geen haltes gekoppeld

- )} + {(() => { + const lineId = schedule.busLineId + const cached = routeStopsByLine[lineId] + if (!cached) { + // Load and show loading text + loadRouteStops(lineId) + return

Laden haltes...

+ } + + if (cached.length === 0) { + return

Geen haltes gekoppeld

+ } + + // Render editable list of stops with ETA inputs + return ( + + + {cached.map(rs => { + const departureDate = new Date(schedule.startTime) + departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0)) + return ( + + + + + + + ) + })} + +
{rs.stopOrder}.{rs.name}{formatTime(departureDate.toISOString())} + { + const val = e.target.value === '' ? '' : Number(e.target.value) + setRouteStopsByLine(prev => ({ + ...prev, + [lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item) + })) + }} + style={{ width: '5rem' }} + /> + +
+ ) + })()}
)} diff --git a/admin_ui/src/components/StopsManager.jsx b/admin_ui/src/components/StopsManager.jsx index aec54d3..692be04 100644 --- a/admin_ui/src/components/StopsManager.jsx +++ b/admin_ui/src/components/StopsManager.jsx @@ -10,6 +10,8 @@ export default function StopsManager({ token, user }) { const [message, setMessage] = useState(null) const [isAdmin] = useState(Boolean(user?.userCode)) const [deleteConfirm, setDeleteConfirm] = useState(null) + const [editingStop, setEditingStop] = useState(null) + const loadStops = async () => { try { @@ -84,6 +86,15 @@ export default function StopsManager({ token, user }) { Delete )} + {isAdmin && ( + + )} ))}
@@ -117,6 +128,40 @@ export default function StopsManager({ token, user }) { )} + + {editingStop && ( +
+
+

Edit Stop

+
{ + e.preventDefault() + setMessage(null) + try { + const res = await client.patch(`/admin/stops/${editingStop.id}`, { + name: editingStop.name, + latitude: parseFloat(editingStop.latitude), + longitude: parseFloat(editingStop.longitude), + }) + setMessage('Stop updated') + setEditingStop(null) + await loadStops() + } catch (err) { + setMessage('Error: ' + (err.response?.data?.error || err.message)) + } + }} className="stack-form"> +
+ setEditingStop({...editingStop, name: e.target.value})} /> + setEditingStop({...editingStop, latitude: e.target.value})} /> + setEditingStop({...editingStop, longitude: e.target.value})} /> +
+
+ + +
+
+
+
+ )} ) } diff --git a/bus_terminal/lib/screens/navigation_screen.dart b/bus_terminal/lib/screens/navigation_screen.dart index 35d078c..ed52ba7 100644 --- a/bus_terminal/lib/screens/navigation_screen.dart +++ b/bus_terminal/lib/screens/navigation_screen.dart @@ -11,10 +11,12 @@ import '../services/schedule_service.dart'; class NavigationScreen extends StatefulWidget { final schedule.Route route; + final List? initialStops; const NavigationScreen({ super.key, required this.route, + this.initialStops, }); @override @@ -54,13 +56,18 @@ class _NavigationScreenState extends State { final currentLoc = await _navigationService.getCurrentLocation(); setState(() => _currentLocation = currentLoc); - // Fetch stops for the bus line from the API (if available) - try { - _stops = await _scheduleService.getStops(widget.route.busLine.id); + // Use provided initial stops when available, otherwise fetch from API + if (widget.initialStops != null) { + _stops = widget.initialStops; _stopsFetchError = null; - } catch (e) { - _stops = []; - _stopsFetchError = e.toString().replaceAll('Exception: ', ''); + } else { + try { + _stops = await _scheduleService.getStops(widget.route.busLine.id); + _stopsFetchError = null; + } catch (e) { + _stops = []; + _stopsFetchError = e.toString().replaceAll('Exception: ', ''); + } } // Determine start and end points: prefer stop locations if available. diff --git a/bus_terminal/lib/screens/schedule_overview_screen.dart b/bus_terminal/lib/screens/schedule_overview_screen.dart index 98eb036..ad94f03 100644 --- a/bus_terminal/lib/screens/schedule_overview_screen.dart +++ b/bus_terminal/lib/screens/schedule_overview_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; -import '../models/schedule.dart'; +import '../models/schedule.dart' as sched; +import '../models/navigation.dart'; import '../services/auth_service.dart'; import '../services/schedule_service.dart'; +import '../screens/navigation_screen.dart'; class ScheduleOverviewScreen extends StatefulWidget { const ScheduleOverviewScreen({super.key}); @@ -14,7 +16,7 @@ class _ScheduleOverviewScreenState extends State { final _scheduleService = ScheduleService(); final _authService = AuthService(); - late Future> _scheduleFuture; + late Future> _scheduleFuture; DateTime _selectedDate = DateTime.now(); @override @@ -61,9 +63,9 @@ class _ScheduleOverviewScreenState extends State { } } - Future _showScheduleDetails(ServiceSchedule schedule) async { + Future _showScheduleDetails(sched.ServiceSchedule svc) async { try { - final stops = await _scheduleService.getScheduleStops(schedule.id); + final stops = await _scheduleService.getScheduleStops(svc.id); if (!mounted) return; await showModalBottomSheet( @@ -82,19 +84,19 @@ class _ScheduleOverviewScreenState extends State { padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [ Text( - 'Lijn ${schedule.lineNumber}', + 'Lijn ${svc.lineNumber}', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 8), Text( - schedule.busName, + svc.busName, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), Text( - '${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}', + '${_formatTime(svc.startTime)} - ${_formatTime(svc.endTime)}', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 12), @@ -102,11 +104,56 @@ class _ScheduleOverviewScreenState extends State { spacing: 8, runSpacing: 8, children: [ - _InfoChip(label: schedule.status), - _InfoChip(label: schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalig'), + _InfoChip(label: svc.status), + _InfoChip(label: svc.isRecurring ? _weekdayLabel(svc.weekdays) : 'Eenmalig'), ], ), const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () { + // Close the sheet and start navigation with provided stops + Navigator.of(sheetContext).pop(); + + final routeObj = sched.Route( + id: (svc.id is num) ? (svc.id as num).toInt() : int.tryParse(svc.id.toString()) ?? 0, + busLine: sched.BusLine( + id: svc.busLineId, + lineNumber: svc.lineNumber, + startStop: '', + endStop: '', + estimatedDuration: 0, + ), + busType: sched.BusType( + id: svc.busId, + name: svc.busName, + seatCapacity: 0, + licensePlate: '', + ), + startTime: svc.startTime, + endTime: svc.endTime, + status: svc.status, + ); + + final navStops = stops + .map((s) => NavigationPoint( + latitude: s.latitude, + longitude: s.longitude, + name: s.name, + )) + .toList(); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops), + ), + ); + }, + icon: const Icon(Icons.navigation), + label: const Text('Start route'), + ), + ), Text( 'Haltes', style: Theme.of(context).textTheme.titleMedium?.copyWith( @@ -121,7 +168,7 @@ class _ScheduleOverviewScreenState extends State { ) else ...stops.map((stop) { - final departureTime = schedule.startTime.add( + final departureTime = svc.startTime.add( Duration(minutes: stop.estimatedArrivalMinutes), ); return Card( @@ -240,7 +287,7 @@ class _ScheduleOverviewScreenState extends State { ), ), Expanded( - child: FutureBuilder>( + child: FutureBuilder>( future: _scheduleFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -339,7 +386,7 @@ class _ScheduleOverviewScreenState extends State { } class _ScheduleSummaryCard extends StatelessWidget { - final ServiceSchedule schedule; + final sched.ServiceSchedule schedule; final VoidCallback onTap; const _ScheduleSummaryCard({ diff --git a/transit_api/index.js b/transit_api/index.js index 8a107ca..660541f 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -731,9 +731,130 @@ app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) } }); +// ============================================ +// ADMIN - Update fleet bus +// ============================================ +app.patch('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { busId } = req.params; + const { name, seatCapacity, licensePlate } = req.body; + + if (!busId || !Number.isFinite(Number(busId))) { + return res.status(400).json({ error: 'invalid_bus_id' }); + } + + if (DEV_MOCK) { + const bus = _mock.fleetBuses.find(b => Number(b.id) === Number(busId)); + if (!bus) return res.status(404).json({ error: 'bus_not_found' }); + if (name !== undefined) bus.name = name; + if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity); + if (licensePlate !== undefined) bus.licensePlate = licensePlate; + bus.updatedAt = new Date().toISOString(); + return res.json({ bus }); + } + + const result = await db.query(` + UPDATE fleet.bus + SET name = COALESCE($1, name), seat_capacity = COALESCE($2, seat_capacity), license_plate = COALESCE($3, license_plate), updated_at = NOW() + WHERE id = $4 + RETURNING id, name, seat_capacity, license_plate + `, [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId]); + + if (!result.rows[0]) return res.status(404).json({ error: 'bus_not_found' }); + + const bus = result.rows[0]; + res.json({ bus: { id: bus.id, name: bus.name, seatCapacity: bus.seat_capacity, licensePlate: bus.license_plate } }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// ADMIN - Delete fleet bus +// ============================================ +app.delete('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { busId } = req.params; + if (!busId || !Number.isFinite(Number(busId))) { + return res.status(400).json({ error: 'invalid_bus_id' }); + } + + if (DEV_MOCK) { + const idx = _mock.fleetBuses.findIndex(b => Number(b.id) === Number(busId)); + if (idx === -1) return res.status(404).json({ error: 'bus_not_found' }); + const deleted = _mock.fleetBuses.splice(idx, 1)[0]; + return res.json({ success: true, bus: deleted }); + } + + const result = await db.query(`DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`, [busId]); + if (result.rows.length === 0) return res.status(404).json({ error: 'bus_not_found' }); + res.json({ success: true, bus: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + // ============================================ // ADMIN - Link stop to bus line // ============================================ +app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => { + 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 stops = _mock.routeStops + .filter(rs => String(rs.busLineId) === String(busLineId)) + .sort((a, b) => a.stopOrder - b.stopOrder) + .map(rs => ({ + id: rs.id, + busLineId: rs.busLineId, + stopId: rs.stopId, + stopOrder: rs.stopOrder, + estimatedArrivalMinutes: rs.estimatedArrivalMinutes, + name: rs.name, + latitude: rs.latitude, + longitude: rs.longitude, + })); + return res.json({ routeStops: stops }); + } + + const result = await db.query(` + SELECT + rs.id, + rs.bus_line_id, + rs.stop_id, + rs.stop_order, + rs.estimated_arrival_minutes, + s.name as stop_name, + s.latitude, + s.longitude + 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({ routeStops: result.rows.map(row => ({ + id: row.id, + busLineId: row.bus_line_id, + stopId: row.stop_id, + stopOrder: row.stop_order, + estimatedArrivalMinutes: row.estimated_arrival_minutes, + name: row.stop_name, + latitude: parseFloat(row.latitude), + longitude: parseFloat(row.longitude), + })) }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => { try { const { busLineId } = req.params; @@ -952,6 +1073,55 @@ app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, } }); +// ============================================ +// ADMIN - Update a stop +// ============================================ +app.patch('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { stopId } = req.params; + const { name, latitude, longitude } = req.body; + + if (!stopId || !Number.isFinite(Number(stopId))) { + return res.status(400).json({ error: 'invalid_stop_id' }); + } + + if (DEV_MOCK) { + const stop = _mock.stops.find(s => Number(s.id) === Number(stopId)); + if (!stop) return res.status(404).json({ error: 'stop_not_found' }); + if (name !== undefined) stop.name = name; + if (latitude !== undefined) stop.latitude = parseFloat(latitude); + if (longitude !== undefined) stop.longitude = parseFloat(longitude); + stop.updatedAt = new Date().toISOString(); + return res.json({ stop }); + } + + const result = await db.query( + `UPDATE transit.stops + SET name = COALESCE($1, name), + latitude = COALESCE($2, latitude), + longitude = COALESCE($3, longitude), + updated_at = NOW() + WHERE id = $4 + RETURNING id, name, latitude, longitude, created_at, updated_at` + , [name ?? null, latitude ?? null, longitude ?? null, stopId]); + + if (!result.rows[0]) return res.status(404).json({ error: 'stop_not_found' }); + + const stop = result.rows[0]; + res.json({ stop: { + id: stop.id, + name: stop.name, + latitude: parseFloat(stop.latitude), + longitude: parseFloat(stop.longitude), + createdAt: stop.created_at, + updatedAt: stop.updated_at, + }}); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + // ============================================ // ADMIN - Delete a bus line // ============================================