Add scheduling availability indicators for drivers and vehicles

This commit is contained in:
Sven laptop
2026-05-06 19:40:58 +02:00
parent 619a825137
commit b6f6c92392
2 changed files with 311 additions and 42 deletions
+82 -4
View File
@@ -138,6 +138,74 @@ export default function ScheduleMaker({ token, user }) {
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }) return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
} }
const getCandidateWindow = () => {
const line = busLines.find(l => String(l.id) === String(selectedLine))
if (!line || !selectedDate || !startTime) return null
const start = new Date(`${selectedDate}T${startTime}:00`)
const end = new Date(start)
end.setMinutes(end.getMinutes() + (line.estimatedDurationMinutes || 60))
return { start, end }
}
const hasOverlap = (candidate, existing) => {
if (!candidate || !existing?.startTime || !existing?.endTime) return false
const cStart = candidate.start.getTime()
const cEnd = candidate.end.getTime()
const eStart = new Date(existing.startTime).getTime()
const eEnd = new Date(existing.endTime).getTime()
const timeOverlap = cStart < eEnd && cEnd > eStart
if (!timeOverlap) return false
const cWeekdays = selectedWeekdays || []
const eWeekdays = Array.isArray(existing.weekdays) ? existing.weekdays : []
if (cWeekdays.length === 0 && eWeekdays.length === 0) {
return candidate.start.toDateString() === new Date(existing.startTime).toDateString()
}
if (cWeekdays.length > 0 && eWeekdays.length > 0) {
return cWeekdays.some(d => eWeekdays.includes(d))
}
if (cWeekdays.length > 0 && eWeekdays.length === 0) {
return cWeekdays.includes(new Date(existing.startTime).getDay())
}
return eWeekdays.includes(candidate.start.getDay())
}
const candidateWindow = getCandidateWindow()
const busyDriverIds = new Set(
schedules
.filter(s => s.driverId && hasOverlap(candidateWindow, s))
.map(s => Number(s.driverId))
)
const busyBusIds = new Set(
schedules
.filter(s => s.busId && hasOverlap(candidateWindow, s))
.map(s => Number(s.busId))
)
const scheduleHasDriverConflict = (schedule) => {
if (!schedule?.driverId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.driverId) === Number(schedule.driverId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
const scheduleHasBusConflict = (schedule) => {
if (!schedule?.busId) return false
return schedules.some(other =>
other.id !== schedule.id &&
Number(other.busId) === Number(schedule.busId) &&
hasOverlap({ start: new Date(schedule.startTime), end: new Date(schedule.endTime) }, other)
)
}
// Group schedules by date and time (apply driver filter if selected) // Group schedules by date and time (apply driver filter if selected)
const timeSlots = generateTimeSlots() const timeSlots = generateTimeSlots()
const filteredSchedules = selectedDriverFilter const filteredSchedules = selectedDriverFilter
@@ -181,8 +249,8 @@ export default function ScheduleMaker({ token, user }) {
<select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}> <select value={selectedBus} onChange={e => setSelectedBus(e.target.value)}>
<option value="">Kies voertuig</option> <option value="">Kies voertuig</option>
{buses.map(bus => ( {buses.map(bus => (
<option key={bus.id} value={bus.id}> <option key={bus.id} value={bus.id} disabled={busyBusIds.has(Number(bus.id))}>
{bus.name} ({bus.licensePlate}) {bus.name} ({bus.licensePlate}) {busyBusIds.has(Number(bus.id)) ? '- in gebruik' : ''}
</option> </option>
))} ))}
</select> </select>
@@ -192,8 +260,8 @@ export default function ScheduleMaker({ token, user }) {
<select value={driverId} onChange={e => setDriverId(e.target.value)}> <select value={driverId} onChange={e => setDriverId(e.target.value)}>
<option value="">Bestuurder (optioneel)</option> <option value="">Bestuurder (optioneel)</option>
{drivers.map(driver => ( {drivers.map(driver => (
<option key={driver.id} value={driver.id}> <option key={driver.id} value={driver.id} disabled={busyDriverIds.has(Number(driver.id))}>
{driver.name} {driver.name} {busyDriverIds.has(Number(driver.id)) ? '- in gebruik' : ''}
</option> </option>
))} ))}
</select> </select>
@@ -280,6 +348,16 @@ export default function ScheduleMaker({ token, user }) {
{schedule.driverName} {schedule.driverName}
</div> </div>
)} )}
{scheduleHasDriverConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Chauffeur in gebruik
</div>
)}
{scheduleHasBusConflict(schedule) && (
<div style={{ fontSize: '0.75rem', color: '#b45309', marginTop: '0.25rem', fontWeight: 600 }}>
Voertuig in gebruik
</div>
)}
{schedule.weekdays && schedule.weekdays.length > 0 && ( {schedule.weekdays && schedule.weekdays.length > 0 && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}> <div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{getWeekdayLabel(schedule.weekdays)} {getWeekdayLabel(schedule.weekdays)}
+206 -15
View File
@@ -87,6 +87,117 @@ function calculateDepartureTimes(startTime, busLineId, mock) {
}); });
} }
function parseWeekdaysValue(value) {
if (!value) return [];
if (Array.isArray(value)) return value.map(v => Number(v)).filter(v => Number.isFinite(v));
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map(v => Number(v)).filter(v => Number.isFinite(v));
}
} catch (_) {
// Fall through to CSV/PG-array parsing.
}
// Handles values like "1,2,3" or "{1,2,3}".
const normalized = trimmed.replace('{', '').replace('}', '');
return normalized
.split(',')
.map(v => Number(v.trim()))
.filter(v => Number.isFinite(v));
}
return [];
}
/**
* Check if two schedules have date/weekday overlap
*/
function doSchedulesDateOverlap(schedule1Start, schedule1End, schedule1Weekdays, schedule2Start, schedule2End, schedule2Weekdays) {
const s1Start = new Date(schedule1Start);
const s1End = new Date(schedule1End);
const s2Start = new Date(schedule2Start);
const s2End = new Date(schedule2End);
// Check time overlap
if (s1End <= s2Start || s2End <= s1Start) {
return false; // No time overlap
}
// If both have no weekdays (one-time), check if same day
if ((!schedule1Weekdays || schedule1Weekdays.length === 0) &&
(!schedule2Weekdays || schedule2Weekdays.length === 0)) {
return s1Start.toDateString() === s2Start.toDateString();
}
// If one has weekdays and other doesn't, check if one-time falls on weekday
if (schedule1Weekdays && schedule1Weekdays.length > 0 &&
(!schedule2Weekdays || schedule2Weekdays.length === 0)) {
const s2Day = s2Start.getDay(); // 0=Sun, 1=Mon, etc.
const s2Adjusted = s2Day === 0 ? 0 : s2Day; // Normalize to 0-6
return schedule1Weekdays.includes(s2Adjusted);
}
if ((!schedule1Weekdays || schedule1Weekdays.length === 0) &&
schedule2Weekdays && schedule2Weekdays.length > 0) {
const s1Day = s1Start.getDay();
const s1Adjusted = s1Day === 0 ? 0 : s1Day;
return schedule2Weekdays.includes(s1Adjusted);
}
// Both have weekdays: check if any day overlaps
if (schedule1Weekdays && schedule2Weekdays) {
return schedule1Weekdays.some(d => schedule2Weekdays.includes(d));
}
return false;
}
/**
* Check if a driver has conflicts with existing schedules
*/
function checkDriverConflict(driverId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) {
if (!driverId) return false; // No conflict if no driver specified
return allSchedules.some(s => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sDriverId = s.driverId ?? s.driver_id;
if (Number(sDriverId) !== Number(driverId)) return false;
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays);
});
}
/**
* Check if a bus has conflicts with existing schedules
*/
function checkBusConflict(busId, startTime, endTime, weekdays, allSchedules, excludeScheduleId = null) {
if (!busId) return false;
return allSchedules.some(s => {
if (excludeScheduleId && s.id === excludeScheduleId) return false;
const sBusId = s.busId ?? s.bus_id;
if (Number(sBusId) !== Number(busId)) return false;
const sWeekdays = parseWeekdaysValue(s.weekdays);
const wkdays = weekdays || [];
const sStart = s.startTime ?? s.start_time;
const sEnd = s.endTime ?? s.end_time;
return doSchedulesDateOverlap(startTime, endTime, wkdays, sStart, sEnd, sWeekdays);
});
}
// ============================================ // ============================================
// ROUTES - Get daily schedule // ROUTES - Get daily schedule
// ============================================ // ============================================
@@ -478,7 +589,13 @@ app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async
app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
if (DEV_MOCK) { if (DEV_MOCK) {
return res.json({ buses: _mock.fleetBuses.slice().sort((a, b) => a.id - b.id) }); const buses = _mock.fleetBuses.slice().sort((a, b) => a.id - b.id);
return res.json({
buses: buses.map(b => ({
...b,
available: !checkBusConflict(b.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || [])
}))
});
} }
const result = await db.query(` const result = await db.query(`
@@ -487,13 +604,29 @@ app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res)
ORDER BY id ASC ORDER BY id ASC
`); `);
// Get all schedules to check for conflicts
const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`);
const schedules = schedulesResult.rows.map(row => ({
...row,
weekdays: parseWeekdaysValue(row.weekdays),
}));
res.json({ res.json({
buses: result.rows.map(row => ({ buses: result.rows.map(row => {
const busId = row.id;
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isAvailable = !checkBusConflict(busId, now.toISOString(), tomorrow.toISOString(), [], schedules);
return {
id: row.id, id: row.id,
name: row.name, name: row.name,
seatCapacity: row.seat_capacity, seatCapacity: row.seat_capacity,
licensePlate: row.license_plate, licensePlate: row.license_plate,
})), available: isAvailable,
};
}),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -1047,7 +1180,13 @@ app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
if (DEV_MOCK) { if (DEV_MOCK) {
return res.json({ drivers: _mock.drivers.slice().filter(d => d.status === 'active') }); const drivers = _mock.drivers.slice().filter(d => d.status === 'active');
return res.json({
drivers: drivers.map(d => ({
...d,
available: !checkDriverConflict(d.id, new Date().toISOString(), new Date(Date.now() + 24*60*60*1000).toISOString(), [], _mock.schedules || [])
}))
});
} }
const result = await db.query(` const result = await db.query(`
@@ -1057,8 +1196,22 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
ORDER BY firstname, lastname ASC ORDER BY firstname, lastname ASC
`); `);
// Get all schedules to check for conflicts
const schedulesResult = await db.query(`SELECT * FROM transit.schedules WHERE status IN ('planned', 'active')`);
const schedules = schedulesResult.rows.map(row => ({
...row,
weekdays: parseWeekdaysValue(row.weekdays),
}));
res.json({ res.json({
drivers: result.rows.map(row => ({ drivers: result.rows.map(row => {
const driverId = row.id;
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const isAvailable = !checkDriverConflict(driverId, now.toISOString(), tomorrow.toISOString(), [], schedules);
return {
id: row.id, id: row.id,
name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(), name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(),
email: row.email, email: row.email,
@@ -1066,7 +1219,9 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
job: row.job, job: row.job,
joined: row.joined, joined: row.joined,
status: row.status, status: row.status,
})), available: isAvailable,
};
}),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -1087,7 +1242,12 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
return res.json({ schedules }); return res.json({ schedules });
} }
const result = await db.query(` const hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
const baseSelect = `
SELECT SELECT
s.id, s.id,
s.bus_line_id, s.bus_line_id,
@@ -1095,23 +1255,33 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
s.driver_id, s.driver_id,
s.start_time, s.start_time,
s.end_time, s.end_time,
s.weekdays,
s.status, s.status,
s.created_at, s.created_at,
bl.line_number, bl.line_number,
bl.estimated_duration_minutes, bl.estimated_duration_minutes,
b.name as bus_name, b.name as bus_name,
b.license_plate, b.license_plate,
TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name`;
const result = hasWeekdaysColumn.rows.length > 0
? await db.query(`${baseSelect}, s.weekdays as weekdays
FROM transit.schedules s FROM transit.schedules s
LEFT JOIN transit.bus_lines bl ON s.bus_line_id = bl.id 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 fleet.bus b ON s.bus_id = b.id
LEFT JOIN workers.employees e ON s.driver_id = e.id LEFT JOIN workers.employees e ON s.driver_id = e.id
ORDER BY s.start_time DESC ORDER BY s.start_time DESC`)
`); : await db.query(`${baseSelect}, NULL as weekdays
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
ORDER BY s.start_time DESC`);
const schedules = result.rows.map(row => { const schedules = result.rows.map(row => {
const departureTimes = calculateDepartureTimes(row.start_time, row.bus_line_id, _mock); const departureTimes = calculateDepartureTimes(row.start_time, row.bus_line_id, _mock);
const weekdays = parseWeekdaysValue(row.weekdays);
return { return {
id: row.id, id: row.id,
busLineId: row.bus_line_id, busLineId: row.bus_line_id,
@@ -1120,7 +1290,7 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
driverName: row.driver_name || null, driverName: row.driver_name || null,
startTime: row.start_time, startTime: row.start_time,
endTime: row.end_time, endTime: row.end_time,
weekdays: row.weekdays ? JSON.parse(row.weekdays) : [], weekdays: weekdays,
departureTimes, departureTimes,
status: row.status, status: row.status,
lineNumber: row.line_number, lineNumber: row.line_number,
@@ -1132,7 +1302,7 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
res.json({ schedules }); res.json({ schedules });
} catch (err) { } catch (err) {
console.error(err); console.error('GET /admin/schedules error:', err);
res.status(500).json({ error: 'internal_error', details: err.message }); res.status(500).json({ error: 'internal_error', details: err.message });
} }
}); });
@@ -1193,7 +1363,15 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
const endDate = new Date(startDate); const endDate = new Date(startDate);
endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
const result = await db.query(` // Check if weekdays column exists before including it in INSERT
const hasWeekdaysColumn = await db.query(`
SELECT 1 FROM information_schema.columns
WHERE table_schema='transit' AND table_name='schedules' AND column_name='weekdays'
`);
let result;
if (hasWeekdaysColumn.rows.length > 0) {
result = await db.query(`
WITH next_id AS ( WITH next_id AS (
SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules SELECT COALESCE(MAX(id), 0) + 1 AS id FROM transit.schedules
) )
@@ -1202,10 +1380,23 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
FROM next_id FROM next_id
RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
`, [busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]); `, [busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]);
} else {
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, endDate.toISOString()]);
}
const schedule = result.rows[0]; const schedule = result.rows[0];
const departureTimes = calculateDepartureTimes(startTime, busLineId, _mock); const departureTimes = calculateDepartureTimes(startTime, busLineId, _mock);
const weekdaysResult = parseWeekdaysValue(schedule.weekdays);
res.status(201).json({ res.status(201).json({
schedule: { schedule: {
id: schedule.id, id: schedule.id,
@@ -1214,7 +1405,7 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
driverId: schedule.driver_id, driverId: schedule.driver_id,
startTime: schedule.start_time, startTime: schedule.start_time,
endTime: schedule.end_time, endTime: schedule.end_time,
weekdays: schedule.weekdays ? JSON.parse(schedule.weekdays) : [], weekdays: weekdaysResult,
departureTimes, departureTimes,
status: schedule.status, status: schedule.status,
createdAt: schedule.created_at, createdAt: schedule.created_at,