Add edit and delete functionality for buses and stops in admin panel
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -13,6 +13,7 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
const [editingLine, setEditingLine] = useState(null)
|
||||||
|
|
||||||
const loadBusLines = async () => {
|
const loadBusLines = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -41,10 +42,14 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setMessage(null)
|
setMessage(null)
|
||||||
try {
|
try {
|
||||||
|
// Map selected stop ids to names (server currently stores names)
|
||||||
|
const startStopName = stops.find(s => String(s.id) === String(startStop))?.name || startStop
|
||||||
|
const endStopName = stops.find(s => String(s.id) === String(endStop))?.name || endStop
|
||||||
|
|
||||||
const res = await client.post('/admin/bus-lines', {
|
const res = await client.post('/admin/bus-lines', {
|
||||||
lineNumber: Number(lineNumber),
|
lineNumber: Number(lineNumber),
|
||||||
startStop,
|
startStop: startStopName,
|
||||||
endStop,
|
endStop: endStopName,
|
||||||
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
||||||
description,
|
description,
|
||||||
})
|
})
|
||||||
@@ -91,6 +96,7 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
<div>{line.estimatedDurationMinutes} min</div>
|
<div>{line.estimatedDurationMinutes} min</div>
|
||||||
{line.description ? <div className="muted">{line.description}</div> : null}
|
{line.description ? <div className="muted">{line.description}</div> : null}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className="danger"
|
className="danger"
|
||||||
onClick={() => setDeleteConfirm(line)}
|
onClick={() => setDeleteConfirm(line)}
|
||||||
@@ -98,6 +104,21 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setEditingLine({
|
||||||
|
id: line.id,
|
||||||
|
lineNumber: line.lineNumber,
|
||||||
|
startStop: (stops.find(s => s.name === line.startStop) || {}).id || line.startStop,
|
||||||
|
endStop: (stops.find(s => s.name === line.endStop) || {}).id || line.endStop,
|
||||||
|
estimatedDurationMinutes: line.estimatedDurationMinutes,
|
||||||
|
description: line.description || '',
|
||||||
|
})}
|
||||||
|
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
@@ -110,13 +131,13 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
|
<select value={startStop} onChange={e => setStartStop(e.target.value)}>
|
||||||
<option value="">Start stop</option>
|
<option value="">Start stop</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => (
|
||||||
<option key={stop.id} value={stop.name}>#{stop.id} {stop.name}</option>
|
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
|
<select value={endStop} onChange={e => setEndStop(e.target.value)}>
|
||||||
<option value="">End stop</option>
|
<option value="">End stop</option>
|
||||||
{stops.map(stop => (
|
{stops.map(stop => (
|
||||||
<option key={stop.id} value={stop.name}>#{stop.id} {stop.name}</option>
|
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +151,60 @@ export default function BusLinesManager({ token, user }) {
|
|||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{editingLine && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Edit Bus Line</h3>
|
||||||
|
<form onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const startStopName = stops.find(s => String(s.id) === String(editingLine.startStop))?.name || editingLine.startStop
|
||||||
|
const endStopName = stops.find(s => String(s.id) === String(editingLine.endStop))?.name || editingLine.endStop
|
||||||
|
|
||||||
|
await client.patch(`/admin/bus-lines/${editingLine.id}`, {
|
||||||
|
lineNumber: Number(editingLine.lineNumber),
|
||||||
|
startStop: startStopName,
|
||||||
|
endStop: endStopName,
|
||||||
|
estimatedDurationMinutes: Number(editingLine.estimatedDurationMinutes),
|
||||||
|
description: editingLine.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
setMessage('Bus line updated')
|
||||||
|
setEditingLine(null)
|
||||||
|
await loadBusLines()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}} className="stack-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<input value={editingLine.lineNumber} onChange={e => setEditingLine({...editingLine, lineNumber: e.target.value})} />
|
||||||
|
<select value={editingLine.startStop} onChange={e => setEditingLine({...editingLine, startStop: e.target.value})}>
|
||||||
|
<option value="">Start stop</option>
|
||||||
|
{stops.map(stop => (
|
||||||
|
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select value={editingLine.endStop} onChange={e => setEditingLine({...editingLine, endStop: e.target.value})}>
|
||||||
|
<option value="">End stop</option>
|
||||||
|
{stops.map(stop => (
|
||||||
|
<option key={stop.id} value={stop.id}>#{stop.id} {stop.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<input value={editingLine.estimatedDurationMinutes} onChange={e => setEditingLine({...editingLine, estimatedDurationMinutes: e.target.value})} />
|
||||||
|
<input value={editingLine.description} onChange={e => setEditingLine({...editingLine, description: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" type="button" onClick={() => setEditingLine(null)}>Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{deleteConfirm && (
|
{deleteConfirm && (
|
||||||
<div style={overlay}>
|
<div style={overlay}>
|
||||||
<div style={modal}>
|
<div style={modal}>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default function FleetManager({ token, user }) {
|
|||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [canCreateBus, setCanCreateBus] = useState(true)
|
const [canCreateBus, setCanCreateBus] = useState(true)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
|
const [editingBus, setEditingBus] = useState(null)
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
|
||||||
const loadBuses = async () => {
|
const loadBuses = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -65,6 +67,12 @@ export default function FleetManager({ token, user }) {
|
|||||||
<strong>#{bus.id} {bus.name}</strong>
|
<strong>#{bus.id} {bus.name}</strong>
|
||||||
<div>Seats: {bus.seatCapacity}</div>
|
<div>Seats: {bus.seatCapacity}</div>
|
||||||
<div>Plate: {bus.licensePlate}</div>
|
<div>Plate: {bus.licensePlate}</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button className="secondary" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setEditingBus(bus)}>Edit</button>
|
||||||
|
<button className="danger" style={{ marginTop: '0.5rem', width: '100%' }} onClick={() => setDeleteConfirm(bus)}>Delete</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +91,62 @@ export default function FleetManager({ token, user }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message && <div className="message">{message}</div>}
|
{message && <div className="message">{message}</div>}
|
||||||
|
|
||||||
|
{editingBus && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Edit Bus</h3>
|
||||||
|
<form onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
await client.patch(`/admin/fleet/buses/${editingBus.id}`, {
|
||||||
|
name: editingBus.name,
|
||||||
|
seatCapacity: Number(editingBus.seatCapacity),
|
||||||
|
licensePlate: editingBus.licensePlate,
|
||||||
|
})
|
||||||
|
setMessage('Bus updated')
|
||||||
|
setEditingBus(null)
|
||||||
|
await loadBuses()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}} className="stack-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<input value={editingBus.name} onChange={e => setEditingBus({...editingBus, name: e.target.value})} />
|
||||||
|
<input value={editingBus.seatCapacity} onChange={e => setEditingBus({...editingBus, seatCapacity: e.target.value})} />
|
||||||
|
<input value={editingBus.licensePlate} onChange={e => setEditingBus({...editingBus, licensePlate: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" type="button" onClick={() => setEditingBus(null)}>Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Delete Bus?</h3>
|
||||||
|
<p><strong>{deleteConfirm.name}</strong></p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" onClick={() => setDeleteConfirm(null)}>Cancel</button>
|
||||||
|
<button className="danger" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await client.delete(`/admin/fleet/buses/${deleteConfirm.id}`)
|
||||||
|
setMessage('Bus deleted')
|
||||||
|
setDeleteConfirm(null)
|
||||||
|
await loadBuses()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,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 [routeStopsByLine, setRouteStopsByLine] = useState({})
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [selectedLine, setSelectedLine] = useState('')
|
const [selectedLine, setSelectedLine] = useState('')
|
||||||
@@ -138,6 +139,18 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
|
return date.toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch route stops for a given line and cache them in state
|
||||||
|
const loadRouteStops = async (busLineId) => {
|
||||||
|
if (!busLineId) return
|
||||||
|
try {
|
||||||
|
const res = await client.get(`/admin/bus-lines/${busLineId}/stops`)
|
||||||
|
const routeStops = res.data.routeStops || []
|
||||||
|
setRouteStopsByLine(prev => ({ ...prev, [busLineId]: routeStops }))
|
||||||
|
} catch (err) {
|
||||||
|
// ignore silently, message already used elsewhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getCandidateWindow = () => {
|
const getCandidateWindow = () => {
|
||||||
const line = busLines.find(l => String(l.id) === String(selectedLine))
|
const line = busLines.find(l => String(l.id) === String(selectedLine))
|
||||||
if (!line || !selectedDate || !startTime) return null
|
if (!line || !selectedDate || !startTime) return null
|
||||||
@@ -385,25 +398,67 @@ export default function ScheduleMaker({ token, user }) {
|
|||||||
|
|
||||||
{expandedSchedule === schedule.id && (
|
{expandedSchedule === schedule.id && (
|
||||||
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
|
<div style={{ marginTop: '0.5rem', paddingTop: '0.5rem', borderTop: '1px solid #eee' }}>
|
||||||
{schedule.departureTimes && schedule.departureTimes.length > 0 ? (
|
{(() => {
|
||||||
|
const lineId = schedule.busLineId
|
||||||
|
const cached = routeStopsByLine[lineId]
|
||||||
|
if (!cached) {
|
||||||
|
// Load and show loading text
|
||||||
|
loadRouteStops(lineId)
|
||||||
|
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Laden haltes...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.length === 0) {
|
||||||
|
return <p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render editable list of stops with ETA inputs
|
||||||
|
return (
|
||||||
<table style={{ width: '100%', fontSize: '0.8rem' }}>
|
<table style={{ width: '100%', fontSize: '0.8rem' }}>
|
||||||
<tbody>
|
<tbody>
|
||||||
{schedule.departureTimes.map(stop => (
|
{cached.map(rs => {
|
||||||
<tr key={stop.stopId}>
|
const departureDate = new Date(schedule.startTime)
|
||||||
<td style={{ paddingRight: '0.5rem' }}>
|
departureDate.setMinutes(departureDate.getMinutes() + (rs.estimatedArrivalMinutes || 0))
|
||||||
<strong>{stop.order}.</strong>
|
return (
|
||||||
</td>
|
<tr key={rs.id}>
|
||||||
<td style={{ flex: 1 }}>{stop.stopName}</td>
|
<td style={{ paddingRight: '0.5rem' }}><strong>{rs.stopOrder}.</strong></td>
|
||||||
<td style={{ textAlign: 'right', color: '#666' }}>
|
<td style={{ flex: 1 }}>{rs.name}</td>
|
||||||
{formatTime(stop.departureTime)}
|
<td style={{ textAlign: 'right', color: '#666' }}>{formatTime(departureDate.toISOString())}</td>
|
||||||
|
<td style={{ paddingLeft: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rs.estimatedArrivalMinutes ?? 0}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value === '' ? '' : Number(e.target.value)
|
||||||
|
setRouteStopsByLine(prev => ({
|
||||||
|
...prev,
|
||||||
|
[lineId]: prev[lineId].map(item => item.id === rs.id ? { ...item, estimatedArrivalMinutes: val } : item)
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
style={{ width: '5rem' }}
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={async () => {
|
||||||
|
const newVal = Number((routeStopsByLine[lineId].find(i => i.id === rs.id)?.estimatedArrivalMinutes) || 0)
|
||||||
|
try {
|
||||||
|
await client.post(`/admin/bus-lines/${lineId}/stops`, {
|
||||||
|
stopId: rs.stopId,
|
||||||
|
stopOrder: rs.stopOrder,
|
||||||
|
estimatedArrivalMinutes: newVal,
|
||||||
|
})
|
||||||
|
setMessage('ETA updated')
|
||||||
|
await loadRouteStops(lineId)
|
||||||
|
await loadSchedules()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}} style={{ marginLeft: '0.5rem' }}>Save</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
)
|
||||||
<p style={{ fontSize: '0.85rem', color: '#999' }}>Geen haltes gekoppeld</p>
|
})()}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export default function StopsManager({ token, user }) {
|
|||||||
const [message, setMessage] = useState(null)
|
const [message, setMessage] = useState(null)
|
||||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
const [deleteConfirm, setDeleteConfirm] = useState(null)
|
||||||
|
const [editingStop, setEditingStop] = useState(null)
|
||||||
|
|
||||||
|
|
||||||
const loadStops = async () => {
|
const loadStops = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +86,15 @@ export default function StopsManager({ token, user }) {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setEditingStop(stop)}
|
||||||
|
style={{ marginTop: '0.5rem', width: '100%' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +128,40 @@ export default function StopsManager({ token, user }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingStop && (
|
||||||
|
<div style={overlay}>
|
||||||
|
<div style={modal}>
|
||||||
|
<h3>Edit Stop</h3>
|
||||||
|
<form onSubmit={async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setMessage(null)
|
||||||
|
try {
|
||||||
|
const res = await client.patch(`/admin/stops/${editingStop.id}`, {
|
||||||
|
name: editingStop.name,
|
||||||
|
latitude: parseFloat(editingStop.latitude),
|
||||||
|
longitude: parseFloat(editingStop.longitude),
|
||||||
|
})
|
||||||
|
setMessage('Stop updated')
|
||||||
|
setEditingStop(null)
|
||||||
|
await loadStops()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||||
|
}
|
||||||
|
}} className="stack-form">
|
||||||
|
<div className="form-row">
|
||||||
|
<input value={editingStop.name} onChange={e => setEditingStop({...editingStop, name: e.target.value})} />
|
||||||
|
<input value={editingStop.latitude} onChange={e => setEditingStop({...editingStop, latitude: e.target.value})} />
|
||||||
|
<input value={editingStop.longitude} onChange={e => setEditingStop({...editingStop, longitude: e.target.value})} />
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ marginTop: '1rem' }}>
|
||||||
|
<button className="secondary" type="button" onClick={() => setEditingStop(null)}>Cancel</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import '../services/schedule_service.dart';
|
|||||||
|
|
||||||
class NavigationScreen extends StatefulWidget {
|
class NavigationScreen extends StatefulWidget {
|
||||||
final schedule.Route route;
|
final schedule.Route route;
|
||||||
|
final List<NavigationPoint>? initialStops;
|
||||||
|
|
||||||
const NavigationScreen({
|
const NavigationScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.route,
|
required this.route,
|
||||||
|
this.initialStops,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -54,7 +56,11 @@ class _NavigationScreenState extends State<NavigationScreen> {
|
|||||||
final currentLoc = await _navigationService.getCurrentLocation();
|
final currentLoc = await _navigationService.getCurrentLocation();
|
||||||
setState(() => _currentLocation = currentLoc);
|
setState(() => _currentLocation = currentLoc);
|
||||||
|
|
||||||
// Fetch stops for the bus line from the API (if available)
|
// Use provided initial stops when available, otherwise fetch from API
|
||||||
|
if (widget.initialStops != null) {
|
||||||
|
_stops = widget.initialStops;
|
||||||
|
_stopsFetchError = null;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
_stops = await _scheduleService.getStops(widget.route.busLine.id);
|
_stops = await _scheduleService.getStops(widget.route.busLine.id);
|
||||||
_stopsFetchError = null;
|
_stopsFetchError = null;
|
||||||
@@ -62,6 +68,7 @@ class _NavigationScreenState extends State<NavigationScreen> {
|
|||||||
_stops = [];
|
_stops = [];
|
||||||
_stopsFetchError = e.toString().replaceAll('Exception: ', '');
|
_stopsFetchError = e.toString().replaceAll('Exception: ', '');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine start and end points: prefer stop locations if available.
|
// Determine start and end points: prefer stop locations if available.
|
||||||
// If no stops are returned from the API, geocode the stop names.
|
// If no stops are returned from the API, geocode the stop names.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/schedule.dart';
|
import '../models/schedule.dart' as sched;
|
||||||
|
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';
|
||||||
|
|
||||||
class ScheduleOverviewScreen extends StatefulWidget {
|
class ScheduleOverviewScreen extends StatefulWidget {
|
||||||
const ScheduleOverviewScreen({super.key});
|
const ScheduleOverviewScreen({super.key});
|
||||||
@@ -14,7 +16,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
final _scheduleService = ScheduleService();
|
final _scheduleService = ScheduleService();
|
||||||
final _authService = AuthService();
|
final _authService = AuthService();
|
||||||
|
|
||||||
late Future<List<ServiceSchedule>> _scheduleFuture;
|
late Future<List<sched.ServiceSchedule>> _scheduleFuture;
|
||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -61,9 +63,9 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showScheduleDetails(ServiceSchedule schedule) async {
|
Future<void> _showScheduleDetails(sched.ServiceSchedule svc) async {
|
||||||
try {
|
try {
|
||||||
final stops = await _scheduleService.getScheduleStops(schedule.id);
|
final stops = await _scheduleService.getScheduleStops(svc.id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
@@ -82,19 +84,19 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Lijn ${schedule.lineNumber}',
|
'Lijn ${svc.lineNumber}',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
schedule.busName,
|
svc.busName,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${_formatTime(schedule.startTime)} - ${_formatTime(schedule.endTime)}',
|
'${_formatTime(svc.startTime)} - ${_formatTime(svc.endTime)}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -102,11 +104,56 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_InfoChip(label: schedule.status),
|
_InfoChip(label: svc.status),
|
||||||
_InfoChip(label: schedule.isRecurring ? _weekdayLabel(schedule.weekdays) : 'Eenmalig'),
|
_InfoChip(label: svc.isRecurring ? _weekdayLabel(svc.weekdays) : 'Eenmalig'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// Close the sheet and start navigation with provided stops
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
|
||||||
|
final routeObj = sched.Route(
|
||||||
|
id: (svc.id is num) ? (svc.id as num).toInt() : int.tryParse(svc.id.toString()) ?? 0,
|
||||||
|
busLine: sched.BusLine(
|
||||||
|
id: svc.busLineId,
|
||||||
|
lineNumber: svc.lineNumber,
|
||||||
|
startStop: '',
|
||||||
|
endStop: '',
|
||||||
|
estimatedDuration: 0,
|
||||||
|
),
|
||||||
|
busType: sched.BusType(
|
||||||
|
id: svc.busId,
|
||||||
|
name: svc.busName,
|
||||||
|
seatCapacity: 0,
|
||||||
|
licensePlate: '',
|
||||||
|
),
|
||||||
|
startTime: svc.startTime,
|
||||||
|
endTime: svc.endTime,
|
||||||
|
status: svc.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
final navStops = stops
|
||||||
|
.map((s) => NavigationPoint(
|
||||||
|
latitude: s.latitude,
|
||||||
|
longitude: s.longitude,
|
||||||
|
name: s.name,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => NavigationScreen(route: routeObj, initialStops: navStops),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.navigation),
|
||||||
|
label: const Text('Start route'),
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
'Haltes',
|
'Haltes',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
@@ -121,7 +168,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
...stops.map((stop) {
|
...stops.map((stop) {
|
||||||
final departureTime = schedule.startTime.add(
|
final departureTime = svc.startTime.add(
|
||||||
Duration(minutes: stop.estimatedArrivalMinutes),
|
Duration(minutes: stop.estimatedArrivalMinutes),
|
||||||
);
|
);
|
||||||
return Card(
|
return Card(
|
||||||
@@ -240,7 +287,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<List<ServiceSchedule>>(
|
child: FutureBuilder<List<sched.ServiceSchedule>>(
|
||||||
future: _scheduleFuture,
|
future: _scheduleFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
@@ -339,7 +386,7 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ScheduleSummaryCard extends StatelessWidget {
|
class _ScheduleSummaryCard extends StatelessWidget {
|
||||||
final ServiceSchedule schedule;
|
final sched.ServiceSchedule schedule;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ScheduleSummaryCard({
|
const _ScheduleSummaryCard({
|
||||||
|
|||||||
@@ -731,9 +731,130 @@ app.post('/admin/fleet/buses', authMiddleware, adminMiddleware, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Update fleet bus
|
||||||
|
// ============================================
|
||||||
|
app.patch('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { busId } = req.params;
|
||||||
|
const { name, seatCapacity, licensePlate } = req.body;
|
||||||
|
|
||||||
|
if (!busId || !Number.isFinite(Number(busId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bus_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const bus = _mock.fleetBuses.find(b => Number(b.id) === Number(busId));
|
||||||
|
if (!bus) return res.status(404).json({ error: 'bus_not_found' });
|
||||||
|
if (name !== undefined) bus.name = name;
|
||||||
|
if (seatCapacity !== undefined) bus.seatCapacity = Number(seatCapacity);
|
||||||
|
if (licensePlate !== undefined) bus.licensePlate = licensePlate;
|
||||||
|
bus.updatedAt = new Date().toISOString();
|
||||||
|
return res.json({ bus });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
UPDATE fleet.bus
|
||||||
|
SET name = COALESCE($1, name), seat_capacity = COALESCE($2, seat_capacity), license_plate = COALESCE($3, license_plate), updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING id, name, seat_capacity, license_plate
|
||||||
|
`, [name ?? null, seatCapacity ?? null, licensePlate ?? null, busId]);
|
||||||
|
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'bus_not_found' });
|
||||||
|
|
||||||
|
const bus = result.rows[0];
|
||||||
|
res.json({ bus: { id: bus.id, name: bus.name, seatCapacity: bus.seat_capacity, licensePlate: bus.license_plate } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Delete fleet bus
|
||||||
|
// ============================================
|
||||||
|
app.delete('/admin/fleet/buses/:busId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { busId } = req.params;
|
||||||
|
if (!busId || !Number.isFinite(Number(busId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bus_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const idx = _mock.fleetBuses.findIndex(b => Number(b.id) === Number(busId));
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'bus_not_found' });
|
||||||
|
const deleted = _mock.fleetBuses.splice(idx, 1)[0];
|
||||||
|
return res.json({ success: true, bus: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`DELETE FROM fleet.bus WHERE id = $1 RETURNING id, name`, [busId]);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'bus_not_found' });
|
||||||
|
res.json({ success: true, bus: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ADMIN - Link stop to bus line
|
// ADMIN - Link stop to bus line
|
||||||
// ============================================
|
// ============================================
|
||||||
|
app.get('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { busLineId } = req.params;
|
||||||
|
if (!busLineId || !Number.isFinite(Number(busLineId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_bus_line_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const stops = _mock.routeStops
|
||||||
|
.filter(rs => String(rs.busLineId) === String(busLineId))
|
||||||
|
.sort((a, b) => a.stopOrder - b.stopOrder)
|
||||||
|
.map(rs => ({
|
||||||
|
id: rs.id,
|
||||||
|
busLineId: rs.busLineId,
|
||||||
|
stopId: rs.stopId,
|
||||||
|
stopOrder: rs.stopOrder,
|
||||||
|
estimatedArrivalMinutes: rs.estimatedArrivalMinutes,
|
||||||
|
name: rs.name,
|
||||||
|
latitude: rs.latitude,
|
||||||
|
longitude: rs.longitude,
|
||||||
|
}));
|
||||||
|
return res.json({ routeStops: stops });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
rs.id,
|
||||||
|
rs.bus_line_id,
|
||||||
|
rs.stop_id,
|
||||||
|
rs.stop_order,
|
||||||
|
rs.estimated_arrival_minutes,
|
||||||
|
s.name as stop_name,
|
||||||
|
s.latitude,
|
||||||
|
s.longitude
|
||||||
|
FROM transit.route_stops rs
|
||||||
|
JOIN transit.stops s ON s.id = rs.stop_id
|
||||||
|
WHERE rs.bus_line_id = $1
|
||||||
|
ORDER BY rs.stop_order ASC
|
||||||
|
`, [busLineId]);
|
||||||
|
|
||||||
|
res.json({ routeStops: result.rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
busLineId: row.bus_line_id,
|
||||||
|
stopId: row.stop_id,
|
||||||
|
stopOrder: row.stop_order,
|
||||||
|
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
||||||
|
name: row.stop_name,
|
||||||
|
latitude: parseFloat(row.latitude),
|
||||||
|
longitude: parseFloat(row.longitude),
|
||||||
|
})) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
|
app.post('/admin/bus-lines/:busLineId/stops', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { busLineId } = req.params;
|
const { busLineId } = req.params;
|
||||||
@@ -952,6 +1073,55 @@ app.delete('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ADMIN - Update a stop
|
||||||
|
// ============================================
|
||||||
|
app.patch('/admin/stops/:stopId', authMiddleware, adminMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { stopId } = req.params;
|
||||||
|
const { name, latitude, longitude } = req.body;
|
||||||
|
|
||||||
|
if (!stopId || !Number.isFinite(Number(stopId))) {
|
||||||
|
return res.status(400).json({ error: 'invalid_stop_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEV_MOCK) {
|
||||||
|
const stop = _mock.stops.find(s => Number(s.id) === Number(stopId));
|
||||||
|
if (!stop) return res.status(404).json({ error: 'stop_not_found' });
|
||||||
|
if (name !== undefined) stop.name = name;
|
||||||
|
if (latitude !== undefined) stop.latitude = parseFloat(latitude);
|
||||||
|
if (longitude !== undefined) stop.longitude = parseFloat(longitude);
|
||||||
|
stop.updatedAt = new Date().toISOString();
|
||||||
|
return res.json({ stop });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE transit.stops
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
latitude = COALESCE($2, latitude),
|
||||||
|
longitude = COALESCE($3, longitude),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
RETURNING id, name, latitude, longitude, created_at, updated_at`
|
||||||
|
, [name ?? null, latitude ?? null, longitude ?? null, stopId]);
|
||||||
|
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'stop_not_found' });
|
||||||
|
|
||||||
|
const stop = result.rows[0];
|
||||||
|
res.json({ stop: {
|
||||||
|
id: stop.id,
|
||||||
|
name: stop.name,
|
||||||
|
latitude: parseFloat(stop.latitude),
|
||||||
|
longitude: parseFloat(stop.longitude),
|
||||||
|
createdAt: stop.created_at,
|
||||||
|
updatedAt: stop.updated_at,
|
||||||
|
}});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// ADMIN - Delete a bus line
|
// ADMIN - Delete a bus line
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user