Enhance schedule management; add recurring schedules screen, implement schedule editing functionality, and improve schedule loading and filtering in ScheduleMaker

This commit is contained in:
Sven laptop
2026-05-13 17:41:25 +02:00
parent 86ffce07b0
commit 1e41c05214
4 changed files with 804 additions and 221 deletions
+122 -12
View File
@@ -4,9 +4,22 @@ import { transitClient } from '../api'
const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo'] const WEEKDAYS = ['Ma', 'Di', 'Wo', 'Do', 'Vr', 'Za', 'Zo']
const WEEKDAY_VALUES = [1, 2, 3, 4, 5, 6, 0] // 0=Sun, 1=Mon... 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 }) { export default function ScheduleMaker({ token, user }) {
const client = transitClient(token) const client = transitClient(token)
const [schedules, setSchedules] = useState([]) const [schedules, setSchedules] = useState([])
const [scheduleTemplates, setScheduleTemplates] = useState([])
const [overviewSchedules, setOverviewSchedules] = useState([]) const [overviewSchedules, setOverviewSchedules] = useState([])
const [busLines, setBusLines] = useState([]) const [busLines, setBusLines] = useState([])
const [buses, setBuses] = useState([]) const [buses, setBuses] = useState([])
@@ -16,6 +29,7 @@ export default function ScheduleMaker({ token, user }) {
const [isAdmin] = useState(Boolean(user?.userCode)) const [isAdmin] = useState(Boolean(user?.userCode))
const [deleteConfirm, setDeleteConfirm] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState(null)
const [expandedSchedule, setExpandedSchedule] = useState(null) const [expandedSchedule, setExpandedSchedule] = useState(null)
const [editingScheduleId, setEditingScheduleId] = useState(null)
const [routeStopsByLine, setRouteStopsByLine] = useState({}) const [routeStopsByLine, setRouteStopsByLine] = useState({})
// Form state // 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 () => { const loadOverviewSchedules = async () => {
try { try {
const params = new URLSearchParams({ date: overviewDate }) const params = new URLSearchParams({ date: overviewDate })
@@ -79,6 +102,7 @@ export default function ScheduleMaker({ token, user }) {
useEffect(() => { useEffect(() => {
loadSchedules() loadSchedules()
loadScheduleTemplates()
loadBusLines() loadBusLines()
loadBuses() loadBuses()
loadDrivers() loadDrivers()
@@ -88,7 +112,28 @@ export default function ScheduleMaker({ token, user }) {
loadOverviewSchedules() loadOverviewSchedules()
}, [overviewDate, selectedDriverFilter]) }, [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() e.preventDefault()
setMessage(null) setMessage(null)
@@ -100,25 +145,27 @@ export default function ScheduleMaker({ token, user }) {
try { try {
const startDateTime = `${selectedDate}T${startTime}:00` const startDateTime = `${selectedDate}T${startTime}:00`
const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b) const normalizedWeekdays = [...selectedWeekdays].sort((a, b) => a - b)
const payload = {
const res = await client.post('/admin/schedules', {
busLineId: Number(selectedLine), busLineId: Number(selectedLine),
busId: Number(selectedBus), busId: Number(selectedBus),
driverId: driverId ? Number(driverId) : null, driverId: driverId ? Number(driverId) : null,
startTime: startDateTime, startTime: startDateTime,
weekdays: normalizedWeekdays, weekdays: normalizedWeekdays,
}) }
const res = editingScheduleId
? await client.put(`/admin/schedules/${editingScheduleId}`, payload)
: await client.post('/admin/schedules', payload)
const dayLabels = normalizedWeekdays.length > 0 const dayLabels = normalizedWeekdays.length > 0
? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ') ? normalizedWeekdays.map(d => WEEKDAYS[WEEKDAY_VALUES.indexOf(d)]).join(', ')
: 'eenmalig' : 'eenmalig'
setMessage(`Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`) setMessage(editingScheduleId
setSelectedLine('') ? `Dienst bijgewerkt: #${res.data.schedule.id} (${dayLabels})`
setSelectedBus('') : `Dienst aangemaakt: #${res.data.schedule.id} (${dayLabels})`)
setStartTime('08:00') resetScheduleForm()
setDriverId('')
setSelectedWeekdays([])
await loadSchedules() await loadSchedules()
await loadScheduleTemplates()
await loadOverviewSchedules() await loadOverviewSchedules()
} catch (err) { } catch (err) {
const errMsg = err.response?.data?.error || err.message const errMsg = err.response?.data?.error || err.message
@@ -132,7 +179,11 @@ export default function ScheduleMaker({ token, user }) {
await client.delete(`/admin/schedules/${scheduleId}`) await client.delete(`/admin/schedules/${scheduleId}`)
setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`) setMessage(`Dienst verwijderd: Lijn ${lineNumber}, ${busName}`)
setDeleteConfirm(null) setDeleteConfirm(null)
if (editingScheduleId === scheduleId) {
resetScheduleForm()
}
await loadSchedules() await loadSchedules()
await loadScheduleTemplates()
await loadOverviewSchedules() await loadOverviewSchedules()
} catch (err) { } catch (err) {
const errMsg = err.response?.data?.error || err.message const errMsg = err.response?.data?.error || err.message
@@ -270,7 +321,21 @@ export default function ScheduleMaker({ token, user }) {
</div> </div>
{isAdmin && ( {isAdmin && (
<form onSubmit={createSchedule} className="stack-form"> <form onSubmit={submitSchedule} className="stack-form">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
<div>
<h3 style={{ margin: 0 }}>{editingScheduleId ? 'Vaste dienst bewerken' : 'Dienst aanmaken'}</h3>
<p style={{ margin: '0.25rem 0 0', color: '#666' }}>
{editingScheduleId ? 'Werk de bestaande dienst bij en sla de wijzigingen op.' : 'Maak een nieuwe dienst of vaste herhaling aan.'}
</p>
</div>
{editingScheduleId && (
<button type="button" className="secondary" onClick={resetScheduleForm}>
Bewerken annuleren
</button>
)}
</div>
<div className="form-row"> <div className="form-row">
<select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}> <select value={selectedLine} onChange={e => setSelectedLine(e.target.value)}>
<option value="">Kies lijn</option> <option value="">Kies lijn</option>
@@ -339,7 +404,7 @@ export default function ScheduleMaker({ token, user }) {
</div> </div>
<div className="form-row"> <div className="form-row">
<button type="submit">Dienst Aanmaken</button> <button type="submit">{editingScheduleId ? 'Dienst bijwerken' : 'Dienst aanmaken'}</button>
</div> </div>
</form> </form>
)} )}
@@ -524,6 +589,51 @@ export default function ScheduleMaker({ token, user }) {
</table> </table>
</div> </div>
<h3 style={{ marginTop: '2rem' }}>Vaste diensten beheren</h3>
<p style={{ marginTop: 0, color: '#666' }}>
Pas bestaande vaste diensten aan of verwijder ze direct vanuit dit overzicht.
</p>
<div style={{ display: 'grid', gap: '1rem' }}>
{scheduleTemplates.length === 0 ? (
<div className="message">Nog geen vaste diensten gevonden.</div>
) : (
scheduleTemplates
.slice()
.sort((a, b) => a.lineNumber - b.lineNumber || new Date(a.startTime) - new Date(b.startTime))
.map(schedule => (
<article key={`template-${schedule.id}`} className="schedule-card" style={{ position: 'relative' }}>
<div className="schedule-header">
<strong>Lijn {schedule.lineNumber}</strong>
<span className="schedule-time">
{formatTime(schedule.startTime)} - {formatTime(schedule.endTime)}
</span>
</div>
<div className="schedule-details">
<div>{schedule.busName}</div>
<div className="schedule-plate">{schedule.licensePlate}</div>
{schedule.driverName && (
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.driverName}
</div>
)}
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '0.25rem' }}>
{schedule.recurrenceLabel || getWeekdayLabel(schedule.weekdays)}
</div>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<button type="button" className="secondary" onClick={() => startEditingSchedule(schedule)}>
Bewerken
</button>
<button type="button" className="danger" onClick={() => setDeleteConfirm(schedule)}>
Verwijderen
</button>
</div>
</article>
))
)}
</div>
{message && <div className="message">{message}</div>} {message && <div className="message">{message}</div>}
{deleteConfirm && ( {deleteConfirm && (
@@ -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<RecurringSchedulesScreen> createState() => _RecurringSchedulesScreenState();
}
class _RecurringSchedulesScreenState extends State<RecurringSchedulesScreen> {
final _scheduleService = ScheduleService();
late Future<List<sched.ServiceSchedule>> _schedulesFuture;
String _selectedSortBy = 'line'; // 'line', 'time', 'weekday'
final Set<int> _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<int> 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<sched.ServiceSchedule> _sortSchedules(List<sched.ServiceSchedule> schedules) {
final sorted = List<sched.ServiceSchedule>.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<int, List<sched.ServiceSchedule>> _groupByLine(List<sched.ServiceSchedule> schedules) {
final grouped = <int, List<sched.ServiceSchedule>>{};
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<String>(
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<List<sched.ServiceSchedule>>(
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<int>) 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,
),
],
),
],
),
),
);
}
}
@@ -6,6 +6,7 @@ import '../models/navigation.dart';
import '../services/auth_service.dart'; import '../services/auth_service.dart';
import '../services/schedule_service.dart'; import '../services/schedule_service.dart';
import '../screens/navigation_screen.dart'; import '../screens/navigation_screen.dart';
import '../screens/recurring_schedules_screen.dart';
class ScheduleOverviewScreen extends StatefulWidget { class ScheduleOverviewScreen extends StatefulWidget {
const ScheduleOverviewScreen({super.key}); const ScheduleOverviewScreen({super.key});
@@ -14,17 +15,25 @@ class ScheduleOverviewScreen extends StatefulWidget {
State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState(); State<ScheduleOverviewScreen> createState() => _ScheduleOverviewScreenState();
} }
class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> { class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with TickerProviderStateMixin {
final _scheduleService = ScheduleService(); final _scheduleService = ScheduleService();
final _authService = AuthService(); final _authService = AuthService();
late Future<List<sched.ServiceSchedule>> _scheduleFuture; late Future<List<sched.ServiceSchedule>> _scheduleFuture;
DateTime _selectedDate = DateTime.now(); DateTime _selectedDate = DateTime.now();
late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scheduleFuture = _scheduleService.getSchedules(_selectedDate); _scheduleFuture = _scheduleService.getSchedules(_selectedDate);
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
} }
Future<void> _handleLogout() async { Future<void> _handleLogout() async {
@@ -117,10 +126,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
onPressed: () async { onPressed: () async {
try { try {
await _scheduleService.updateScheduleStatus(svc.id, 'in_progress'); await _scheduleService.updateScheduleStatus(svc.id, 'in_progress');
if (!mounted) return; if (!mounted) return;
// Close the sheet and start navigation with provided stops
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
final routeObj = sched.Route( final routeObj = sched.Route(
@@ -210,13 +216,19 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final dateStr =
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}';
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Today on the Road'), title: const Text('Diensten'),
centerTitle: true,
elevation: 0, 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: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),
@@ -225,174 +237,179 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
), ),
], ],
), ),
body: Container( body: TabBarView(
decoration: BoxDecoration( controller: _tabController,
gradient: LinearGradient( children: [
colors: Theme.of(context).brightness == Brightness.dark _DailyScheduleTab(
? const [Color(0xFF0B1220), Color(0xFF111827)] scheduleService: _scheduleService,
: const [Color(0xFFF1F5F9), Color(0xFFE2E8F0)], selectedDate: _selectedDate,
begin: Alignment.topCenter, onSelectDate: _selectDate,
end: Alignment.bottomCenter, 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<List<sched.ServiceSchedule>> 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: [ child: Column(
Container( children: [
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), Container(
padding: const EdgeInsets.all(18), margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
decoration: BoxDecoration( padding: const EdgeInsets.all(18),
gradient: const LinearGradient( decoration: BoxDecoration(
colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)], gradient: const LinearGradient(
begin: Alignment.topLeft, colors: [Color(0xFF0F172A), Color(0xFF1D4ED8)],
end: Alignment.bottomRight, 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( borderRadius: BorderRadius.circular(24),
crossAxisAlignment: CrossAxisAlignment.start, boxShadow: [
children: [ BoxShadow(
Text( color: Colors.black.withOpacity(0.16),
'Geplande diensten', blurRadius: 22,
style: Theme.of(context).textTheme.labelSmall?.copyWith( offset: const Offset(0, 10),
color: Colors.white.withOpacity(0.78), ),
), ],
), ),
const SizedBox(height: 6), child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
dateStr, children: [
style: Theme.of(context).textTheme.headlineMedium?.copyWith( Text(
color: Colors.white, 'Geplande diensten',
fontWeight: FontWeight.w800, style: Theme.of(context).textTheme.labelSmall?.copyWith(
), color: Colors.white.withOpacity(0.78),
), ),
const SizedBox(height: 6), ),
Text( const SizedBox(height: 6),
'Automatisch berekende eindtijden, haltes en herhalende weekdagen', Text(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( dateStr,
color: Colors.white.withOpacity(0.84), style: Theme.of(context).textTheme.headlineMedium?.copyWith(
), color: Colors.white,
), fontWeight: FontWeight.w800,
const SizedBox(height: 14), ),
Align( ),
alignment: Alignment.centerRight, const SizedBox(height: 6),
child: SizedBox( Text(
height: 48, 'Automatisch berekende eindtijden, haltes en herhalende weekdagen',
child: OutlinedButton.icon( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
onPressed: _selectDate, color: Colors.white.withOpacity(0.84),
icon: const Icon(Icons.calendar_today, size: 18), ),
label: const Text('Wijzig datum'), ),
style: OutlinedButton.styleFrom( const SizedBox(height: 14),
foregroundColor: Colors.white, Align(
side: BorderSide(color: Colors.white.withOpacity(0.28)), alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16), 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<List<sched.ServiceSchedule>>( Expanded(
future: _scheduleFuture, child: FutureBuilder<List<sched.ServiceSchedule>>(
builder: (context, snapshot) { future: scheduleFuture,
if (snapshot.connectionState == ConnectionState.waiting) { builder: (context, snapshot) {
return const Center(child: CircularProgressIndicator()); if (snapshot.connectionState == ConnectionState.waiting) {
} return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) { if (snapshot.hasError) {
return Center( return Center(
child: Padding( child: Column(
padding: const EdgeInsets.symmetric(horizontal: 24), mainAxisAlignment: MainAxisAlignment.center,
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, Icon(Icons.error_outline, size: 48, color: colorScheme.error),
children: [ const SizedBox(height: 16),
Icon( Text('Fout bij laden: ${snapshot.error}', textAlign: TextAlign.center),
Icons.error_outline, const SizedBox(height: 16),
size: 64, FilledButton(
color: colorScheme.error, onPressed: onRefresh,
), child: const Text('Opnieuw proberen'),
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'),
),
],
), ),
), ],
);
}
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), const SizedBox(height: 16),
+172 -44
View File
@@ -240,6 +240,35 @@ function expandRecurringScheduleRows(rows) {
return expandedRows; 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()) { function scheduleMatchesFilters(schedule, filters, routeStopsByLine = new Map()) {
if (!schedule || !filters) return true; if (!schedule || !filters) return true;
@@ -614,7 +643,7 @@ async function ensureScheduleStopProgressTable() {
AND tc.table_name = 'schedule_stop_progress' AND tc.table_name = 'schedule_stop_progress'
AND tc.constraint_type = 'UNIQUE' AND tc.constraint_type = 'UNIQUE'
GROUP BY tc.constraint_name 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; LIMIT 1;
IF constraint_name IS NOT NULL THEN 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) => { app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
const templatesOnly = String(req.query.templates || req.query.includeTemplates || '').toLowerCase() === 'true' || String(req.query.templates || req.query.includeTemplates || '') === '1';
if (DEV_MOCK) { if (DEV_MOCK) {
const routeStopsByLine = new Map(); const routeStopsByLine = new Map();
for (const routeStop of _mock.routeStops || []) { 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 busLine = _mock.busLines.find(bl => bl.id === s.busLineId);
const bus = _mock.fleetBuses.find(b => b.id === s.busId); const bus = _mock.fleetBuses.find(b => b.id === s.busId);
return { return {
@@ -2001,7 +2032,9 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
driverId: s.driverId || null, driverId: s.driverId || null,
estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60, estimatedDurationMinutes: busLine?.estimatedDurationMinutes || 60,
}; };
})) });
const schedules = (templatesOnly ? rawSchedules : expandRecurringScheduleRows(rawSchedules))
.filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine)) .filter(row => scheduleMatchesFilters(row, parseScheduleFilters(req.query), routeStopsByLine))
.map(s => ({ .map(s => ({
...s, ...s,
@@ -2080,48 +2113,13 @@ app.get('/admin/schedules', authMiddleware, adminMiddleware, async (req, res) =>
} }
const filters = parseScheduleFilters(req.query); 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)); .filter(row => scheduleMatchesFilters(row, filters, routeStopsByLine));
const schedules = expandedSchedules.map(row => { const schedules = expandedSchedules
const weekdays = parseWeekdaysValue(row.weekdays); .map(row => mapAdminScheduleRow(row, routeStopsByLine))
const busLineId = Number(row.bus_line_id); .sort((a, b) => new Date(a.startTime) - new Date(b.startTime));
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));
res.json({ schedules }); res.json({ schedules });
} catch (err) { } 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) => { app.delete('/admin/schedules/:scheduleId', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
const { scheduleId } = req.params; const { scheduleId } = req.params;
@@ -3191,6 +3308,17 @@ app.get('/public/schedules/:scheduleId/progress', async (req, res) => {
}); });
const PORT = process.env.PORT || 5001; 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; module.exports = app;