Enhance FixedScheduleEditor; implement driver assignment dialog and conflict checking for bulk schedules

This commit is contained in:
Sven laptop
2026-05-14 01:30:50 +02:00
parent ad48e79db2
commit 0e10757c49
4 changed files with 378 additions and 33 deletions
@@ -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 = ({
</div>
</div>
)}
{/* Driver Assignment Modal */}
{showDriverAssignment && (
<div style={overlay}>
<div style={{ ...modal, maxWidth: '600px', maxHeight: '80vh', overflowY: 'auto' }}>
<h3 style={{ marginTop: 0, marginBottom: '1rem' }}>Verdeel ritten aan chauffeurs</h3>
{!driverAssignments || driverAssignments.length === 0 ? (
<p>Laden...</p>
) : (
<>
<div style={{ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' }}>
<strong>{driverAssignments.length}</strong> vertrektijden gevonden.
<br />
Klik op een chauffeur om deze ritt toe te wijzen.
</div>
<div style={{ marginBottom: '1.5rem' }}>
{driverAssignments.map((assignment, idx) => (
<div key={idx} style={{ marginBottom: '1rem', padding: '0.75rem', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>
Ritt {idx + 1}: {assignment.time.split('T')[1]?.slice(0, 5)} uur
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '0.5rem' }}>
{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 (
<button
key={dIdx}
onClick={() => {
setDriverAssignments(prev => prev.map((a, aIdx) =>
aIdx === idx ? { ...a, assignedDriverId: driverOpt.driverId } : a
))
}}
style={{
padding: '0.5rem',
backgroundColor: bgColor,
color: textColor,
border: isAssigned ? '2px solid #2e7d32' : '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: isAssigned ? 'bold' : 'normal',
}}
title={driverOpt.hasConflict ? 'Conflict: chauffeur al bezet' : 'Klik om toe te wijzen'}
>
{driverOpt.driverName?.split(' ')[0]}
{driverOpt.hasConflict && ' ⚠️'}
</button>
)
})}
</div>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDriverAssignment(false)}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Annuleren
</button>
<button
onClick={async () => {
// Finalize and create schedules
if (!pendingBatchData) return
try {
const assignmentsWithEndTime = driverAssignments.map((assignment, idx) => {
const nextTime = driverAssignments[idx + 1]?.time
const endTime = nextTime || addMinutesToTime(assignment.time.split('T')[1], 60) // Default 1 hour
return {
time: assignment.time,
endTime,
assignedDriverId: assignment.assignedDriverId,
}
})
const payload = {
assignments: assignmentsWithEndTime,
busLineId: pendingBatchData.busLineId,
busId: pendingBatchData.busId,
weekdays: pendingBatchData.weekdays,
}
// Use bulk endpoint with driver assignments
const schedules = assignmentsWithEndTime
.filter(a => a.assignedDriverId)
.map(a => ({
busLineId: pendingBatchData.busLineId,
busId: pendingBatchData.busId,
driverId: a.assignedDriverId,
startTime: a.time,
weekdays: pendingBatchData.weekdays,
}))
const res = await client.post('/admin/schedules/bulk', { schedules })
setMessage(`✓ ${res.data.createdCount || schedules.length} diensten aangemaakt met chauffeurs`)
setShowDriverAssignment(false)
setPendingBatchData(null)
resetForm()
onScheduleChange?.()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
setMessage('Error: ' + errMsg)
}
}}
style={{
padding: '0.5rem 1rem',
backgroundColor: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Opslaan
</button>
</div>
</>
)}
</div>
</div>
)}
</section>
)
}
@@ -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<DailyScheduleScreen> {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future<DailySchedule> _scheduleFuture;
late Future<schedule.DailySchedule> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
@override
@@ -62,6 +62,16 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
}
}
// Group routes by hour for better visual organization
Map<int, List<schedule.Route>> _groupByHour(List<schedule.Route> routes) {
final grouped = <int, List<schedule.Route>>{};
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<DailyScheduleScreen> {
),
),
Expanded(
child: FutureBuilder<DailySchedule>(
child: FutureBuilder<schedule.DailySchedule>(
future: _scheduleFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
@@ -267,12 +277,12 @@ class _DailyScheduleScreenState extends State<DailyScheduleScreen> {
),
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<DailyScheduleScreen> {
..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<DailyScheduleScreen> {
),
);
}
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,
),
),
);
}
}
@@ -155,7 +155,9 @@ class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
);
}
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<RecurringSchedulesScreen> {
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,
),
),
],
),
);
+113
View File
@@ -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