Enhance schedule management; add recurring schedules screen, implement schedule editing functionality, and improve schedule loading and filtering in ScheduleMaker
This commit is contained in:
@@ -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),
|
||||||
@@ -523,4 +542,4 @@ String _weekdayLabel(List<int> weekdays) {
|
|||||||
return 'Dagelijks';
|
return 'Dagelijks';
|
||||||
}
|
}
|
||||||
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
|
return weekdays.where((day) => day >= 0 && day < labels.length).map((day) => labels[day]).join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-44
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user