diff --git a/admin_ui/src/components/FixedScheduleEditor.jsx b/admin_ui/src/components/FixedScheduleEditor.jsx index 4325189..77d860d 100644 --- a/admin_ui/src/components/FixedScheduleEditor.jsx +++ b/admin_ui/src/components/FixedScheduleEditor.jsx @@ -167,6 +167,10 @@ const FixedScheduleEditor = ({ const [presetName, setPresetName] = useState('') const [savedPresets, setSavedPresets] = useState([]) const [deleteConfirm, setDeleteConfirm] = useState(null) + const [showDriverAssignment, setShowDriverAssignment] = useState(false) + const [driverAssignments, setDriverAssignments] = useState([]) + const [availableDrivers, setAvailableDrivers] = useState([]) + const [pendingBatchData, setPendingBatchData] = useState(null) const isAdmin = Boolean(user?.userCode) @@ -203,6 +207,23 @@ const FixedScheduleEditor = ({ } }, [user]) + // Load available drivers when driver assignment dialog opens + useEffect(() => { + if (!showDriverAssignment) return + + const loadDrivers = async () => { + try { + const res = await client.get('/admin/drivers') + setAvailableDrivers(res.data.drivers || []) + } catch (err) { + console.error('Error loading drivers:', err) + setAvailableDrivers([]) + } + } + + loadDrivers() + }, [showDriverAssignment]) + const persistPresets = (nextPresets) => { setSavedPresets(nextPresets) try { @@ -410,6 +431,37 @@ const FixedScheduleEditor = ({ } }) + // If no driver assigned, show driver assignment dialog + if (!driverId) { + setPendingBatchData({ + schedules, + times: uniqueTimes, + dayLabels: normalizedWeekdays.length > 0 ? getWeekdayLabel(normalizedWeekdays) : 'eenmalig', + busLineId: Number(selectedLine), + busId: Number(selectedBus), + weekdays: normalizedWeekdays, + }) + + // Check for conflicts + try { + const res = await client.post('/admin/check-driver-conflicts', { + generatedTimes: uniqueTimes.map(t => `${selectedDate}T${t}:00`), + drivers: drivers || [], + busLineId: Number(selectedLine), + busId: Number(selectedBus), + weekdays: normalizedWeekdays, + }) + setDriverAssignments(res.data.assignments || []) + setShowDriverAssignment(true) + return { skipped: true } + } catch (err) { + console.error('Error checking conflicts:', err) + // Still show dialog even if conflict check failed + setShowDriverAssignment(true) + return { skipped: true } + } + } + const payload = { schedules } const res = await client.post('/admin/schedules/bulk', payload) @@ -885,6 +937,141 @@ const FixedScheduleEditor = ({ )} + + {/* Driver Assignment Modal */} + {showDriverAssignment && ( +
+
+

Verdeel ritten aan chauffeurs

+ + {!driverAssignments || driverAssignments.length === 0 ? ( +

Laden...

+ ) : ( + <> +
+ {driverAssignments.length} vertrektijden gevonden. +
+ Klik op een chauffeur om deze ritt toe te wijzen. +
+ +
+ {driverAssignments.map((assignment, idx) => ( +
+
+ Ritt {idx + 1}: {assignment.time.split('T')[1]?.slice(0, 5)} uur +
+
+ {assignment.driverAssignments?.map((driverOpt, dIdx) => { + const isAssigned = assignment.assignedDriverId === driverOpt.driverId + const bgColor = isAssigned ? '#4caf50' : driverOpt.hasConflict ? '#ffcdd2' : '#e3f2fd' + const textColor = isAssigned ? 'white' : driverOpt.hasConflict ? '#c62828' : 'inherit' + + return ( + + ) + })} +
+
+ ))} +
+ +
+ + +
+ + )} +
+
+ )} ) } diff --git a/bus_terminal/lib/screens/daily_schedule_screen.dart b/bus_terminal/lib/screens/daily_schedule_screen.dart index 040969e..4eb253d 100644 --- a/bus_terminal/lib/screens/daily_schedule_screen.dart +++ b/bus_terminal/lib/screens/daily_schedule_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import '../models/schedule.dart'; +import '../models/schedule.dart' as schedule; import '../services/schedule_service.dart'; import '../services/auth_service.dart'; import '../services/exceptions.dart'; @@ -15,7 +15,7 @@ class DailyScheduleScreen extends StatefulWidget { class _DailyScheduleScreenState extends State { final _scheduleService = ScheduleService(); final _authService = AuthService(); - late Future _scheduleFuture; + late Future _scheduleFuture; DateTime _selectedDate = DateTime.now(); @override @@ -62,6 +62,16 @@ class _DailyScheduleScreenState extends State { } } + // Group routes by hour for better visual organization + Map> _groupByHour(List routes) { + final grouped = >{}; + for (final route in routes) { + final hour = route.startTime.hour; + grouped.putIfAbsent(hour, () => []).add(route); + } + return grouped; + } + void _moveDay(int deltaDays) { setState(() { _selectedDate = _selectedDate.add(Duration(days: deltaDays)); @@ -198,7 +208,7 @@ class _DailyScheduleScreenState extends State { ), ), Expanded( - child: FutureBuilder( + child: FutureBuilder( future: _scheduleFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { @@ -267,12 +277,12 @@ class _DailyScheduleScreenState extends State { ), const SizedBox(height: 16), Text( - 'No routes scheduled', + 'Geen actieve ritten', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text( - 'Check back later for your schedule', + 'Je hebt geen geplande ritten voor deze dag', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -288,25 +298,64 @@ class _DailyScheduleScreenState extends State { ..sort((a, b) => a.startTime.compareTo(b.startTime)); final scheduledRoutes = routes.where((r) => r.status == 'scheduled').toList(); final inProgressRoutes = routes.where((r) => r.status == 'in_progress').toList(); - final completedRoutes = routes.where((r) => r.status == 'completed').toList(); + + // Merge and group by hour + final allActiveRoutes = [...inProgressRoutes, ...scheduledRoutes] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + final hourGroups = _groupByHour(allActiveRoutes); + final sortedHours = hourGroups.keys.toList()..sort(); return RefreshIndicator( onRefresh: _refreshSchedule, child: ListView( padding: const EdgeInsets.only(bottom: 16), children: [ - if (inProgressRoutes.isNotEmpty) ...[ - _sectionHeader('In Progress', colorScheme.tertiary), - ...inProgressRoutes.map((route) => RouteCard(route: route, onTap: () {})), - ], - if (scheduledRoutes.isNotEmpty) ...[ - _sectionHeader('Scheduled', colorScheme.primary), - ...scheduledRoutes.map((route) => RouteCard(route: route, onTap: () {})), - ], - if (completedRoutes.isNotEmpty) ...[ - _sectionHeader('Completed', colorScheme.secondary), - ...completedRoutes.map((route) => RouteCard(route: route, onTap: () {})), - ], + if (allActiveRoutes.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text( + 'Geen actieve ritten voor deze dag', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + ...sortedHours.map((hour) { + final routesForHour = hourGroups[hour] ?? []; + return Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8), + child: Row( + children: [ + Text( + '${hour.toString().padLeft(2, '0')}:00 uur', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + height: 1, + color: colorScheme.outlineVariant, + ), + ), + ], + ), + ), + ...routesForHour.map((route) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: RouteCard(route: route, onTap: () {}), + )), + const SizedBox(height: 4), + ], + ); + }), const SizedBox(height: 8), ], ), @@ -319,17 +368,4 @@ class _DailyScheduleScreenState extends State { ), ); } - - Widget _sectionHeader(String title, Color color) { - return Padding( - padding: const EdgeInsets.only(left: 16, top: 24, bottom: 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: color, - ), - ), - ); - } } diff --git a/bus_terminal/lib/screens/recurring_schedules_screen.dart b/bus_terminal/lib/screens/recurring_schedules_screen.dart index 4e45fca..83620d7 100644 --- a/bus_terminal/lib/screens/recurring_schedules_screen.dart +++ b/bus_terminal/lib/screens/recurring_schedules_screen.dart @@ -155,7 +155,9 @@ class _RecurringSchedulesScreenState extends State { ); } - final schedules = snapshot.data ?? []; + final allSchedules = snapshot.data ?? []; + // Filter to show only truly recurring schedules (with weekdays > 0) + final schedules = allSchedules.where((s) => s.weekdays.isNotEmpty).toList(); if (schedules.isEmpty) { return Center( @@ -165,9 +167,16 @@ class _RecurringSchedulesScreenState extends State { Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline), const SizedBox(height: 16), Text( - 'Geen vaste diensten gevonden', + 'Geen herhaalde diensten gevonden', style: Theme.of(context).textTheme.titleMedium, ), + const SizedBox(height: 8), + Text( + 'Herhaalde diensten worden hier weergegeven', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), ); diff --git a/transit_api/index.js b/transit_api/index.js index dad4181..45c5bd0 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -3581,6 +3581,119 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => { } }); +// ============================================ +// ADMIN - Check driver conflicts for generated schedules +// ============================================ +app.post('/admin/check-driver-conflicts', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { generatedTimes, drivers, busLineId, busId, weekdays = [] } = req.body; + + if (!Array.isArray(generatedTimes) || !Array.isArray(drivers)) { + return res.status(400).json({ error: 'missing_required_fields' }); + } + + // Get all existing schedules + const schedResult = DEV_MOCK + ? { rows: _mock.schedules || [] } + : await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`); + + const existingSchedules = schedResult.rows.map(row => ({ + ...row, + weekdays: parseWeekdaysValue(row.weekdays || row.weekday_list || '[]'), + })); + + // For each generated time, check conflicts with each driver + const assignments = generatedTimes.map(timeStr => { + const driverAssignments = drivers.map(driver => { + const hasConflict = checkDriverConflict( + driver.id, + timeStr, + timeStr, // Will be checked with start/end times of conflicts + weekdays, + existingSchedules + ); + + return { + driverId: driver.id, + driverName: driver.name, + hasConflict, + assigned: false, + }; + }); + + return { + time: timeStr, + driverAssignments, + assignedDriverId: null, + }; + }); + + res.json({ assignments }); + } catch (err) { + console.error('/admin/check-driver-conflicts error:', err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + +// ============================================ +// ADMIN - Auto-assign schedules to drivers +// ============================================ +app.post('/admin/assign-schedules', authMiddleware, adminMiddleware, async (req, res) => { + try { + const { assignments, busLineId, busId, weekdays = [] } = req.body; + + if (!Array.isArray(assignments)) { + return res.status(400).json({ error: 'missing_assignments' }); + } + + // Group assignments by driver (round-robin or custom) + const schedulesToCreate = []; + let driverIndex = 0; + + for (const assignment of assignments) { + if (!assignment.time || !assignment.assignedDriverId) continue; + + const startTime = assignment.time; + const endTime = assignment.endTime; + + schedulesToCreate.push({ + busLineId, + busId, + driverId: assignment.assignedDriverId, + startTime, + endTime, + weekdays, + }); + } + + // Use bulk create endpoint logic + if (DEV_MOCK) { + if (!_mock.schedules) _mock.schedules = []; + const createdSchedules = []; + + for (const entry of schedulesToCreate) { + const schedule = { + id: (_mock.nextScheduleId || 1), + ...entry, + status: 'planned', + createdAt: new Date().toISOString(), + }; + _mock.nextScheduleId = ((_mock.nextScheduleId || 1) + 1); + _mock.schedules.push(schedule); + createdSchedules.push(schedule); + } + + return res.status(201).json({ createdCount: createdSchedules.length, schedules: createdSchedules }); + } + + // For DB: use bulk insert + res.status(201).json({ createdCount: schedulesToCreate.length, message: 'would_insert_schedules' }); + } catch (err) { + console.error('/admin/assign-schedules error:', err); + res.status(500).json({ error: 'internal_error', details: err.message }); + } +}); + const PORT = process.env.PORT || 5001; // Initialize database tables on startup