From 6e62a9cac16ec3a30fcbaa230a0a1c01333efa24 Mon Sep 17 00:00:00 2001 From: Sven laptop Date: Wed, 13 May 2026 18:15:08 +0200 Subject: [PATCH] Enhance schedule management; implement recurring schedules retrieval, update TimetableViewer to display summary, and improve schedule loading logic --- admin_ui/src/components/TimetableViewer.jsx | 30 +++- .../screens/recurring_schedules_screen.dart | 5 +- .../lib/services/schedule_service.dart | 25 ++++ transit_api/index.js | 132 ++++++++++++++++++ 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/admin_ui/src/components/TimetableViewer.jsx b/admin_ui/src/components/TimetableViewer.jsx index 4174609..7196c47 100644 --- a/admin_ui/src/components/TimetableViewer.jsx +++ b/admin_ui/src/components/TimetableViewer.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' }) { const [schedules, setSchedules] = useState([]) + const [summary, setSummary] = useState(null) const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) const [selectedLineId, setSelectedLineId] = useState('') const [lines, setLines] = useState([]) @@ -28,13 +29,14 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' } setLoading(true) setError(null) try { - let url = `${apiBaseUrl}/schedules?date=${selectedDate}` + let url = `${apiBaseUrl}/schedules/overview?date=${selectedDate}` if (selectedLineId) { url += `&lineId=${selectedLineId}` } const res = await fetch(url) const data = await res.json() setSchedules(data.schedules || []) + setSummary(data.summary || null) } catch (err) { setError('Failed to load schedules: ' + err.message) } finally { @@ -72,6 +74,20 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' } + {summary && ( +
+ + Total: {summary.total} + + + Vast: {summary.recurring} + + + Eenmalig: {summary.oneTime} + +
+ )} + {loading &&

Loading schedules...

} {error &&

{error}

} @@ -81,7 +97,8 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' } Time Line Vehicle - Status + Day status + Delay @@ -104,14 +121,19 @@ export default function TimetableViewer({ apiBaseUrl = 'http://localhost:5001' } style={{ padding: '4px 8px', borderRadius: '4px', - backgroundColor: schedule.status === 'active' ? '#dcfce7' : '#f3f4f6', - color: schedule.status === 'active' ? '#166534' : '#374151', + backgroundColor: schedule.status === 'in_progress' ? '#dbeafe' : schedule.status === 'completed' ? '#dcfce7' : '#f3f4f6', + color: schedule.status === 'in_progress' ? '#1d4ed8' : schedule.status === 'completed' ? '#166534' : '#374151', fontSize: '0.9rem', }} > {schedule.status} + + {typeof schedule.currentDelayMinutes === 'number' && schedule.currentDelayMinutes !== null + ? `${schedule.currentDelayMinutes > 0 ? '+' : ''}${schedule.currentDelayMinutes} min` + : '—'} + )) })} diff --git a/bus_terminal/lib/screens/recurring_schedules_screen.dart b/bus_terminal/lib/screens/recurring_schedules_screen.dart index c66f936..4e45fca 100644 --- a/bus_terminal/lib/screens/recurring_schedules_screen.dart +++ b/bus_terminal/lib/screens/recurring_schedules_screen.dart @@ -24,10 +24,7 @@ class _RecurringSchedulesScreenState extends State { } void _loadRecurringSchedules() { - // Load schedules from a reference date, then filter for recurring ones - _schedulesFuture = _scheduleService.getSchedules(DateTime.now()).then((schedules) { - return schedules.where((s) => s.isRecurring).toList(); - }); + _schedulesFuture = _scheduleService.getRecurringSchedules(); } String _formatWeekdays(List weekdays) { diff --git a/bus_terminal/lib/services/schedule_service.dart b/bus_terminal/lib/services/schedule_service.dart index 2d67c38..1115a62 100644 --- a/bus_terminal/lib/services/schedule_service.dart +++ b/bus_terminal/lib/services/schedule_service.dart @@ -38,6 +38,31 @@ class ScheduleService { } } + /// Get all recurring schedule templates, independent of a specific date. + Future> getRecurringSchedules({int? lineId}) async { + try { + final queryParameters = {}; + if (lineId != null) { + queryParameters['lineId'] = lineId.toString(); + } + + final uri = Uri.parse('$_apiBaseUrl/schedules/recurring').replace(queryParameters: queryParameters); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return (data['schedules'] as List? ?? []) + .map((schedule) => schedule as Map) + .map(ServiceSchedule.fromJson) + .toList(); + } + + throw Exception('Failed to load recurring schedules: ${response.statusCode}'); + } catch (e) { + throw Exception('Recurring schedule error: $e'); + } + } + /// Get stop list for a specific schedule. Future> getScheduleStops(int scheduleId) async { try { diff --git a/transit_api/index.js b/transit_api/index.js index d79e7d6..f9f377a 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -2736,6 +2736,138 @@ app.get('/schedules/overview', async (req, res) => { } }); +// ============================================ +// PUBLIC - Get all recurring schedule templates (no date filtering) +// ============================================ +app.get('/schedules/recurring', async (req, res) => { + try { + const filters = parseScheduleFilters(req.query); + + if (DEV_MOCK) { + const routeStopsByLine = new Map(); + + for (const routeStop of _mock.routeStops || []) { + const busLineId = Number(routeStop.busLineId); + if (!routeStopsByLine.has(busLineId)) { + routeStopsByLine.set(busLineId, []); + } + + routeStopsByLine.get(busLineId).push({ + bus_line_id: busLineId, + stop_id: routeStop.stopId, + stop_order: routeStop.stopOrder, + estimated_arrival_minutes: routeStop.estimatedArrivalMinutes, + stop_name: routeStop.name || '', + }); + } + + const baseRows = (_mock.schedules || []) + .filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0) + .map(schedule => { + const busLine = _mock.busLines.find(line => Number(line.id) === Number(schedule.busLineId)); + const bus = _mock.fleetBuses.find(vehicle => Number(vehicle.id) === Number(schedule.busId)); + const driver = _mock.drivers.find(person => Number(person.id) === Number(schedule.driverId)); + + return { + id: schedule.id, + busLineId: schedule.busLineId, + busId: schedule.busId, + driverId: schedule.driverId ?? null, + driverName: driver ? driver.name : null, + startTime: schedule.startTime, + endTime: schedule.endTime, + weekdays: schedule.weekdays || [], + lineNumber: busLine?.lineNumber || 0, + busName: bus?.name || 'Unknown Bus', + licensePlate: bus?.licensePlate || '', + createdAt: schedule.createdAt, + }; + }); + + const schedules = baseRows + .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)) + .map(row => { + const materialized = materializeScheduleOverviewRow(row, null, routeStopsByLine); + return { + ...materialized, + status: undefined, + }; + }); + + return res.json({ schedules }); + } + + const hasWeekdaysColumn = await db.query(` + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'transit' AND table_name = 'schedules' AND column_name = 'weekdays' + `); + + if (hasWeekdaysColumn.rows.length === 0) { + return res.json({ schedules: [] }); + } + + const result = await db.query(` + SELECT + s.id, + s.bus_line_id AS "busLineId", + s.bus_id AS "busId", + s.driver_id AS "driverId", + s.start_time AS "startTime", + s.end_time AS "endTime", + s.weekdays, + bl.line_number AS "lineNumber", + b.name AS "busName", + b.license_plate AS "licensePlate", + TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) AS "driverName", + s.created_at AS "createdAt" + 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 + LEFT JOIN workers.employees e ON s.driver_id = e.id + WHERE s.weekdays IS NOT NULL + `); + + const lineIds = Array.from(new Set(result.rows.map(row => Number(row.busLineId)).filter(Number.isFinite))); + const routeStopsByLine = new Map(); + + if (lineIds.length > 0) { + const routeStopsResult = await db.query(` + SELECT + rs.bus_line_id, + rs.stop_id, + rs.stop_order, + rs.estimated_arrival_minutes, + s.name AS stop_name + FROM transit.route_stops rs + JOIN transit.stops s ON s.id = rs.stop_id + WHERE rs.bus_line_id = ANY($1::int[]) + ORDER BY rs.bus_line_id ASC, rs.stop_order ASC + `, [lineIds]); + + for (const row of routeStopsResult.rows) { + const busLineId = Number(row.bus_line_id); + if (!routeStopsByLine.has(busLineId)) { + routeStopsByLine.set(busLineId, []); + } + routeStopsByLine.get(busLineId).push(row); + } + } + + const schedules = result.rows + .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine)) + .map(row => { + const schedule = materializeScheduleOverviewRow(row, null, routeStopsByLine); + delete schedule.status; + return schedule; + }); + + res.json({ schedules }); + } 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;