diff --git a/admin_ui/src/components/ScheduleMaker.jsx b/admin_ui/src/components/ScheduleMaker.jsx
index 08111f5..88745a8 100644
--- a/admin_ui/src/components/ScheduleMaker.jsx
+++ b/admin_ui/src/components/ScheduleMaker.jsx
@@ -4,9 +4,22 @@ import { transitClient } from '../api'
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon...
+const toInputDate = (value) => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return new Date().toISOString().split('T')[0]
+ return date.toISOString().split('T')[0]
+}
+
+const toInputTime = (value) => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return '08:00'
+ return `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}`
+}
+
export default function ScheduleMaker({ token, user }) {
const client = transitClient(token)
const [schedules, setSchedules] = useState([])
+ const [scheduleTemplates, setScheduleTemplates] = useState([])
const [overviewSchedules, setOverviewSchedules] = useState([])
const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([])
@@ -16,6 +29,7 @@ export default function ScheduleMaker({ token, user }) {
const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null)
+ const [editingScheduleId, setEditingScheduleId] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({})
// Form state
@@ -36,6 +50,15 @@ export default function ScheduleMaker({ token, user }) {
}
}
+ const loadScheduleTemplates = async () => {
+ try {
+ const res = await client.get('/admin/schedules?templates=1')
+ setScheduleTemplates((res.data.schedules || []).filter(schedule => Array.isArray(schedule.weekdays) && schedule.weekdays.length > 0))
+ } catch (err) {
+ setMessage('Error loading fixed schedules: ' + (err.response?.data?.error || err.message))
+ }
+ }
+
const loadOverviewSchedules = async () => {
try {
const params = new URLSearchParams({ date: overviewDate })
@@ -79,6 +102,7 @@ export default function ScheduleMaker({ token, user }) {
useEffect(() => {
loadSchedules()
+ loadScheduleTemplates()
loadBusLines()
loadBuses()
loadDrivers()
@@ -88,7 +112,28 @@ export default function ScheduleMaker({ token, user }) {
loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter])
- const createSchedule = async (e) => {
+ const resetScheduleForm = () => {
+ setEditingScheduleId(null)
+ setSelectedLine('')
+ setSelectedBus('')
+ setStartTime('08:00')
+ setDriverId('')
+ setSelectedWeekdays([])
+ }
+
+ const startEditingSchedule = (schedule) => {
+ setMessage(null)
+ setEditingScheduleId(schedule.id)
+ setSelectedLine(String(schedule.busLineId))
+ setSelectedBus(String(schedule.busId))
+ setDriverId(schedule.driverId ? String(schedule.driverId) : '')
+ setSelectedDate(toInputDate(schedule.startTime))
+ setStartTime(toInputTime(schedule.startTime))
+ setSelectedWeekdays(Array.isArray(schedule.weekdays) ? [...schedule.weekdays] : [])
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+
+ const submitSchedule = async (e) => {
e.preventDefault()
setMessage(null)
@@ -100,25 +145,27 @@ export default function ScheduleMaker({ token, user }) {
try {
const startDateTime = `${selectedDate}T${startTime}:00`
const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b)
-
- const res = await client.post('/admin/schedules', {
+ const payload = {
busLineId: Number(selectedLine),
busId: Number(selectedBus),
driverId: driverId ? Number(driverId) : null,
startTime: startDateTime,
weekdays: normalizedWeekdays,
- })
+ }
+
+ const res = editingScheduleId
+ ? await client.put(`/admin/schedules/${editingScheduleId}`, payload)
+ : await client.post('/admin/schedules', payload)
const dayLabels = normalizedWeekdays.length > 0
? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ')
: 'eenmalig'
- setMessage(`Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
- setSelectedLine('')
- setSelectedBus('')
- setStartTime('08:00')
- setDriverId('')
- setSelectedWeekdays([])
+ setMessage(editingScheduleId
+ ? `Dienst bijgewerkt: #${res.data.schedule.id} (${dayLabels})`
+ : `Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
+ resetScheduleForm()
await loadSchedules()
+ await loadScheduleTemplates()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
@@ -132,7 +179,11 @@ export default function ScheduleMaker({ token, user }) {
await client.delete(`/admin/schedules/${scheduleId}`)
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
setDeleteConfirm(null)
+ if (editingScheduleId === scheduleId) {
+ resetScheduleForm()
+ }
await loadSchedules()
+ await loadScheduleTemplates()
await loadOverviewSchedules()
} catch (err) {
const errMsg = err.response?.data?.error || err.message
@@ -270,7 +321,21 @@ export default function ScheduleMaker({ token, user }) {
{isAdmin && (
-
)}
@@ -524,6 +589,51 @@ export default function ScheduleMaker({ token, user }) {
+ Vaste diensten beheren
+
+ Pas bestaande vaste diensten aan of verwijder ze direct vanuit dit overzicht.
+
+
+ {scheduleTemplates.length === 0 ? (
+
Nog geen vaste diensten gevonden.
+ ) : (
+ scheduleTemplates
+ .slice()
+ .sort((a, b) => a.lineNumber - b.lineNumber || new Date(a.startTime) - new Date(b.startTime))
+ .map(schedule => (
+
+
+ Lijn {schedule.lineNumber}
+
+ {formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
+
+
+
+
{schedule.busName}
+
{schedule.licensePlate}
+ {schedule.driverName && (
+
+ {schedule.driverName}
+
+ )}
+
+ {schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
{message && {message}
}
{deleteConfirm && (
diff --git a/bus_terminal/lib/screens/recurring_schedules_screen.dart b/bus_terminal/lib/screens/recurring_schedules_screen.dart
new file mode 100644
index 0000000..c66f936
--- /dev/null
+++ b/bus_terminal/lib/screens/recurring_schedules_screen.dart
@@ -0,0 +1,326 @@
+// ignore_for_file: use_build_context_synchronously
+
+import 'package:flutter/material.dart';
+import '../models/schedule.dart' as sched;
+import '../services/schedule_service.dart';
+
+class RecurringSchedulesScreen extends StatefulWidget {
+ const RecurringSchedulesScreen({super.key});
+
+ @override
+ State createState() => _RecurringSchedulesScreenState();
+}
+
+class _RecurringSchedulesScreenState extends State {
+ final _scheduleService = ScheduleService();
+ late Future> _schedulesFuture;
+ String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
+ final Set _expandedLineIds = {};
+
+ @override
+ void initState() {
+ super.initState();
+ _loadRecurringSchedules();
+ }
+
+ 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();
+ });
+ }
+
+ String _formatWeekdays(List weekdays) {
+ const dayLabels = ['Zo', 'Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za'];
+ if (weekdays.isEmpty) return 'Eenmalig';
+ if (weekdays.length == 7) return 'Dagelijks';
+
+ final sorted = weekdays.toSet().toList()..sort((a, b) => a.compareTo(b));
+ return sorted.map((d) => dayLabels[d]).join(', ');
+ }
+
+ String _formatTime(DateTime dt) {
+ return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
+ }
+
+ List _sortSchedules(List schedules) {
+ final sorted = List.from(schedules);
+
+ switch (_selectedSortBy) {
+ case 'line':
+ sorted.sort((a, b) => a.lineNumber.compareTo(b.lineNumber));
+ break;
+ case 'time':
+ sorted.sort((a, b) => a.startTime.compareTo(b.startTime));
+ break;
+ case 'weekday':
+ sorted.sort((a, b) {
+ final aFirst = a.weekdays.isEmpty ? 7 : a.weekdays.first;
+ final bFirst = b.weekdays.isEmpty ? 7 : b.weekdays.first;
+ return aFirst.compareTo(bFirst);
+ });
+ break;
+ }
+
+ return sorted;
+ }
+
+ Map> _groupByLine(List schedules) {
+ final grouped = >{};
+ for (final schedule in schedules) {
+ grouped.putIfAbsent(schedule.lineNumber, () => []).add(schedule);
+ }
+ return grouped;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Vaste Diensten'),
+ centerTitle: true,
+ actions: [
+ PopupMenuButton(
+ onSelected: (value) {
+ setState(() => _selectedSortBy = value);
+ },
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: 'line',
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.sort_by_alpha, color: _selectedSortBy == 'line' ? colorScheme.primary : null),
+ const SizedBox(width: 8),
+ const Text('Lijn'),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'time',
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.schedule, color: _selectedSortBy == 'time' ? colorScheme.primary : null),
+ const SizedBox(width: 8),
+ const Text('Tijd'),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'weekday',
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(Icons.calendar_today, color: _selectedSortBy == 'weekday' ? colorScheme.primary : null),
+ const SizedBox(width: 8),
+ const Text('Weekdag'),
+ ],
+ ),
+ ),
+ ],
+ child: const Padding(
+ padding: EdgeInsets.symmetric(horizontal: 16),
+ child: Icon(Icons.filter_list),
+ ),
+ ),
+ ],
+ ),
+ body: FutureBuilder>(
+ future: _schedulesFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (snapshot.hasError) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.error_outline, size: 48, color: colorScheme.error),
+ const SizedBox(height: 16),
+ Text(
+ 'Fout bij laden: ${snapshot.error}',
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ onPressed: () {
+ setState(() => _loadRecurringSchedules());
+ },
+ child: const Text('Opnieuw proberen'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ final schedules = snapshot.data ?? [];
+
+ if (schedules.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.event_note_outlined, size: 48, color: colorScheme.outline),
+ const SizedBox(height: 16),
+ Text(
+ 'Geen vaste diensten gevonden',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ ],
+ ),
+ );
+ }
+
+ final sortedSchedules = _sortSchedules(schedules);
+ final groupedByLine = _groupByLine(sortedSchedules);
+
+ return ListView.builder(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
+ itemCount: groupedByLine.length,
+ itemBuilder: (context, lineIndex) {
+ final lineNumber = groupedByLine.keys.toList()[lineIndex];
+ final lineSchedules = groupedByLine[lineNumber] ?? [];
+ final isExpanded = _expandedLineIds.contains(lineNumber);
+
+ return Column(
+ children: [
+ Card(
+ child: InkWell(
+ onTap: () {
+ setState(() {
+ if (isExpanded) {
+ _expandedLineIds.remove(lineNumber);
+ } else {
+ _expandedLineIds.add(lineNumber);
+ }
+ });
+ },
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Lijn $lineNumber',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ '${lineSchedules.length} diensten',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.outline,
+ ),
+ ),
+ ],
+ ),
+ Icon(
+ isExpanded ? Icons.expand_less : Icons.expand_more,
+ color: colorScheme.primary,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ if (isExpanded)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: ListView.separated(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ itemCount: lineSchedules.length,
+ separatorBuilder: (_, _) => const SizedBox(height: 8),
+ itemBuilder: (context, scheduleIndex) {
+ final schedule = lineSchedules[scheduleIndex];
+ return _ScheduleCard(schedule: schedule, formatTime: _formatTime, formatWeekdays: _formatWeekdays);
+ },
+ ),
+ ),
+ const SizedBox(height: 8),
+ ],
+ );
+ },
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _ScheduleCard extends StatelessWidget {
+ final sched.ServiceSchedule schedule;
+ final String Function(DateTime) formatTime;
+ final String Function(List) formatWeekdays;
+
+ const _ScheduleCard({
+ required this.schedule,
+ required this.formatTime,
+ required this.formatWeekdays,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+
+ return Card(
+ elevation: 1,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '${formatTime(schedule.startTime)} - ${formatTime(schedule.endTime)}',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ schedule.busName,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.outline,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ Chip(
+ label: Text(
+ formatWeekdays(schedule.weekdays),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ avatar: const Icon(Icons.calendar_today, size: 16),
+ visualDensity: VisualDensity.compact,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/bus_terminal/lib/screens/schedule_overview_screen.dart b/bus_terminal/lib/screens/schedule_overview_screen.dart
index 4b950d4..21d19cd 100644
--- a/bus_terminal/lib/screens/schedule_overview_screen.dart
+++ b/bus_terminal/lib/screens/schedule_overview_screen.dart
@@ -6,6 +6,7 @@ import '../models/navigation.dart';
import '../services/auth_service.dart';
import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart';
+import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key});
@@ -14,17 +15,25 @@ class ScheduleOverviewScreen extends StatefulWidget {
State createState() => _ScheduleOverviewScreenState();
}
-class _ScheduleOverviewScreenState extends State {
+class _ScheduleOverviewScreenState extends State with TickerProviderStateMixin {
final _scheduleService = ScheduleService();
final _authService = AuthService();
late Future> _scheduleFuture;
DateTime _selectedDate = DateTime.now();
+ late TabController _tabController;
@override
void initState() {
super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate);
+ _tabController = TabController(length: 2, vsync: this);
+ }
+
+ @override
+ void dispose() {
+ _tabController.dispose();
+ super.dispose();
}
Future _handleLogout() async {
@@ -117,10 +126,7 @@ class _ScheduleOverviewScreenState extends State {
onPressed: () async {
try {
await _scheduleService.updateScheduleStatus(svc.id, 'in_progress');
-
if (!mounted) return;
-
- // Close the sheet and start navigation with provided stops
Navigator.of(sheetContext).pop();
final routeObj = sched.Route(
@@ -210,13 +216,19 @@ class _ScheduleOverviewScreenState extends State {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
- final dateStr =
- '${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
return Scaffold(
appBar: AppBar(
- title: const Text('Today on the Road'),
+ title: const Text('Diensten'),
+ centerTitle: true,
elevation: 0,
+ bottom: TabBar(
+ controller: _tabController,
+ tabs: const [
+ Tab(icon: Icon(Icons.calendar_today), text: 'Dagschema'),
+ Tab(icon: Icon(Icons.repeat), text: 'Vaste diensten'),
+ ],
+ ),
actions: [
IconButton(
icon: const Icon(Icons.logout),
@@ -225,174 +237,179 @@ class _ScheduleOverviewScreenState extends State {
),
],
),
- body: Container(
- decoration: BoxDecoration(
- gradient: LinearGradient(
- colors: Theme.of(context).brightness == Brightness.dark
- ? const [Color(0xFF0B1220), Color(0xFF111827)]
- : const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
- begin: Alignment.topCenter,
- end: Alignment.bottomCenter,
+ body: TabBarView(
+ controller: _tabController,
+ children: [
+ _DailyScheduleTab(
+ scheduleService: _scheduleService,
+ selectedDate: _selectedDate,
+ onSelectDate: _selectDate,
+ onRefresh: _refreshSchedules,
+ onShowDetails: _showScheduleDetails,
+ scheduleFuture: _scheduleFuture,
),
+ const RecurringSchedulesScreen(),
+ ],
+ ),
+ );
+ }
+}
+
+class _DailyScheduleTab extends StatelessWidget {
+ final ScheduleService scheduleService;
+ final DateTime selectedDate;
+ final VoidCallback onSelectDate;
+ final VoidCallback onRefresh;
+ final Function(sched.ServiceSchedule) onShowDetails;
+ final Future> scheduleFuture;
+
+ const _DailyScheduleTab({
+ required this.scheduleService,
+ required this.selectedDate,
+ required this.onSelectDate,
+ required this.onRefresh,
+ required this.onShowDetails,
+ required this.scheduleFuture,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final dateStr =
+ '${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}';
+
+ return Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: Theme.of(context).brightness == Brightness.dark
+ ? const [Color(0xFF0B1220), Color(0xFF111827)]
+ : const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
),
- child: Column(
- children: [
- Container(
- margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
- padding: const EdgeInsets.all(18),
- decoration: BoxDecoration(
- gradient: const LinearGradient(
- colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- ),
- borderRadius: BorderRadius.circular(24),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.16),
- blurRadius: 22,
- offset: const Offset(0, 10),
- ),
- ],
+ ),
+ child: Column(
+ children: [
+ Container(
+ margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Geplande diensten',
- style: Theme.of(context).textTheme.labelSmall?.copyWith(
- color: Colors.white.withOpacity(0.78),
- ),
- ),
- const SizedBox(height: 6),
- Text(
- dateStr,
- style: Theme.of(context).textTheme.headlineMedium?.copyWith(
- color: Colors.white,
- fontWeight: FontWeight.w800,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- 'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Colors.white.withOpacity(0.84),
- ),
- ),
- const SizedBox(height: 14),
- Align(
- alignment: Alignment.centerRight,
- child: SizedBox(
- height: 48,
- child: OutlinedButton.icon(
- onPressed: _selectDate,
- icon: const Icon(Icons.calendar_today, size: 18),
- label: const Text('Wijzig datum'),
- style: OutlinedButton.styleFrom(
- foregroundColor: Colors.white,
- side: BorderSide(color: Colors.white.withOpacity(0.28)),
- padding: const EdgeInsets.symmetric(horizontal: 16),
- ),
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.16),
+ blurRadius: 22,
+ offset: const Offset(0, 10),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Geplande diensten',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: Colors.white.withOpacity(0.78),
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ dateStr,
+ style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ color: Colors.white.withOpacity(0.84),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Align(
+ alignment: Alignment.centerRight,
+ child: SizedBox(
+ height: 48,
+ child: OutlinedButton.icon(
+ onPressed: onSelectDate,
+ icon: const Icon(Icons.calendar_today, size: 18),
+ label: const Text('Wijzig datum'),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.white,
+ side: BorderSide(color: Colors.white.withOpacity(0.28)),
+ padding: const EdgeInsets.symmetric(horizontal: 16),
),
),
),
- ],
- ),
+ ),
+ ],
),
- Expanded(
- child: FutureBuilder>(
- future: _scheduleFuture,
- builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.waiting) {
- return const Center(child: CircularProgressIndicator());
- }
+ ),
+ Expanded(
+ child: FutureBuilder>(
+ future: scheduleFuture,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(child: CircularProgressIndicator());
+ }
- if (snapshot.hasError) {
- return Center(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.error_outline,
- size: 64,
- color: colorScheme.error,
- ),
- const SizedBox(height: 16),
- Text(
- 'Failed to load schedule',
- style: Theme.of(context).textTheme.titleMedium,
- ),
- const SizedBox(height: 8),
- Text(
- snapshot.error.toString(),
- style: Theme.of(context).textTheme.bodySmall,
- textAlign: TextAlign.center,
- ),
- const SizedBox(height: 24),
- FilledButton.icon(
- onPressed: _refreshSchedules,
- icon: const Icon(Icons.refresh),
- label: const Text('Retry'),
- ),
- ],
+ if (snapshot.hasError) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.error_outline, size: 48, color: colorScheme.error),
+ const SizedBox(height: 16),
+ Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
+ const SizedBox(height: 16),
+ FilledButton(
+ onPressed: onRefresh,
+ child: const Text('Opnieuw proberen'),
),
- ),
- );
- }
-
- final schedules = snapshot.data ?? [];
- if (schedules.isEmpty) {
- return Center(
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 24),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.calendar_today,
- size: 64,
- color: colorScheme.outline,
- ),
- const SizedBox(height: 16),
- Text(
- 'No routes scheduled',
- style: Theme.of(context).textTheme.titleMedium,
- ),
- const SizedBox(height: 8),
- Text(
- 'Check back later for your schedule',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: colorScheme.onSurfaceVariant,
- ),
- textAlign: TextAlign.center,
- ),
- ],
- ),
- ),
- );
- }
-
- return RefreshIndicator(
- onRefresh: _refreshSchedules,
- child: ListView.builder(
- padding: const EdgeInsets.only(bottom: 16),
- itemCount: schedules.length,
- itemBuilder: (context, index) {
- final schedule = schedules[index];
- return _ScheduleSummaryCard(
- schedule: schedule,
- onTap: () => _showScheduleDetails(schedule),
- );
- },
+ ],
),
);
- },
- ),
+ }
+
+ final schedules = snapshot.data ?? [];
+ if (schedules.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.event_available_outlined, size: 48, color: colorScheme.outline),
+ const SizedBox(height: 16),
+ Text('Geen diensten op $dateStr', style: Theme.of(context).textTheme.titleMedium),
+ ],
+ ),
+ );
+ }
+
+ return RefreshIndicator(
+ onRefresh: () async => onRefresh(),
+ child: ListView.builder(
+ padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
+ itemCount: schedules.length,
+ itemBuilder: (context, index) {
+ final schedule = schedules[index];
+ return _ScheduleSummaryCard(
+ schedule: schedule,
+ onTap: () => onShowDetails(schedule),
+ );
+ },
+ ),
+ );
+ },
),
- ],
- ),
+ ),
+ ],
),
);
}
@@ -446,7 +463,9 @@ class _ScheduleSummaryCard extends StatelessWidget {
),
),
),
- _InfoChip(label: schedule.status),
+ // Only show status for one-time schedules; recurring schedules have no fixed status
+ if (!schedule.isRecurring)
+ _InfoChip(label: schedule.status),
],
),
const SizedBox(height: 16),
@@ -523,4 +542,4 @@ String _weekdayLabel(List weekdays) {
return 'Dagelijks';
}
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
-}
\ No newline at end of file
+}
diff --git a/transit_api/index.js b/transit_api/index.js
index c62aea9..d79e7d6 100644
--- a/transit_api/index.js
+++ b/transit_api/index.js
@@ -240,6 +240,35 @@ function expandRecurringScheduleRows(rows) {
return expandedRows;
}
+function mapAdminScheduleRow(row, routeStopsByLine = new Map()) {
+ const weekdays = parseWeekdaysValue(row.weekdays);
+ const busLineId = Number(row.busLineId ?? row.bus_line_id);
+ const startTime = row.startTime ?? row.start_time;
+ const routeStops = routeStopsByLine.get(busLineId) || [];
+ const departureTimes = buildDepartureTimes(startTime, routeStops);
+
+ return {
+ id: Number(row.id),
+ busLineId,
+ busId: Number(row.busId ?? row.bus_id),
+ driverId: row.driverId !== null && row.driverId !== undefined ? Number(row.driverId) : null,
+ driverName: row.driverName ?? row.driver_name ?? null,
+ startTime,
+ endTime: row.endTime ?? row.end_time,
+ weekdays,
+ departureTimes,
+ status: row.status,
+ lineNumber: Number(row.lineNumber ?? row.line_number) || null,
+ busName: row.busName ?? row.bus_name ?? 'Unknown Bus',
+ licensePlate: row.licensePlate ?? row.license_plate ?? null,
+ createdAt: row.createdAt ?? row.created_at ?? null,
+ isRecurringInstance: row.is_recurring_instance || false,
+ templateId: row.template_id || row.templateId || null,
+ recurrenceLabel: formatWeekdayLabel(weekdays),
+ isRecurring: weekdays.length > 0,
+ };
+}
+
function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) {
if (!schedule || !filters) return true;
@@ -614,7 +643,7 @@ async function ensureScheduleStopProgressTable() {
AND tc.table_name = 'schedule_stop_progress'
AND tc.constraint_type = 'UNIQUE'
GROUP BY tc.constraint_name
- HAVING array_agg(kcu.column_name ORDER BY kcu.ordinal_position) = ARRAY['schedule_id', 'stop_order']
+ HAVING array_agg(kcu.column_name ORDER BY kcu.ordinal_position)::text[] = ARRAY['schedule_id', 'stop_order']::text[]
LIMIT 1;
IF constraint_name IS NOT NULL THEN
@@ -1972,6 +2001,8 @@ app.get('/admin/drivers', authMiddleware, adminMiddleware, async (req, res) => {
// ============================================
app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
try {
+ const templatesOnly = String(req.query.templates || req.query.includeTemplates || '').toLowerCase() === 'true' || String(req.query.templates || req.query.includeTemplates || '') === '1';
+
if (DEV_MOCK) {
const routeStopsByLine = new Map();
for (const routeStop of _mock.routeStops || []) {
@@ -1985,7 +2016,7 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
});
}
- const schedules = expandRecurringScheduleRows((_mock.schedules || []).map(s => {
+ const rawSchedules = (_mock.schedules || []).map(s => {
const busLine = _mock.busLines.find(bl => bl.id === s.busLineId);
const bus = _mock.fleetBuses.find(b => b.id === s.busId);
return {
@@ -2001,7 +2032,9 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
driverId: s.driverId || null,
estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
};
- }))
+ });
+
+ const schedules = (templatesOnly ? rawSchedules : expandRecurringScheduleRows(rawSchedules))
.filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine))
.map(s => ({
...s,
@@ -2080,48 +2113,13 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
}
const filters = parseScheduleFilters(req.query);
- const expandedSchedules = expandRecurringScheduleRows(result.rows)
+ const rowSource = templatesOnly ? result.rows : expandRecurringScheduleRows(result.rows)
+ const expandedSchedules = rowSource
.filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
- const schedules = expandedSchedules.map(row => {
- const weekdays = parseWeekdaysValue(row.weekdays);
- const busLineId = Number(row.bus_line_id);
- const lineRouteStops = routeStopsByLine.get(busLineId) || [];
- const startDate = new Date(row.start_time);
-
- const departureTimes = lineRouteStops.map(routeStop => {
- const departureDate = new Date(startDate);
- const etaMinutes = Number(routeStop.estimated_arrival_minutes) || 0;
- departureDate.setMinutes(departureDate.getMinutes() + etaMinutes);
-
- return {
- order: Number(routeStop.stop_order),
- stopId: Number(routeStop.stop_id),
- stopName: routeStop.stop_name || 'Unknown Stop',
- estimatedArrivalMinutes: etaMinutes,
- departureTime: departureDate.toISOString(),
- };
- });
-
- return {
- id: Number(row.id),
- busLineId,
- busId: Number(row.bus_id),
- driverId: row.driver_id !== null && row.driver_id !== undefined ? Number(row.driver_id) : null,
- driverName: row.driver_name || null,
- startTime: row.start_time,
- endTime: row.end_time,
- weekdays,
- departureTimes,
- status: row.status,
- lineNumber: row.line_number,
- busName: row.bus_name,
- licensePlate: row.license_plate,
- createdAt: row.created_at,
- isRecurringInstance: row.is_recurring_instance || false,
- templateId: row.template_id || null,
- };
- }).sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
+ const schedules = expandedSchedules
+ .map(row => mapAdminScheduleRow(row, routeStopsByLine))
+ .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
res.json({ schedules });
} catch (err) {
@@ -2240,6 +2238,125 @@ app.post('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =
}
});
+app.put('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
+ try {
+ const { scheduleId } = req.params;
+ const { busLineId, busId, driverId, startTime, weekdays } = req.body;
+
+ if (!scheduleId || !Number.isFinite(Number(scheduleId))) {
+ return res.status(400).json({ error: 'invalid_schedule_id' });
+ }
+
+ if (!busLineId || !busId || !startTime) {
+ return res.status(400).json({ error: 'missing_required_fields: busLineId, busId, startTime required' });
+ }
+
+ const scheduleWeekdays = Array.isArray(weekdays) && weekdays.length > 0
+ ? weekdays.map(w => Number(w)).filter(w => w >= 0 && w <= 6)
+ : [];
+
+ if (DEV_MOCK) {
+ const index = (_mock.schedules || []).findIndex(s => Number(s.id) === Number(scheduleId));
+ if (index === -1) {
+ return res.status(404).json({ error: 'schedule_not_found' });
+ }
+
+ const busLine = _mock.busLines.find(bl => bl.id == busLineId);
+ if (!busLine) {
+ return res.status(404).json({ error: 'bus_line_not_found' });
+ }
+
+ const startDate = new Date(startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(endDate.getMinutes() + (busLine.estimatedDurationMinutes || 60));
+
+ _mock.schedules[index] = {
+ ..._mock.schedules[index],
+ busLineId: Number(busLineId),
+ busId: Number(busId),
+ driverId: driverId ? Number(driverId) : null,
+ startTime,
+ endTime: endDate.toISOString(),
+ weekdays: scheduleWeekdays,
+ };
+
+ return res.json({ schedule: _mock.schedules[index] });
+ }
+
+ const existingResult = await db.query(`
+ SELECT id, status
+ FROM transit.schedules
+ WHERE id = $1
+ `, [scheduleId]);
+
+ if (existingResult.rows.length === 0) {
+ return res.status(404).json({ error: 'schedule_not_found' });
+ }
+
+ const busLine = await db.query('SELECT estimated_duration_minutes FROM transit.bus_lines WHERE id = $1', [busLineId]);
+ if (!busLine.rows[0]) {
+ return res.status(404).json({ error: 'bus_line_not_found' });
+ }
+
+ const startDate = new Date(startTime);
+ const endDate = new Date(startDate);
+ endDate.setMinutes(endDate.getMinutes() + (busLine.rows[0].estimated_duration_minutes || 60));
+
+ 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(`
+ UPDATE transit.schedules
+ SET
+ bus_line_id = $2,
+ bus_id = $3,
+ driver_id = $4,
+ start_time = $5,
+ end_time = $6,
+ weekdays = $7,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, weekdays, created_at
+ `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString(), JSON.stringify(scheduleWeekdays)]);
+ } else {
+ result = await db.query(`
+ UPDATE transit.schedules
+ SET
+ bus_line_id = $2,
+ bus_id = $3,
+ driver_id = $4,
+ start_time = $5,
+ end_time = $6,
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING id, bus_line_id, bus_id, driver_id, start_time, end_time, status, created_at
+ `, [scheduleId, busLineId, busId, driverId || null, startTime, endDate.toISOString()]);
+ }
+
+ const schedule = result.rows[0];
+ return res.json({
+ schedule: {
+ id: Number(schedule.id),
+ busLineId: Number(schedule.bus_line_id),
+ busId: Number(schedule.bus_id),
+ driverId: schedule.driver_id !== null && schedule.driver_id !== undefined ? Number(schedule.driver_id) : null,
+ startTime: schedule.start_time,
+ endTime: schedule.end_time,
+ weekdays: parseWeekdaysValue(schedule.weekdays),
+ status: schedule.status,
+ createdAt: schedule.created_at,
+ },
+ });
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'internal_error', details: err.message });
+ }
+});
+
app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
try {
const { scheduleId } = req.params;
@@ -3191,6 +3308,17 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
});
const PORT = process.env.PORT || 5001;
-app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
+
+// Initialize database tables on startup
+(async () => {
+ try {
+ await ensureScheduleStopProgressTable();
+ console.log('✓ Database tables initialized');
+ } catch (err) {
+ console.error('⚠ Error initializing tables:', err.message);
+ }
+
+ app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
+})();
module.exports = app;