diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx index 9cb89a1..42c2c0f 100644 --- a/admin_ui/src/components/ScheduleMaker.jsx +++ b/admin_ui/src/components/ScheduleMaker.jsx @@ -138,6 +138,74 @@ export default function ScheduleMaker({ token, user }) { 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) const timeSlots = generateTimeSlots() const filteredSchedules = selectedDriverFilter @@ -181,8 +249,8 @@ export default function ScheduleMaker({ token, user }) { @@ -192,8 +260,8 @@ export default function ScheduleMaker({ token, user }) { @@ -280,6 +348,16 @@ export default function ScheduleMaker({ token, user }) { {schedule.driverName} )} + {scheduleHasDriverConflict(schedule) && ( +
+ Chauffeur in gebruik +
+ )} + {scheduleHasBusConflict(schedule) && ( +
+ Voertuig in gebruik +
+ )} {schedule.weekdays && schedule.weekdays.length > 0 && (
{getWeekdayLabel(schedule.weekdays)} diff --git a/transit_api/index.js b/transit_api/index.js index 44677fa..1425e06 100644 --- a/transit_api/index.js +++ b/transit_api/index.js @@ -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 // ============================================ @@ -478,7 +589,13 @@ app.patch('/admin/bus-lines/:busLineId', authMiddleware, adminMiddleware, async app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) => { try { 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(` @@ -487,13 +604,29 @@ app.get('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res) 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({ - buses: result.rows.map(row => ({ - id: row.id, - name: row.name, - seatCapacity: row.seat_capacity, - licensePlate: row.license_plate, - })), + 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, + name: row.name, + seatCapacity: row.seat_capacity, + licensePlate: row.license_plate, + available: isAvailable, + }; + }), }); } catch (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) => { try { 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(` @@ -1057,16 +1196,32 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => { 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({ - drivers: result.rows.map(row => ({ - id: row.id, - name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(), - email: row.email, - phone: row.phone, - job: row.job, - joined: row.joined, - status: row.status, - })), + 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, + name: (String(row.firstname || '') + ' ' + String(row.lastname || '')).trim(), + email: row.email, + phone: row.phone, + job: row.job, + joined: row.joined, + status: row.status, + available: isAvailable, + }; + }), }); } catch (err) { console.error(err); @@ -1087,7 +1242,12 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => 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 s.id, s.bus_line_id, @@ -1095,23 +1255,33 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => s.driver_id, s.start_time, s.end_time, - s.weekdays, s.status, s.created_at, bl.line_number, bl.estimated_duration_minutes, b.name as bus_name, b.license_plate, - TRIM(COALESCE(e.firstname, '') || ' ' || COALESCE(e.lastname, '')) as driver_name - 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 - `); + 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 + 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`) + : 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 departureTimes = calculateDepartureTimes(row.start_time, row.bus_line_id, _mock); + + const weekdays = parseWeekdaysValue(row.weekdays); + return { id: row.id, busLineId: row.bus_line_id, @@ -1120,7 +1290,7 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => driverName: row.driver_name || null, startTime: row.start_time, endTime: row.end_time, - weekdays: row.weekdays ? JSON.parse(row.weekdays) : [], + weekdays: weekdays, departureTimes, status: row.status, lineNumber: row.line_number, @@ -1132,7 +1302,7 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => res.json({ schedules }); } catch (err) { - console.error(err); + console.error('GET /admin/schedules error:', err); res.status(500).json({ error: 'internal_error', details: err.message }); } }); @@ -1193,19 +1363,40 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) = const endDate = new Date(startDate); endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60)); - const 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, weekdays) - SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 - FROM next_id - 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)]); + // 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 ( + 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, weekdays) + SELECT next_id.id, $1, $2, $3, $4, $5, 'planned', $6 + FROM next_id + 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)]); + } 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 departureTimes = calculateDepartureTimes(startTime, busLineId, _mock); + const weekdaysResult = parseWeekdaysValue(schedule.weekdays); + res.status(201).json({ schedule: { id: schedule.id, @@ -1214,7 +1405,7 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) = driverId: schedule.driver_id, startTime: schedule.start_time, endTime: schedule.end_time, - weekdays: schedule.weekdays ? JSON.parse(schedule.weekdays) : [], + weekdays: weekdaysResult, departureTimes, status: schedule.status, createdAt: schedule.created_at,